ASP.NET Core文件上传下载实战:流式处理、分片与存储层设计 简介这份资源面向正在学习或使用 .NET Core 开发 WebAPI 的开发者聚焦文件上传与下载服务的完整实现涵盖 multipart/form-data 表单处理、IFormFile 接收、Content-Disposition 与 Content-Type 响应头设置、流式输出、权限验证、路径与文件名安全、异步与分块传输等常见工程要点适合希望把接口能力落地到实际项目的初中级后端人员参考。压缩包共 50 个文件约 206KB以 25 个 cs 源码文件为核心配合 5 个 csproj 项目文件、9 个 json 配置、4 个 js 前端脚本及 Dockerfile、sln、readme 等构成可运行的多项目示例结构。内容预览显示其中包含上传服务、前端演示、客户端 Demo 与中间件等模块便于对照理解上传中间件、缩略图处理与负载均衡上传等设计思路。已有 1922 人学习可作为快速搭建文件服务的实践参考。1. 一个上传接口撑不住的时候问题往往不在带宽文件上传下载这件事看起来是 Web 开发里最没有技术含量的模块一个IFormFile参数一个FileStreamResult返回十几行代码就能跑通。但真正把它放进生产环境问题会一个接一个冒出来——大文件上传到一半连接断了、并发上传把内存打满、下载时文件名变成一串乱码、前端拿不到进度条、上传目录被恶意文件名穿越到系统盘。这些都不是带宽问题而是服务端设计没做到位。这份 .NET Core WebApi 文件上传下载服务的源码包解决的就是从「能跑」到「敢用」之间的这段距离。它把上传、下载、分片、校验、存储路径管理这几件事拆开实现适合正在做后台管理系统、内容平台、内部工具站的 .NET 开发者也适合刚接触 ASP.NET Core、想找一个完整文件服务参考实现的新手。下面按「它怎么组织 → 怎么跑起来 → 参数怎么调 → 坑在哪」的顺序拆。2. 文件服务的骨架Controller、存储层与流式处理怎么分工2.1 为什么不用 IFormFile 一把梭很多示例代码直接写public async TaskIActionResult Upload(IFormFile file)小文件没问题但 ASP.NET Core 对IFormFile的处理默认会经过模型绑定大文件场景下容易触发请求体大小限制而且整个文件在绑定阶段就可能被缓冲。常见做法是改用Request.Body直接读流配合MultipartReader手动解析这样可以在读取过程中做大小校验、类型校验甚至边读边写磁盘内存占用可控。这份源码里上传入口分了两条路径小文件走IFormFile简化写法大文件走流式读取。判断阈值一般设在 10MB 到 30MB 之间我一般取 20MB低于这个值用绑定更省事高于这个值必须走流。// 上传入口根据 Content-Length 决定走哪条路径 [HttpPost(upload)] [RequestSizeLimit(2L * 1024 * 1024 * 1024)] // 单请求上限 2GB public async TaskIActionResult Upload() { // 小于 20MB 走模型绑定代码简单 if (Request.ContentLength 20 * 1024 * 1024) { var form await Request.ReadFormAsync(); var file form.Files.FirstOrDefault(); if (file null) return BadRequest(未收到文件); return Ok(await _storage.SaveAsync(file.OpenReadStream(), file.FileName)); } // 大文件走流式解析避免整包缓冲 var reader new MultipartReader(Request.GetMultipartBoundary(), Request.Body); var section await reader.ReadNextSectionAsync(); while (section ! null) { if (section.ContentDisposition?.Contains(filename) true) { var fileName section.FileName; // 注意这里拿到的名字不可信 return Ok(await _storage.SaveAsync(section.Body, fileName)); } section await reader.ReadNextSectionAsync(); } return BadRequest(未找到文件段); }这段代码的关键点在两个地方。RequestSizeLimit是请求级上限必须显式设置否则默认值会让大文件在 Kestrel 层就被拒掉报 413。MultipartReader需要从请求头里取 boundaryGetMultipartBoundary()是框架提供的扩展方法别自己解析字符串。section.FileName来自客户端绝对不能直接拼路径后面存储层会统一做净化。2.2 存储层为什么要单独抽出来上传逻辑里最容易失控的不是接收而是「存到哪、怎么命名、怎么防重」。如果 Controller 里直接File.WriteAllBytes(Path.Combine(uploadDir, file.FileName))三个问题立刻出现同名文件互相覆盖、文件名带../穿越目录、磁盘写满时没有统一出口。把存储抽成IFileStorage接口Controller 只负责协议层存储实现负责路径规则和落盘策略后面换本地磁盘、对象存储、NAS 都只改一个实现类。public interface IFileStorage { TaskStoredFile SaveAsync(Stream source, string originalName, CancellationToken ct default); TaskStream OpenReadAsync(string fileId, CancellationToken ct default); Taskbool DeleteAsync(string fileId, CancellationToken ct default); } public class LocalFileStorage : IFileStorage { private readonly string _root; public LocalFileStorage(IConfiguration cfg) { // 根目录从配置读不硬编码 _root Path.GetFullPath(cfg[Storage:Root] ?? App_Data/uploads); Directory.CreateDirectory(_root); } public async TaskStoredFile SaveAsync(Stream source, string originalName, CancellationToken ct default) { // 1. 净化文件名只保留文件名部分去掉路径分隔符 var safeName Path.GetFileName(originalName); if (string.IsNullOrWhiteSpace(safeName)) safeName unnamed; // 2. 生成不冲突的存储名日期目录 GUID 原扩展名 var ext Path.GetExtension(safeName); var id ${DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid():N}{ext}; var fullPath Path.Combine(_root, id); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); // 3. 流式写入不整包进内存 await using var fs new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true); await source.CopyToAsync(fs, ct); return new StoredFile { Id id, OriginalName safeName, Size fs.Length }; } public TaskStream OpenReadAsync(string fileId, CancellationToken ct default) { // 关键把 fileId 拼回根目录后必须校验仍在根目录内 var fullPath Path.GetFullPath(Path.Combine(_root, fileId)); if (!fullPath.StartsWith(_root, StringComparison.Ordinal)) throw new UnauthorizedAccessException(非法文件路径); return Task.FromResultStream(new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true)); } }Path.GetFileName是防穿越的第一道闸它会把../../etc/passwd削成passwd。第二道闸在读取时做StartsWith校验因为fileId可能来自 URL 参数攻击者可以构造..%2f..%2f之类的编码。FileMode.CreateNew配合 GUID 命名理论上不会撞名撞了会抛异常而不是静默覆盖这是好事。缓冲区 81920 字节是 .NET 流复制的常用值和 4KB 相比能明显减少系统调用次数。2.3 下载接口Range 支持与 Content-Disposition 的细节下载看起来简单但有两个细节决定体验。第一是Content-Disposition里的文件名编码中文名直接塞进去会乱码必须用filename*UTF-8的 RFC 5987 格式。第二是 Range 请求支持视频、大文件断点续传都依赖它ASP.NET Core 的PhysicalFile自带 Range 处理但流式返回需要自己判断。[HttpGet(download/{*fileId})] public async TaskIActionResult Download(string fileId) { var meta await _storage.GetMetaAsync(fileId); if (meta null) return NotFound(); var stream await _storage.OpenReadAsync(fileId); // 中文文件名按 RFC 5987 编码同时保留 ASCII 回退 var encoded Uri.EscapeDataString(meta.OriginalName); var disposition $attachment; filename\download{Path.GetExtension(meta.OriginalName)}\; filename*UTF-8{encoded}; Response.Headers[Content-Disposition] disposition; Response.Headers[Accept-Ranges] bytes; // enableRangeProcessing 让框架处理 Range 头 return File(stream, application/octet-stream, enableRangeProcessing: true); }enableRangeProcessing: true是 .NET Core 3.0 之后FileStreamResult支持的参数开了之后框架会自动解析Range头并返回 206不用自己算偏移。filename*和filename同时给是为了兼容老客户端现代浏览器优先读带星号的那个。{*fileId}的星号捕获允许路径里带斜杠因为存储 ID 是日期/GUID.ext结构不带星号会被路由截断。3. 把服务跑起来配置、启动与一次完整的上传下载验证3.1 请求体大小与 Kestrel 的联动配置很多人只改了[RequestSizeLimit]结果大文件还是 413原因是 Kestrel 层还有一道MaxRequestBodySize。这两层是独立的Controller 特性管的是 MVC 层Kestrel 管的是服务器层必须同时放开。另外FormOptions.MultipartBodyLengthLimit管的是表单解析上限走ReadFormAsync的路径会受它约束。// Program.cs 中的关键配置 builder.WebHost.ConfigureKestrel(o { // 服务器层单请求上限和 Controller 特性保持一致 o.Limits.MaxRequestBodySize 2L * 1024 * 1024 * 1024; // 请求体最小速率防止慢速攻击单位字节/秒 o.Limits.MinRequestBodyDataRate new MinDataRate(240, TimeSpan.FromSeconds(5)); }); builder.Services.ConfigureFormOptions(o { // 表单解析上限走 IFormFile 路径时生效 o.MultipartBodyLengthLimit 2L * 1024 * 1024 * 1024; o.ValueLengthLimit 1024 * 1024; }); builder.Services.AddSingletonIFileStorage, LocalFileStorage();MinRequestBodyDataRate这个参数容易被忽略默认值是 240 字节/秒、5 秒宽限意思是客户端如果传得比这个还慢连接会被掐掉。内网环境没问题但如果你的用户网络质量差上传大文件时可能被误杀可以适当调低到 100 左右。ValueLengthLimit管的是普通表单字段和文件无关但设太小会导致附带的其他字段被截断。3.2 用 curl 走一遍完整链路服务起来之后别急着写前端先用 curl 把上传、下载、断点续传三条链路各验一遍。这样出问题时能确定是服务端还是前端的事。# 1. 普通上传观察返回的 fileId curl -X POST http://localhost:5000/api/files/upload \ -F file./test-100mb.bin \ -H Content-Type: multipart/form-data # 2. 按 fileId 下载-D 看响应头里的 Content-Disposition curl -D headers.txt -o out.bin \ http://localhost:5000/api/files/download/20250101/abc123.bin # 3. 验证 Range只取前 1MB期望返回 206 curl -r 0-1048575 -D range-headers.txt -o part.bin \ http://localhost:5000/api/files/download/20250101/abc123.bin # 4. 验证中文文件名不乱码 curl -X POST http://localhost:5000/api/files/upload \ -F file./测试文档.pdf第三步返回的响应头里应该有Content-Range: bytes 0-1048575/总大小和状态码 206如果返回 200 说明 Range 没生效回去检查enableRangeProcessing是否传了。第四步上传后下载用浏览器打开看文件名是否正常如果显示成%E6%B5%8B%E8%AF%95说明只给了filename*没给 ASCII 回退或者客户端太老。3.3 分片上传的合并逻辑超过几百 MB 的文件单请求上传的失败率会明显上升常见做法是前端切片、服务端按片接收、最后合并。这份源码里分片接口的设计是每片带uploadId和chunkIndex服务端按uploadId建临时目录收齐后按序号拼接。[HttpPost(chunk)] public async TaskIActionResult UploadChunk( [FromForm] string uploadId, [FromForm] int chunkIndex, [FromForm] int totalChunks, [FromForm] string fileName, IFormFile chunk) { // uploadId 必须是 GUID 格式防止被用来构造路径 if (!Guid.TryParse(uploadId, out _)) return BadRequest(非法 uploadId); var tempDir Path.Combine(_tempRoot, uploadId); Directory.CreateDirectory(tempDir); // 分片文件按序号命名补零保证排序正确 var chunkPath Path.Combine(tempDir, ${chunkIndex:D6}.part); await using (var fs new FileStream(chunkPath, FileMode.Create, FileAccess.Write)) { await chunk.CopyToAsync(fs); } // 检查是否收齐 var received Directory.GetFiles(tempDir, *.part).Length; if (received totalChunks) return Ok(new { received, totalChunks }); // 收齐后按序合并 var mergedPath Path.Combine(tempDir, merged.tmp); await using (var output new FileStream(mergedPath, FileMode.Create, FileAccess.Write)) { for (var i 0; i totalChunks; i) { var part Path.Combine(tempDir, ${i:D6}.part); await using var input new FileStream(part, FileMode.Open, FileAccess.Read); await input.CopyToAsync(output); } } // 合并完交给存储层再清理临时目录 await using var merged new FileStream(mergedPath, FileMode.Open, FileAccess.Read); var stored await _storage.SaveAsync(merged, fileName); Directory.Delete(tempDir, recursive: true); return Ok(stored); }uploadId做 GUID 校验是必须的否则攻击者传../../就能在任意目录建文件。分片序号补零到 6 位是为了字符串排序和数字排序一致不补零的话10.part会排在2.part前面。合并阶段是串行读写的如果分片很多、单机 IO 吃紧可以改成先算总大小预分配但串行版本逻辑最清晰先跑通再优化。临时目录的清理放在合并成功之后如果中途失败残留目录需要靠定时任务扫这一点在源码里留了扩展点。4. 避坑清单文件服务上线前必须过的五道坎4.1 上传成功但文件大小是 0现象接口返回 200StoredFile.Size也是 0磁盘上文件存在但内容为空。原因通常是流的位置不对——如果先读了Request.ContentLength或者做过任何预读section.Body的 Position 可能已经在末尾。解决在CopyToAsync之前检查source.CanSeek source.Position 0时Seek(0, Begin)或者干脆不要对同一个流做两次读取。我一般会在存储层入口加一句if (source.CanSeek) source.Position 0;成本极低。4.2 下载中文名在部分浏览器变成乱码现象Chrome 正常某些老版本客户端或下载工具显示成百分号编码。原因只设置了filename*UTF-8...没有给filename...的 ASCII 回退或者回退值里直接放了中文。解决回退值用纯 ASCII比如download.pdf真实名字放带星号的参数里。另外Uri.EscapeDataString对空格编码成%20有些客户端期望这个差异无解只能保证主流浏览器正常。4.3 并发上传时目录创建抛异常现象高并发下偶发IOException: The directory already exists。原因Directory.CreateDirectory本身是幂等的但如果两个线程同时走到「判断不存在 → 创建」的逻辑中间有竞态。解决不要自己判断Directory.Exists直接调CreateDirectory它对已存在目录不抛异常。真正会抛的是FileMode.CreateNew撞名那个用 GUID 基本可以规避。4.4 大文件上传到 99% 断开前端拿不到错误现象网络抖动后连接断前端只看到请求失败服务端日志里没有明确记录。原因Kestrel 在客户端断开时会抛BadHttpRequestException如果没捕获异常被中间件吞掉。解决在存储层的CopyToAsync外面包一层 try-catch捕获IOException和OperationCanceledException记录已写入的字节数返回明确的错误码。同时前端要用XMLHttpRequest.upload.onprogress或 fetch 的流式读取来感知进度不能只等最终响应。4.5 临时分片目录越积越多现象跑一段时间后磁盘被_tempRoot下的目录占满。原因分片上传中途放弃、合并失败、客户端重试换了uploadId都会留下孤儿目录。解决给临时目录加时间戳前缀或者记录创建时间起一个BackgroundService每小时扫一遍删除超过 24 小时未完成的目录。源码里这个清理任务留了接口实现时注意别把正在上传的目录误删判断依据用「最后写入时间」而不是「创建时间」。5. 进阶把存储层换成可插拔实现以及一次压测验证5.1 用配置切换本地存储与对象存储前面把存储抽成IFileStorage真正的价值在切换成本。假设后面要接对象存储只需要新增一个实现类在Program.cs里按配置选。// 按配置决定注册哪个实现Controller 完全不用改 var storageType builder.Configuration[Storage:Type] ?? Local; if (storageType.Equals(Object, StringComparison.OrdinalIgnoreCase)) builder.Services.AddSingletonIFileStorage, ObjectFileStorage(); else builder.Services.AddSingletonIFileStorage, LocalFileStorage();ObjectFileStorage里SaveAsync改成上传到桶、OpenReadAsync改成返回网络流fileId用对象 key。注意对象存储的流不一定支持Seek如果下载接口依赖 Range要么用支持 Range 的 SDK 方法要么在服务端做代理转发并手动处理 Range 头。这个边界在切换前必须确认否则视频播放会退化成必须整段下载。5.2 用压测确认并发上限在哪功能跑通不代表能扛。上线前我一般会用轻量压测工具对上传和下载各打一轮重点看三个指标内存峰值、磁盘 IO 等待、错误率拐点。下面是一个用wrk打下载接口的示例上传用hey或自己写脚本更合适因为 multipart 构造比较麻烦。# 下载接口压测100 并发持续 30 秒读 1MB 文件 wrk -t4 -c100 -d30s --latency \ http://localhost:5000/api/files/download/20250101/abc123.bin # 关注输出里的 Latency Distribution 和 Non-2xx responses # 如果 99% 延迟突然跳高多半是磁盘 IO 或线程池饥饿压测时用dotnet-counters monitor盯System.Runtime的ThreadPool Queue Length和GC Heap Size。如果队列长度持续大于 0说明线程池在排队可能是同步 IO 阻塞了线程检查有没有漏掉async/await。如果堆大小随并发线性上涨说明某处把文件读进了内存回去查ReadFormAsync的路径是否被大文件走到了。我自己的经验是单机 4 核 8G 的配置流式上传下载在 100 并发、单文件 50MB 的场景下内存稳定在 300MB 以内算正常超过 1G 就要怀疑缓冲没控住。从那以后我每次接文件服务都会先把「Kestrel 上限、FormOptions 上限、Controller 特性」这三层配置对齐再用 curl 把 Range 和中文名各验一遍最后压测看内存曲线——这三步走完后面基本不会在半夜被「上传失败」的电话叫醒。希望帮到你。本文还有配套的精品资源点击获取