
简介一份面向 .NET Core WebApi 开发者的文件上传与下载服务实现资源适合需要构建文件接口的中级后端工程师。内容围绕上传场景下的表单数据解析、文件流接收与保存以及下载场景中的响应头设置、媒体类型指定和流式传输展开同时覆盖身份验证、路径防遍历、文件名清理、异步并发、分块传输和日志监控等生产级关注点。压缩包共有五十个文件其中以 C# 源码文件为主二十五份另有九份 JSON 配置、五份工程文件并包含容器部署配置、前端演示页面和说明文档整体大小约二百零六 KB。解决方案被拆分为多个独立子项目能够对照学习中间件实现、缩略图生成、负载均衡上传等扩展能力。目前已有约一千九百二十一人学习下载是一份可直接研读并迁移到实际项目中的精简参考实现。 最近把项目里的文件上传下载服务从旧框架迁到.NET Core WebApi原以为就是写个接收文件、再写个返回文件的接口结果真做起来才发现到处是边界问题大文件传一半超时、下载的中文文件名乱码、部署到 IIS 之后上传 30MB 直接 413、vue 前端拿到 blob 后文件名全变成随机串。这篇文章就是把这些实测经验和排查过程完整记录下来涉及 .net core WebApi 文件上传、文件下载的接口设计、参数绑定、流式读写、断点续传以及部署层的配置适合刚接触这部分内容的开发者也适合想把上传下载服务做得更规范的人参考。文中所有代码都是实际跑过的不是那种只讲概念不给代码的教程。1. 为什么文件上传下载在 WebApi 里看着简单却到处是坑先说结论上传下载的代码量确实不大真正麻烦的是它横跨了前端、后端、服务器配置三个层面。任何一个环节没对齐表现就是接口报错或者文件损坏而且报错方式五花八门排查起来特别费劲。从后端角度看上传接口的本质是解析multipart/form-data请求体下载接口的本质是把字节流写进响应体再把响应头里的Content-Type和Content-Disposition设置正确。听起来不复杂但一旦落到实际业务里就有各种细节文件传到一半连接断开服务端是继续接收还是丢弃多个文件同时上传时请求体的解析顺序和文件边界怎么处理文件名里有中文、空格、特殊字符下载时浏览器能不能正确识别用户传上来的文件名能不能直接拿来存盘显然不能路径穿越攻击就是利用这个。前端用fetch拿二进制流时后端返回的文件名信息在Content-Disposition响应头里前端如果不解析这个头就只能自己硬编码文件名这正好对应了搜索热词里如何保持文件名不变 blob这个问题。我在实际开发里见过太多只写了一个文件上传接口就以为完事的情况结果前端一对接就暴露问题。所以接下来我会按照上传、下载、存储安全、坑位排查四个部分展开。2. 上传接口从 IFormFile 到流式读写的取舍2.1 单文件上传的基础写法IFormFile 足够应付 90% 的场景.NET Core WebApi 处理单文件上传非常简单直接在 action 参数里声明IFormFile对象即可。框架会自动完成multipart/form-data的解析和绑定开发者只需要关心文件保存到哪里、怎么命名。[HttpPost(upload)] public async TaskIActionResult Upload([FromForm] IFormFile file) { if (file null || file.Length 0) { return BadRequest(new { message 文件不能为空 }); } var uploadRoot Path.Combine(_webHostEnvironment.WebRootPath, uploads); var todayFolder DateTime.Now.ToString(yyyyMMdd); var dir Path.Combine(uploadRoot, todayFolder); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } var ext Path.GetExtension(file.FileName); var newFileName ${Guid.NewGuid():N}{ext}; var fullPath Path.Combine(dir, newFileName); using (var stream new FileStream(fullPath, FileMode.Create)) { await file.CopyToAsync(stream); } var fileUrl $/uploads/{todayFolder}/{newFileName}; return Ok(new { url fileUrl, name newFileName, size file.Length }); }这段代码里有几个点我当时也觉得无所谓后来线上出问题才意识到重要[FromForm]一定要写。虽然框架在 WebApi 里会自动绑定IFormFile但显式标注之后前端用FormData提交时兼容性最好特别是当参数名和 API 模型属性名不一致时这个标注能避免绑定失败。用Guid.NewGuid().ToString(N).Replace(-, )生成文件名好处是避免文件重名也避免用户文件名里的非法字符进入服务器文件名。有人喜欢直接用用户上传的原始文件名来存盘这非常不推荐后面安全部分细说。using确保流及时释放否则文件句柄占用后续删除、移动文件都会遇到文件被占用的异常在 Windows 服务器上尤其常见。IFormFile底层的行为需要了解小文件会缓冲到内存里大文件会写入到临时文件默认阈值是 64KB在 Kestrel 下由FormOptions.MultipartBodyLengthLimit控制默认 134217728 字节也就是 128MB。所以如果你什么都不配超过 128MB 的文件上传时请求会直接被拒绝报的错是InvalidDataException: Multipart body length limit exceeded。2.2 大文件场景绕开 IFormFile直接读写 Request.Body当你要支持几百 MB 乃至几个 GB 的文件时IFormFile就不太合适了。因为框架默认会对multipart/form-data做缓冲超过内存阈值就落临时文件虽然不会内存爆掉但整个过程会有一次不必要的磁盘写入。更合理的方式是直接操作Request.Body自己解析multipart的边界把流分块写到目标路径这样内存占用和磁盘 IO 都更可控。[HttpPost(upload-raw)] public async TaskIActionResult UploadRaw(CancellationToken cancellationToken) { var boundary Request.GetMultipartBoundary(); if (string.IsNullOrEmpty(boundary)) { return BadRequest(new { message 无效的 multipart 请求 }); } var reader new MultipartReader(boundary, Request.Body); MultipartSection? section; while ((section await reader.ReadNextSectionAsync(cancellationToken)) ! null) { var contentDisposition section.GetContentDispositionHeader(); if (contentDisposition.DispositionType form-data contentDisposition.FileName ! null) { var fileName contentDisposition.FileName.Value; var fullPath GetSafeFullPath(fileName); await using (var targetStream new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous)) { await section.Body.CopyToAsync(targetStream, 81920, cancellationToken); } } } return Ok(new { message 上传成功 }); }使用MultipartReader的好处是每个 section 逐块处理不会先把整个请求体加载到内存或临时文件大文件上传的内存占用非常稳定。需要注意的地方有两点GetMultipartBoundary()是从Content-Type请求头里提取 boundary 的扩展方法如果前端没有正确设置 multipart 请求头这里拿到的就是 null。section.Body.CopyToAsync的缓冲区一般设 81920 字节这正好是 Kestrel 流操作比较常用的配置太小会导致系统调用频繁太大也提升不了多少性能。2.3 多文件上传与前端 FormData 的配合多文件上传有两种处理思路一是接口参数声明为ListIFormFile二是对同一个字段名在 FormData 里追加多个文件。最常见的问题反而是前端写法不对导致后端收到的文件数量不对。前端 Vue 中正确的做法是const formData new FormData(); // files 是 File 对象数组注意字段名必须与后端参数名一致 for (const file of files) { formData.append(files, file); } // 不要用 formData.append(files[], file)后端默认绑定不了这种名字后端对应写法[HttpPost(upload-multiple)] public async TaskIActionResult UploadMultiple([FromForm] ListIFormFile files) { if (files null || files.Count 0) { return BadRequest(new { message 未接收到文件 }); } var results new Listobject(); foreach (var file in files) { // 保存逻辑与单文件一致 results.Add(new { name file.FileName, size file.Length }); } return Ok(new { count results.Count, files results }); }这里最容易踩的坑是前端把FormData里的字段名写成files后端参数却叫file请求能发出去但字段对不上后端拿到的是null或空列表。而且在调试时很难发现因为接口没有报错只是文件没收到。建议在接口开头做好判空并把这个判断的日志打出来别憋着不放。3. 下载接口FileResult 的几种正确姿势与文件名编码细节3.1 用 PhysicalFileResult 返回服务器本地文件上传之后的文件最终是要给人下载的。最简单的下载接口就是根据文件的相对路径从磁盘读取文件并返回[HttpGet(download)] public async TaskIActionResult Download(string fileName) { var uploadRoot Path.Combine(_webHostEnvironment.WebRootPath, uploads); var fullPath Path.Combine(uploadRoot, fileName); if (!System.IO.File.Exists(fullPath)) { return NotFound(new { message 文件不存在 }); } var memory new MemoryStream(); using (var stream new FileStream(fullPath, FileMode.Open, FileAccess.Read)) { await stream.CopyToAsync(memory); } memory.Position 0; return File(memory, application/octet-stream, fileName); }这种写法有个隐藏问题MemoryStream会把整个文件读进内存。对几十 MB 的小文件没啥影响但如果文件是几百 MB内存占用就会非常大服务并发一高迟早 OOM。更好的做法是用PhysicalFileResult直接把磁盘文件流交给响应流服务端不需要把整个文件载入内存[HttpGet(download-direct)] public IActionResult DownloadDirect(string fileName) { var fullPath Path.Combine(_webHostEnvironment.WebRootPath, uploads, fileName); if (!System.IO.File.Exists(fullPath)) { return NotFound(new { message 文件不存在 }); } var contentType application/octet-stream; return PhysicalFile(fullPath, contentType, fileName); }PhysicalFile底层用的是FileStream服务端到客户端的传输是流式进行的内存占用和文件大小无关这样才能支撑大文件下载。要注意的是PhysicalFile的最后一个参数是下载文件名这个参数如果不处理中文文件名会乱码所以与前端用 blob 保存文件的场景结合我们需要额外处理。3.2 处理中文文件名与 Content-Disposition 编码浏览器下载文件时文件名主要从两个地方拿到一个是响应头Content-Disposition的filename字段一个是 HTML5 下载场景里前端a.download属性。其中filename里有中文时必须进行 RFC 5987 编码否则浏览器会按 ISO-8859-1 解码导致乱码。在 .NET Core 里更靠谱的做法是手动构造ContentDispositionHeaderValue并设置FileNameStar[HttpGet(download-name)] public IActionResult DownloadWithName(string fileName) { var fullPath Path.Combine(_webHostEnvironment.WebRootPath, uploads, fileName); if (!System.IO.File.Exists(fullPath)) { return NotFound(new { message 文件不存在 }); } var cd new System.Net.Http.Headers.ContentDispositionHeaderValue(attachment) { FileNameStar fileName, // RFC 5987 编码支持中文 FileName fileName // 部分旧浏览器用这个 }; Response.Headers.ContentDisposition cd.ToString(); return PhysicalFile(fullPath, application/octet-stream); }设置FileName和FileNameStar两个字段ToString()之后会生成类似attachment; filenamexxx.pdf; filename*UTF-8xxx.pdf的响应头主流的现代浏览器优先读取filename*这样可以保留中文和空格等特殊字符。前端的处理也很关键。实际项目里前端用axios或fetch拿到二进制 blob然后生成对象 URL 并触发下载如果前端自己的a.download属性不设置浏览器就会根据 URL 末尾的路径片段来给文件取名而下载接口的 URL 往往是一个带参数的路由文件名就会变成随机串或者接口名。正确做法是从响应头里解析出文件名再赋给a.downloadconst response await fetch(/api/file/download-name?fileName encodeURIComponent(fileName)); const blob await response.blob(); let downloadName download; const disposition response.headers.get(Content-Disposition); if (disposition) { const match disposition.match(/filename\*UTF-8([^;])/i); if (match) { downloadName decodeURIComponent(match[1]); } else { const fileNameMatch disposition.match(/filename?([^])?/i); if (fileNameMatch) { downloadName fileNameMatch[1]; } } } const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download downloadName; a.click(); URL.revokeObjectURL(url);需要注意一个细节fetch的response.headers.get(Content-Disposition)能否拿到响应头取决于 API 接口是否在响应头暴露该字段。如果接口有 CORS 限制前端跨域请求时需要在后端配置Access-Control-Expose-Headers: Content-Disposition否则前端读不到这个头。3.3 断点续传与 Range 请求支持下载大文件时如果用户网络不稳定经常会出现下到一半失败的情况。HttpClient或下载器工具会尝试发出Range请求来续传。WebApi 的PhysicalFile默认是不支持Range的需要显式开启[HttpGet(download-resumable)] public IActionResult DownloadResumable(string fileName) { var fullPath Path.Combine(_webHostEnvironment.WebRootPath, uploads, fileName); if (!System.IO.File.Exists(fullPath)) { return NotFound(new { message 文件不存在 }); } // 支持 Range 请求实现断点续传 Request.Headers.Range Request.Headers.Range; var cd new System.Net.Http.Headers.ContentDispositionHeaderValue(attachment) { FileNameStar fileName, FileName fileName }; Response.Headers.ContentDisposition cd.ToString(); return PhysicalFile(fullPath, application/octet-stream, enableRangeProcessing: true); }enableRangeProcessing: true这个参数让 Kestrel 自己处理Range请求头包括If-Range、Content-Range等响应头不用我们手写二分逻辑。实测下来这个配置对多线程下载工具和浏览器内置下载器都有效。4. 存储策略与安全边界只实现功能是不够的4.1 为什么不能直接用用户上传的文件名存盘这是我在代码审查时经常提的一个点。直接用file.FileName当作服务器文件名会带来两类问题一类是文件重名造成覆盖。两个用户上传了同名文件后上传的会直接覆盖先上传的数据面无事业务面很危险。另一类是路径穿越问题和特殊字符问题。恶意用户完全可以构造一个../../Windows/System32/drivers/etc/hosts之类的文件名如果服务器代码直接把文件名拼进路径可能会写到预期目录之外。虽然后端框架通常会对IFormFile.FileName做一定的清洗但不要依赖这个自己的代码里必须再做一次防护。推荐做法服务器保存时全部用服务端生成的随机文件名原始文件名单独存数据库或写在记录里。对外下载时把原始文件名重新放进Content-Disposition即可。这里给出一个获取安全存储路径的辅助方法private string GetSafeFullPath(string originalFileName) { var uploadRoot Path.Combine(_webHostEnvironment.WebRootPath, uploads); var todayFolder DateTime.Now.ToString(yyyyMMdd); var dir Path.Combine(uploadRoot, todayFolder); Directory.CreateDirectory(dir); var ext Path.GetExtension(originalFileName).ToLowerInvariant(); if (string.IsNullOrEmpty(ext) || ext.Length 10) { ext .bin; } var newName ${Guid.NewGuid():N}{ext}; return Path.Combine(dir, newName); }Path.GetExtension会提取最后一个点后面的部分对含有多级目录的路径来说还能顺带把目录部分摘掉这个函数做了一层天然防护。而ext.Length 10这个判断可以防止文件名为xxx.abcdefghijklmnopqrstuvwxyz这种超长伪扩展名导致的问题。4.2 文件类型和大小限制不要只信前端校验前端的accept属性只是提示后端必须自己校验。校验文件类型不要只依赖Content-Type请求头因为请求头是客户端自己写的完全可伪造。可靠的方式是读取文件头的魔数magic number比如 PNG 头部固定 8 字节JPEG 头部有FF D8 FFPDF 头部有%PDF。如果是内网系统不想搞得太复杂最基本的白名单扩展名校验还是要做的private static readonly HashSetstring AllowedExtensions new(StringComparer.OrdinalIgnoreCase) { .jpg, .jpeg, .png, .gif, .pdf, .xlsx, .docx, .zip }; private bool IsExtAllowed(string fileName) { var ext Path.GetExtension(fileName); return AllowedExtensions.Contains(ext); }大小限制在上传接口里有两种做法一种是用[RequestSizeLimit(104857600)]特性限制单个请求的大小另一种是在 Kestrel 配置里做全局限制。推荐在接口上用特性这样粒度更细[HttpPost(upload)] [RequestSizeLimit(100 * 1024 * 1024)] // 100MB public async TaskIActionResult Upload([FromForm] IFormFile file) { // ... }4.3 目录组织与下载统计怎么设计实际项目里上传文件通常还会附带业务字段比如用户 ID、业务类型、备注等。建议按uploads / {业务类型} / {yyyyMMdd} / {随机文件名}来组织目录这样后续清理过期文件时只需要按日期目录删除非常方便。如果要做下载次数统计不要直接在下载接口里同步写数据库因为大文件下载耗时较长同步写库会阻塞响应。更合适的方案是把下载记录扔到消息队列或者在下载接口里用await异步写入日志表再返回文件流。我自己常用的一个折中方案是在下载接口开始时先以极短超时写入一条下载记录然后立刻返回文件流如果数据库短暂不可用不会影响文件本身的下发。伪代码如下var fileEntity await _dbContext.Files.FindAsync(fileId); fileEntity.DownloadCount; try { await _dbContext.SaveChangesAsync(); } catch (Exception) { // 记录失败不影响下载 }这个设计在并发量不大的内部系统里足够真到了高并发场景再引入消息队列不迟。5. 我实测中踩过的四个坑与完整排查记录5.1 坑一IIS 部署后上传超过 30MB 直接 413现象本地跑 Kestrel 上传 200MB 文件一切正常部署到 IIS 反向代理之后超过 30MB 就返回 413 Request Entity Too Large。排查过程一开始以为是 WebApi 本身的限制检查了web.config里maxAllowedContentLength默认值是 30000000 字节也就是约 28.6MB正好对得上。把配置改大之后问题解决了。解决方案system.webServer security requestFiltering requestLimits maxAllowedContentLength1048576000 / /requestFiltering /security serverRuntime uploadReadAheadSize104857600 / /system.webServermaxAllowedContentLength的单位是字节这里的1048576000是 1GB。另外uploadReadAheadSize这个参数也很关键IIS 默认只预读 49152 字节约 48KB到应用层大文件上传时如果不调大会导致上传速度非常慢甚至超时。5.2 坑二vue 前端下载 blob 后文件名变成随机串现象后端接口能正常返回文件流Postman 里按下 Download 也能得到正确的文件名但前端用 axios 拿到 blob 后触发下载保存对话框里的文件名是一串随机 hash 或者接口名。排查过程一开始怀疑是 responseType 设置不对改成responseType: blob后依然如此。后来在浏览器开发者工具 Network 里查看响应头发现Content-Disposition响应头在前端读不到。进一步检查发现是跨域的问题前端访问后端的接口属于不同源后端没有暴露Content-Disposition给前端。解决方案在后端接口上加上Access-Control-Expose-Headersif (Request.Headers.Origin.Any()) { Response.Headers.Append(Access-Control-Expose-Headers, Content-Disposition); }CORS 中间件配置里也可以统一设置ExposedHeaders。这个坑很容易被忽略因为如果不跨域在前端是可以直接读到的。5.3 坑三上传接口第一次调用正常第二次就超时现象WebApi 部署后第一个文件上传成功紧接着再传一个文件就超时IIS 应用池直接卡死过一会儿才能恢复。排查过程查看 Windows 事件查看器发现System.OutOfMemoryException。再检查代码发现上传接口里创建了MemoryStream用完没有释放Dispose反而放在了await之后且不在using块里导致句柄没有及时释放最终把内存耗尽。解决方案所有流相关的操作都要放进using或者try/finally里。特别是Stream.CopyToAsync之后一定要Dispose。这个教训让我后来对代码里所有Stream都养成了创建后立刻写using的习惯出了事再追就来不及了。5.4 坑四Content-Disposition 里的中文文件名乱码现象后端返回的Content-Disposition里filename思迅报表.pdf浏览器下载时文件名变成乱码或者被截断。排查过程先确认响应头的编码方式。标准里filename是 ISO-8859-1 编码不能直接放中文。如果服务端把字符集设成了 UTF-8 的输出不同浏览器处理方式不一有的能猜出来有的直接乱码。而filename*是 RFC 5987 的编码规则明确支持 UTF-8。解决方案直接设置FileNameStar属性同时保底设置FileName。这样现代浏览器用filename*解码正确旧浏览器也有filename兜底实测下来 Chrome、Firefox、Edge、Safari 都能正常显示中文文件名。6. 实用扩展与收尾建议如果你的项目里上传下载服务比较高频有几个小功能值得顺手加上都不复杂。一是临时文件清理。上传目录里的文件如果没有任何业务引用时间长了会非常臃肿。写一个后台任务每天凌晨扫描uploads目录删除超过 30 天的文件非常简单但非常实用。.NET Core 的BackgroundService可以轻松实现这个定时任务。二是对上传文件做病毒扫描或内容安全检测。内网系统可以接入本地的扫描服务外网系统至少也要做一次文件魔数校验防止上传伪装成图片的恶意脚本。三是分片上传与秒传。这两个功能在超大文件场景下很有价值实现也比较复杂建议在有明确需求的情况下再动手。常规业务里一个 128MB 以内的可断点续传下载服务已经能满足绝大多数需求。最后再分享一个小技巧写上传下载服务时强烈建议在开发阶段就把响应头的Content-Disposition、Content-Type在浏览器开发者工具里反复检查对齐前后端对文件名和类型的处理方式。很多问题到了测试阶段才暴露到时候排查成本高得多。我自己就是踩过几轮之后才总结出了上面这套稳定可靠的写法。本文还有配套的精品资源点击获取