news 2026/11/2 11:26:55

.NET Core WebApi 文件服务:上传下载与Range断点续传

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET Core WebApi 文件服务:上传下载与Range断点续传

简介:这是一套面向.NET Core WebAPI 开发者与学习者的文件服务示例包,聚焦文件上传、下载及配套安全与性能处理,适合需要快速搭建或对照实现 RESTful 文件接口的中级开发者。压缩包共50个文件、约206KB,以25个 C# 源码、9个 JSON 配置、5个 csproj 项目文件为主,并含前端演示页、JavaScript 脚本、Dockerfile、解决方案文件与说明文档,覆盖服务端接口、中间件、客户端调用及缩略图等模块。已有1924人学习下载,具备一定参考热度。读者可据此理解 multipart/form-data 上传、IFormFile 接收、Content-Disposition 下载响应、流式输出、权限与路径安全、异步及分块处理等实现思路,并通过多项目结构快速搭建可运行的文件服务样例。

1. 从 413 说起:.NET Core WebApi 文件服务的边界与切口

这套 .NET Core WebApi 文件服务源码,最容易被低估的不是上传下载能不能跑,而是边界。第一次本地调试往往很顺,换到真实网络里,413、400、206 不生效、中文文件名乱码、临时文件越堆越多,随便一个都能让文件服务翻车。

它解决的是“文件从客户端进来、落到存储、再被安全取走”这条完整链路,不只是两个接口。上传侧要管大小限制、流式落盘、哈希校验和并发写;下载侧要管 Range、缓存协商、文件名编码和鉴权;存储侧要管元数据、软删除和清理。

适合正在搭文件服务、网盘后端、表单附件、内部资料库的开发者。新手可以照着代码块跑通闭环,熟手更值得看参数边界和踩坑顺序。资源把上传、下载、存储抽象和验证脚本放在同一个工程结构里,拿到后先改配置,再跑两条 curl,基本就能判断这套东西能不能落到自己的场景。

2. 上传管线:IFormFile 流式落盘与并发阀值

上传接口最常见的写法是控制器里接一个IFormFile,然后CopyToAsync到磁盘。写法没错,但只适合演示。真实场景里,文件可能几百 MB,多个用户同时传,磁盘顺序写能力有限,框架还会先把请求体解析成表单。内存缓冲、临时文件、请求体上限、表单上限,这几层只要有一层没对齐,就会出现“本地好的、线上 413”的玄学问题。

我的选型是:控制器只做鉴权和参数白名单,存储层负责临时文件、哈希和原子移动。小文件可以走框架缓冲,大文件必须让IFormFile打开只读流后直接落盘,不先读进byte[]。下面按配置、控制器、存储、并发四块拆。

2.1 先定上传边界:Kestrel、FormOptions 与代理层限制

上传限制不是一个开关,而是三层:Kestrel 的请求体上限、FormOptions 的表单解析上限、反向代理或网关的请求体上限。只改一处,另外两处仍会拦。常见做法是把业务最大文件大小加一点表单开销,三层设成同一个数量级,不要一个 100 MB、一个 1 GB。

配置项作用落地时注意
MaxRequestBodySize单次 HTTP 请求体上限要大于文件大小加表单字段开销
MultipartBodyLengthLimitmultipart 表单体上限小于 Kestrel 上限会在解析阶段直接拒绝
ValueLengthLimit普通表单字段长度防止超长字段占内存
MultipartHeadersLengthLimitmultipart 头部长度防止超长头部拖垮解析
using Microsoft.AspNetCore.Http.Features; var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(options => { // 示例值:按业务最大文件再加 1MB 表单开销,不要直接照抄到生产 options.Limits.MaxRequestBodySize = 512L * 1024 * 1024; }); builder.Services.Configure<FormOptions>(options => { // 与 Kestrel 上限保持同一数量级,避免 Form 解析提前拒绝 options.MultipartBodyLengthLimit = 512L * 1024 * 1024; options.ValueLengthLimit = 1024 * 1024; options.MultipartHeadersLengthLimit = 64 * 1024; }); builder.Services.AddControllers(); builder.Services.AddSingleton<IFileStorage, LocalFileStorage>(); builder.Services.AddSingleton<UploadConcurrencyLimiter>(); var app = builder.Build(); app.MapControllers(); app.Run();

这段代码的逻辑是先把请求体入口放宽到业务允许的最大值,再让表单解析器接受同样量级的 multipart 体。参数上,MaxRequestBodySize是 Kestrel 层硬闸,MultipartBodyLengthLimit是 Form 解析层硬闸,后者不能大于前者的有效范围;ValueLengthLimit限制普通字段,不要为了省事设成很大;MultipartHeadersLengthLimit防的是异常头部。实际部署时,前面的代理层也要同步请求体限制,否则请求还没到 Kestrel 就被截断,日志里只能看到 413 或连接重置。

注意:上传限制不是越大越好。文件越大,临时盘、超时、并发写和清理压力都会上来,按业务真实最大值设,不要直接开成无限制。

2.2 控制器只做三件事:鉴权、取文件、交给存储层

控制器里最容易写歪的是把文件直接File.WriteAllBytes到物理路径,或者用原始文件名拼路径。前者会阻塞请求线程,后者会带来路径穿越和覆盖风险。我的习惯是控制器只校验bizType白名单、判断空文件,然后调用IFileStorage.SaveAsync,物理路径和文件命名都不进控制器。

[ApiController] [Route("api/files")] public sealed class FilesController : ControllerBase { private readonly IFileStorage _storage; private readonly ILogger<FilesController> _logger; public FilesController(IFileStorage storage, ILogger<FilesController> logger) { _storage = storage; _logger = logger; } [HttpPost] [RequestSizeLimit(512L * 1024 * 1024)] public async Task<ActionResult<UploadResult>> Upload( IFormFile file, [FromForm] string? bizType, CancellationToken ct) { if (file is null || file.Length == 0) return BadRequest("empty file"); // 业务类型只允许白名单,避免用户输入直接进入存储路径 var type = string.IsNullOrWhiteSpace(bizType) ? "default" : bizType; if (!FileTypePolicy.IsAllowedBizType(type)) return BadRequest("invalid bizType"); var result = await _storage.SaveAsync(file, type, ct); _logger.LogInformation("uploaded {FileId} size={Size}", result.FileId, result.Size); return Ok(result); } }

逻辑说明:IFormFile由框架解析,控制器拿到的是只读流入口;RequestSizeLimit是动作级兜底,和全局 Kestrel 配置形成双保险;bizType只作为存储分区的白名单键,不直接拼任意路径。参数说明:file.Length是解析后的长度,和请求体大小不完全等价;ct要一路传到存储层,客户端断开时能尽早取消;返回UploadResult里至少要有FileId、Size、Sha256,方便后续下载和校验。

提示:不要信任上传请求里的Content-Type。它只能作为参考,最终下载时用的类型应该由服务端按扩展名映射或文件头探测决定。

2.3 落盘服务:临时文件、SHA256、原子移动

存储层的关键是“先临时、再校验、后移动”。直接写最终路径,一旦写入中断就会留下半文件;先写.tmp下的.part文件,算完 SHA256 再File.Move到最终目录,失败时临时文件交给清理服务处理,不会污染正式存储。最终文件名用 GUID,原始文件名只进元数据,不参与路径拼接。

public sealed class LocalFileStorage : IFileStorage { private readonly string _root; private readonly UploadConcurrencyLimiter _limiter; public LocalFileStorage(IConfiguration config, UploadConcurrencyLimiter limiter) { _root = Path.GetFullPath(config["Storage:Root"] ?? "./storage"); _limiter = limiter; Directory.CreateDirectory(_root); } public async Task<UploadResult> SaveAsync(IFormFile file, string bizType, CancellationToken ct) { var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!FileTypePolicy.IsAllowedExtension(ext)) throw new InvalidOperationException("extension not allowed"); var id = Guid.NewGuid().ToString("N"); var temp = Path.Combine(_root, ".tmp", id + ".part"); var final = Path.Combine(_root, bizType, id + ext); Directory.CreateDirectory(Path.GetDirectoryName(temp)!); Directory.CreateDirectory(Path.GetDirectoryName(final)!); using var lease = await _limiter.AcquireAsync(ct); await using (var input = file.OpenReadStream()) await using (var output = new FileStream( temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1024 * 128, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await input.CopyToAsync(output, 1024 * 128, ct); await output.FlushAsync(ct); } var hash = await Sha256Async(temp, ct); File.Move(temp, final, overwrite: false); return new UploadResult(id, final, file.Length, hash); } private static async Task<string> Sha256Async(string path, CancellationToken ct) { await using var stream = File.OpenRead(path); var hash = await SHA256.HashDataAsync(stream, ct); return Convert.ToHexString(hash).ToLowerInvariant(); } }

逻辑说明:id用 GUID 保证最终文件名不冲突;temp和final分属.tmp与业务分区;FileMode.CreateNew防止意外覆盖;FileOptions.Asynchronous | SequentialScan适合顺序写大文件;File.Move在同一卷内是原子操作,跨卷会退化成复制,所以临时目录要放在存储根同一卷下。参数说明:Storage:Root是存储根,bizType来自白名单,1024 * 128是复制缓冲区,太小会增加系统调用,太大对普通磁盘没有明显收益;Sha256既可用于去重,也可作为下载 ETag 的稳定值。

2.4 并发与背压:SemaphoreSlim 按卷限流

文件上传的瓶颈通常不是 CPU,而是磁盘写入和临时盘 IO。多个大文件同时写,机械盘会抖动,SSD 也可能被写队列拖慢。我的做法是在存储层加一个按存储卷的并发闸,默认 4 个同时写,机械盘可以降到 2,SSD 可以适当提高,但不要超过后端真实 IO 能力。

public sealed class UploadConcurrencyLimiter { private readonly SemaphoreSlim _semaphore; public UploadConcurrencyLimiter(IConfiguration config) { var max = config.GetValue("Storage:MaxConcurrentWrites", 4); _semaphore = new SemaphoreSlim(max, max); } public async Task<IDisposable> AcquireAsync(CancellationToken ct) { await _semaphore.WaitAsync(ct); return new Lease(_semaphore); } private sealed class Lease : IDisposable { private readonly SemaphoreSlim _semaphore; private bool _disposed; public Lease(SemaphoreSlim semaphore) => _semaphore = semaphore; public void Dispose() { if (_disposed) return; _disposed = true; _semaphore.Release(); } } }

逻辑说明:SemaphoreSlim控制同时进入落盘区的请求数,超过阈值的请求排队等待,形成背压而不是把磁盘打满;AcquireAsync返回一个可释放租约,using结束后释放名额。参数说明:Storage:MaxConcurrentWrites要按存储介质和部署副本数一起算,单实例 4 不代表集群也是 4;如果前面有多个 Pod 共享同一网络存储,限制要放在共享层或按总配额分配,不能每个实例都按本地磁盘算。

3. 下载链路:Range 分片、Content-Disposition 与缓存协商

下载接口最省事的写法是File.ReadAllBytes后返回FileContentResult,小文件能跑,大文件会把内存打爆,还不支持断点续传。文件下载的核心是流式输出和 HTTP 范围请求:客户端发Range: bytes=0-1023,服务端返回 206 和Content-Range;带If-None-Match且 ETag 匹配时返回 304;文件名要走Content-Disposition,中文名还要正确编码。

选型上,我优先用框架自带的PhysicalFile或FileStreamResult,把 Range 和条件请求交给它处理。自己拼Content-Range容易在边界值、多段 Range、If-Range上出错,下载断点续传一旦错位,客户端拿到的是坏文件,排查成本很高。

3.1 Range 与条件请求:206、304 的触发条件

Range 请求不是“服务端想返回 206 就返回 206”。客户端要带Range头,服务端要声明Accept-Ranges: bytes,返回时状态码 206,并带Content-Range: bytes 0-1023/总大小。如果客户端带If-Range,服务端还要比较 ETag 或 Last-Modified,匹配才返回片段,不匹配就回全量 200。条件请求则是If-None-Match与 ETag 比较,命中返回 304,没有正文。

请求头/响应头作用常见错误
Range请求指定字节区间服务端未开 Range,返回 200 全量
Content-Range告诉客户端当前片段和总大小总大小写成片段大小,续传错位
Accept-Ranges声明支持范围请求缺失后客户端不敢续传
ETag文件版本标识用弱 ETag 或每次变化,304 失效
If-None-Match条件请求大小写或引号处理不一致

3.2 控制器实现:按 fileId 查元数据,PhysicalFile 开 Range

下载接口不要接收物理路径,只接收fileId,由元数据换出storageKey,再由存储层解析成受控的物理路径。这样路径穿越、越权下载、文件被替换都能在元数据层拦住。PhysicalFile的enableRangeProcessing: true打开范围处理,lastModified和entityTag打开条件请求。

[HttpGet("{id}")] public async Task<IActionResult> Download(string id, CancellationToken ct) { var meta = await _metadata.FindAsync(id, ct); if (meta is null || meta.IsDeleted) return NotFound(); // 物理路径必须来自服务端元数据,不能由请求参数拼接 var path = _storage.ResolvePhysicalPath(meta.StorageKey); if (!System.IO.File.Exists(path)) return NotFound(); var etag = new EntityTagHeaderValue($"\"{meta.Sha256}\""); return PhysicalFile( path, meta.ContentType, meta.DownloadName, enableRangeProcessing: true, lastModified: meta.CreatedAt, entityTag: etag); }

逻辑说明:先用fileId查元数据,软删除或不存在都返回 404;ResolvePhysicalPath负责把相对storageKey解析到存储根下并做前缀校验;PhysicalFile负责流式输出、Range、Content-Range、Accept-Ranges和条件请求。参数说明:meta.ContentType应该是服务端登记的类型,不要直接用上传时的Content-Type;meta.DownloadName是展示名,不是物理文件名;lastModified用DateTimeOffset,entityTag用 SHA256 包在引号里,保证同一文件版本稳定。

注意:如果存储层返回的是网络流或对象存储流,不能用PhysicalFile,要用FileStreamResult并确认底层流支持Seek,否则 Range 处理会退化。

3.3 下载文件名与 Content-Disposition:中文名不要手拼

中文文件名下载乱码,十有八九是手拼了filename="测试.pdf"。HTTP 头里不能直接放非 ASCII,正确做法是用ContentDispositionHeaderValue.SetHttpFileName,它会生成filename和filename*两种形式,兼顾老客户端和现代客户端。还要防头注入:文件名里的引号、换行、分号都要让框架处理,不要自己字符串拼接。

[HttpGet("{id}/name")] public IActionResult PreviewName(string id) { var name = "测试文件.pdf"; // 实际从元数据取,示例只演示编码 var header = new ContentDispositionHeaderValue("attachment"); header.SetHttpFileName(name); Response.Headers.ContentDisposition = header.ToString(); return Ok(); }

逻辑说明:ContentDispositionHeaderValue负责把文件名编码成合法头部,SetHttpFileName会处理中文和特殊字符;attachment表示下载,inline表示尽量预览。参数说明:name要来自元数据里的DownloadName,不能直接取用户上传的原始文件名;如果业务允许预览,可以把attachment换成inline,但Content-Type仍要服务端决定。

3.4 用 curl 验证 206 和 304

下载链路不看代码看响应头。用curl -D -打印响应头,-o /dev/null丢弃正文,先确认 Range 返回 206,再确认 ETag 命中返回 304。这个验证比在浏览器里点下载可靠,浏览器会缓存、会重试、会隐藏中间状态。

# 1) 上传一个测试文件,拿到 fileId curl -s -X POST "http://localhost:5080/api/files" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@./test.bin" \ -F "bizType=default" # 2) 取前 1024 字节,期望 206 和 Content-Range curl -s -D - -o /dev/null \ -H "Authorization: Bearer $TOKEN" \ -H "Range: bytes=0-1023" \ "http://localhost:5080/api/files/$FILE_ID" # 3) 带 If-None-Match 再取一次,期望 304 curl -s -D - -o /dev/null \ -H "Authorization: Bearer $TOKEN" \ -H "If-None-Match: \"$ETAG\"" \ "http://localhost:5080/api/files/$FILE_ID"

逻辑说明:第一条命令上传并拿到fileId和哈希;第二条用Range检查服务端是否返回 206、Content-Range总大小是否正确;第三条用If-None-Match检查条件请求是否返回 304。参数说明:$TOKEN是鉴权令牌,$FILE_ID来自上传响应,$ETAG来自下载响应头或元数据;-D -把响应头打到标准输出,-o /dev/null避免把文件内容刷屏。如果第二条返回 200,先检查enableRangeProcessing和响应压缩,不要先怀疑客户端。

4. 存储抽象与生命周期:元数据表、临时文件与清理策略

文件服务如果只存文件不存元数据,后面做去重、审计、软删除、换存储后端都会很被动。我的做法是文件本体和元数据分开:文件按storageKey落盘,元数据记录id、原始名、下载名、大小、SHA256、业务类型、创建时间、删除标记。控制器只依赖IFileStorage和IFileMetadataStore,本地磁盘换对象存储时不用改业务代码。

生命周期也要提前定:上传中的.part文件、软删除后等待清理的文件、数据库里有记录但磁盘上找不到的孤儿记录,都要有清理策略。常见做法是临时文件保留 24 小时,软删除保留 7 天,清理任务每 30 分钟跑一次,失败只记录不退出。

4.1 存储接口与本地实现:控制器不碰物理路径

接口要窄,只暴露保存、解析路径、删除三个动作。保存返回UploadResult,解析路径只接受服务端生成的storageKey,删除按storageKey执行。这样控制器拿不到任意拼接路径的能力,安全边界收在存储层。

public interface IFileStorage { Task<UploadResult> SaveAsync(IFormFile file, string bizType, CancellationToken ct); string ResolvePhysicalPath(string storageKey); Task DeleteAsync(string storageKey, CancellationToken ct); } public sealed record UploadResult( string FileId, string StorageKey, long Size, string Sha256);

逻辑说明:SaveAsync负责临时文件、哈希、原子移动;ResolvePhysicalPath负责根路径校验;DeleteAsync负责物理删除或转软删除标记。参数说明:StorageKey应该是相对路径,例如default/8f3c....bin,不能是绝对路径;UploadResult里的StorageKey要写进元数据,下载时用它换物理路径。

4.2 元数据表:字段与索引

元数据表不用一开始就设计得很重,但几个关键字段

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 11:26:55

从SSL绕过到合规调试:HTTPS抓包与证书校验落地

简介&#xff1a;这份资源面向从事移动端安全测试与抓包分析的中高级读者&#xff0c;聚焦支付宝小程序在代理抓包时遇到的 SSL 证书校验问题&#xff0c;提供基于 Frida 的绕过思路与配套脚本&#xff0c;可用于授权范围内的安全研究、接口调试与协议学习。压缩包共 3 个文件&…

作者头像 李华
网站建设 2026/11/2 11:26:26

UE4纯C++直连Steam好友API集成指南

简介&#xff1a;本资源是面向UE4中级开发者的技术实践项目&#xff0c;聚焦Steam Friends API在虚幻引擎中的集成与应用&#xff0c;适用于希望拓展多人联机社交功能的C游戏开发人员。项目提供完整的可运行示例&#xff0c;涵盖好友列表获取、跨平台邀请发送与会话加入等核心流…

作者头像 李华
网站建设 2026/11/2 11:26:25

爱因斯坦棋AI教学:用概率MDP与分层MCTS训练不确定性决策能力

简介&#xff1a;这是一份基于Java实现的爱因斯坦棋&#xff08;Einstein Wrfelspiel&#xff09;策略游戏源码包&#xff0c;面向Java初学者与AI算法入门者&#xff0c;聚焦博弈逻辑建模与命令行交互式AI对弈开发。资源完整包含4个核心Java源文件&#xff08;如Einstein.java、…

作者头像 李华
网站建设 2026/11/2 11:24:58

WebSocket测试工具实战:握手、帧结构与压测避坑指南

简介&#xff1a;这是一套面向WebSocket开发与联调场景的测试工具合集&#xff0c;适合需要验证服务端与客户端通信有效性、排查握手与帧传输问题的开发者使用。资源围绕WebSocket协议的关键环节展开&#xff0c;涵盖连接建立、握手确认、文本与二进制帧收发、ping/pong心跳检测…

作者头像 李华
网站建设 2026/11/2 11:23:56

艾德克斯IT6900 USB直连控制:绕过VISA实现确定性低延迟通信

简介&#xff1a;本资源是一套基于C#开发的USB接口控制艾德克斯&#xff08;ITECH&#xff09;直流电源的完整工程源码&#xff0c;面向电子测试工程师、自动化测试开发者及高校电类专业高年级学生&#xff0c;解决仪器远程程控与自动化测试中USB通信实现难、SDK集成门槛高的实…

作者头像 李华
网站建设 2026/11/2 11:23:33

macOS翻页屏保Epoch Flip Clock:安装、兼容性排查与验证指南

简介&#xff1a;Epoch Flip Clock.saver.zip 是一款面向 Mac 用户的翻页时钟屏幕保护程序&#xff0c;适合追求桌面个性化与复古科技感的普通用户及开发者参考。它以传统机械翻页钟为设计灵感&#xff0c;在系统闲置时自动启动&#xff0c;既承担保护显示器的基本职责&#xf…

作者头像 李华