简介:一套基于 .NET Core WebApi 的文件上传与下载服务实现示例,面向后端开发者和需要快速搭建文件接口的团队。项目围绕文件接口的真实需求,完整演示了使用 multipart/form-data 表单上传、IFormFile 接收文件并持久化存储,以及通过 HTTP GET 请求配合 Content-Disposition 与 Content-Type 响应头触发浏览器下载的流程;并重点覆盖权限验证、路径遍历防护、文件名清洗、扩展名白名单等安全策略,以及异步控制器、分块传输、缓存和异常日志等性能优化与稳定性措施。压缩包内含 50 个文件,以 C# 源码(25 个 cs)为主,另有 9 份 JSON 配置、5 个 csproj 工程文件、前端 JS/HTML 演示、Dockerfile 与 readme 说明,整体约 206KB;目录分层组织服务端、客户端和前端演示模块,便于从入口、控制器到配置项逐步排查。目前已有 1921 人学习浏览,可直接对照实现文件上传下载与安全控制,适合作为项目落地时的参考模板,也可结合云存储或分片上传做二次扩展。
1. .net core WebApi 文件上传和文件下载:一个看似简单、上生产却总翻车的组合
文件上传下载在 .net core WebApi 里一直是「看着简单、做着事多」的模块。很多团队把 CRUD 接口写得飞快,一到附件上传、资源下载就开始踩坑:上传 50MB 文件直接 413、下载中文文件名乱码、存进去的路径能被人猜到然后被拖走、发布一次站点把用户传的文件全冲掉。用 IFormFile 接收一个文件并落盘,十行代码就能跑通;但把它做成一个能上生产、敢对外分发的文件服务,要处理的是 multipart 解析、请求体限制、路径安全、Range 断点续传、发布目录与存储目录分离这一整条链路。这篇文章从上传接口的最小实现讲起,一路讲到下载接口的响应头设计、安全边界和部署配置,面向的是那些要给内部系统或对外站点搭一个可靠文件服务的后端工程师。全文以代码和配置为主,每段代码后面都会说清楚为什么这么写、参数怎么调、失败时怎么看。
2. 先立住上传接口:multipart/form-data 与 IFormFile 的最小闭环
2.1 上传为什么选 multipart/form-data:从 boundary 到缓冲区
浏览器和客户端工具上传文件,表单的 enctype 基本只有 multipart/form-data 一个选项。它把请求体按 boundary 分隔成多个 part,每个 part 自带 Content-Disposition 和 Content-Type,文件和普通表单字段可以混在同一个请求里。相比把文件转成 base64 塞进 JSON,multipart 的核心优势是流式:服务端不需要等到整个请求体都进内存才开始处理,可以边接收边写入磁盘。
base64 方案的痛点很直接:体积膨胀约 33%,而且 JSON 反序列化时整个文件内容会先躺在内存里,100MB 的文件至少吃掉 130MB 的托管堆。这个方案在 .NET Core WebApi 里基本只适合几 KB 的小图、小配置,超过 10MB 就建议别碰。
ASP.NET Core 对 multipart 的解析封装在 IFormFile 里。当 action 参数是 IFormFile 或 IFormFileCollection 时,框架会通过 MultipartReader 延迟解析请求体,文件数据先写入缓冲区:小文件留在内存,超过阈值(默认约 4MB)的部分落到本地临时文件,等请求结束再清理。这个机制决定了我们写代码时不太需要担心大文件直接打爆内存,但要注意及时复制和释放,否则临时文件可能残留。
选型上,普通业务场景用 IFormFile 就够了;如果是要做超大文件上传(比如几百 MB 甚至 GB 级)、或者想自己控制落盘节奏做秒传和断点续传,那就需要绕开 IFormFile,直接读 Request.Body 配合 MultipartReader 自己解析。后面第 6 章会讲这个方向,先看最小的可用方案。
2.2 最小上传接口:IFormFile 落盘与参数说明
先写一个能直接跑起来的上传 action,核心是接收文件、校验、GUID 重命名、流式落盘。
[ApiController] [Route("api/files")] public class FileController : ControllerBase { private readonly IWebHostEnvironment _env; private readonly ILogger<FileController> _logger; public FileController(IWebHostEnvironment env, ILogger<FileController> logger) { _env = env; _logger = logger; } // POST api/files/upload [HttpPost("upload")] [RequestSizeLimit(100 * 1024 * 1024)] public async Task<IActionResult> Upload([FromForm] IFormFile file) { if (file == null || file.Length == 0) return BadRequest("file 不能为空"); // 服务端重新生成文件名,原始文件名只保留用于展示 var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var storeName = $"{Guid.NewGuid():N}{ext}"; var storeDir = Path.Combine(_env.ContentRootPath, "uploads"); if (!Directory.Exists(storeDir)) Directory.CreateDirectory(storeDir); var fullPath = Path.Combine(storeDir, storeName); await using (var stream = new FileStream(fullPath, FileMode.Create)) { await file.CopyToAsync(stream); } _logger.LogInformation("uploaded {FileName} -> {StoreName}, {Size} bytes", file.FileName, storeName, file.Length); return Ok(new { id = storeName, fileName = file.FileName, size = file.Length }); } }这段代码有几个参数和设计点值得说清楚。[RequestSizeLimit(100 * 1024 * 1024)]是给当前 action 单独放开请求体上限,单位是字节,这里放到了 100MB;不加这个特性时,Kestrel 默认的请求体上限约 30MB,超过就 413,这是上传接口最常见的第一次翻车点。
Path.GetExtension(file.FileName).ToLowerInvariant()只取原文件名的扩展名,用于拼到新文件名后面。存储名用 Guid 生成,这样做一是避免原始文件名里带中文、emoji、特殊字符导致落盘和下载时编码出问题,二是避免文件名变成服务端路径的一部分,后面第 4 章会讲路径穿越的安全风险。
file.CopyToAsync(stream)是流式复制,IFormFile 内部的数据源可能是内存也可能是临时文件,这里不需要关心,复制完 await using 会自动释放 FileStream。注意接口返回的是id = storeName,不要把服务器绝对路径返回给前端。前端后续下载时拿这个 id 来请求,服务端通过 id 去映射真实文件。
2.3 Swagger 里调试上传并解决统一前缀问题
用 Swashbuckle 做接口文档时,IFormFile 类型的参数会自动渲染成一个文件选择控件,不需要额外配置,点开就能直接选文件调试上传,这是 Swagger UI 对 multipart 的默认支持。真正容易出问题的是项目加了统一前缀之后的 Swagger 404。
如果项目里用app.UsePathBase("/api")或者把路由统一加了前缀,Swagger 的请求地址不会自动跟着变。常见做法是在 Program.cs 里把 SwaggerEndpoint 的地址补上前缀,同时要保证 SwaggerEndpoint 的地址和中间件监听的路径模板一致:
// Program.cs app.UsePathBase("/api"); app.UseSwagger(c => { c.RouteTemplate = "api-docs/{documentName}/swagger.json"; }); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/api/api-docs/v1/swagger.json", "FileService v1"); });逻辑说明:UsePathBase会让应用感知到/api这个路径前缀,所有路由都自动带上;UseSwagger的 RouteTemplate 决定 swagger.json 放在哪里;UseSwaggerUI里的 SwaggerEndpoint 是浏览器去拉取 JSON 的完整地址,必须把路径前缀拼上,否则页面出来但接口列表一直是空的,控制台能看到请求 404。
调试上传时还要记住,Swagger 只是一个客户端界面,它发出的请求同样受 Kestrel 请求体大小、IIS maxAllowedContentLength 这些服务端限制约束,不是 Swagger 里能传大文件就代表线上没问题。
3. 下载接口做到能上生产:从 FileStreamResult 到 Range 请求
3.1 静态文件中间件与控制器下载:私有文件必须走控制器
下载文件和上传不同,实现路径有两条明显分岔。.UseStaticFiles()中间件可以直接把服务器上的某个物理目录映射成 URL 访问,还自带 Range 断点续传支持,静态资源如图片、ISO、JSON、DLL 文件放进去就能下载。但它的问题是:没有鉴权能力,只要知道 URL 就能拿;凡是放在 WebRootPath 下的文件,都有被扫描拖走的可能。适合放那些本来就可以公开给所有人的资源。
控制器下载则适合私有文件。接口里先做权限校验、再定位文件、最后用FileStreamResult/File()返回,可以接日志、记下载次数、做限流。缺点是 Range 支持需要自己处理或额外配置。两张方案不是互斥的:公开资源用静态文件中间件,私有附件走控制器。
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 公开的静态资源分发(ISO、图片、前端包) | UseStaticFiles + EnableRange | 性能和 Range 支持是内置的 |
| 需要登录/鉴权的附件下载 | 控制器 FileStreamResult | 可以在返回前做权限校验 |
| 下载行为要记录日志/审计 | 控制器 | 方便在 action 里写日志 |
| URL 不能暴露真实文件路径 | 控制器 | 返回内容用 id 映射,路径不暴露 |
3.2 控制器下载:FileStreamResult、响应头与 iOS 预览问题
写一个下载接口前,先想清楚文件名编码和 Content-Disposition 这两件事。浏览器判断一个响应是「直接展示」还是「下载保存」,看的就是 Content-Disposition 的值,inline是内联展示,attachment是强制下载。很多同事反馈 H5 在 iOS 上下载文件变成了预览,十有八九是接口返回时漏了 attachment,或者文件名没做 RFC 5987 编码。
[HttpGet("download/{id}")] public async Task<IActionResult> DownloadFile(string id) { // 只允许单层文件名格式,避免路径穿越 if (Path.GetFileName(id) != id) return BadRequest("invalid id"); var storeDir = Path.Combine(_env.ContentRootPath, "uploads"); var filePath = Path.Combine(storeDir, id); if (!System.IO.File.Exists(filePath)) return NotFound(); // 实际项目里建议用数据库记录原始文件名,这里用 id 占位 var originalName = id; var stream = System.IO.File.OpenRead(filePath); return File(stream, "application/octet-stream", originalName); }逻辑说明:这里用System.IO.File.OpenRead返回一个 FileStream 给 File 方法,ASP.NET Core 会把它包装成 FileStreamResult 流式输出,而不是先把整个文件读进 MemoryStream。这几点很关键:大文件下载时如果用了MemoryStream承接,100GB 的文件直接把进程内存打爆,所以下载的大文件必须走流式。
File(stream, contentType, downloadName)的三参重载会自动生成 Content-Disposition 响应头,值为attachment; filename="..."; filename*=UTF-8''...。其中 filename* 是 RFC 5987 编码,专门处理中文文件名。iOS 的 WKWebView 在部分系统版本上对 download 属性不敏感,但 WebApi 这侧能做的就是保证 attachment + filename* 同时出现,前端再配合<a download>触发,预览概率会大幅下降。如果是 Vue 项目里用 axios 拿 blob 再手动触发下载,遇到绝对路径 txt 文件被预览,多半是请求方式是 GET 直链而非 blob。后端统一提供下载接口后,前端只需要window.open('/api/files/download/' + id)或创建 a 标签即可。
3.3 Range 支持:大文件下载与断点续传
下载工具、浏览器断点续传、视频播放器在请求文件时,通常会在请求头带上Range: bytes=0-1023,如果服务端返回 200 加完整内容,下载工具就只能从头开始;返回 206 加Content-Range头,才能支持断点续传和视频拖动。
UseStaticFiles默认就支持 Range。控制器返回的 FileResult 在 ASP.NET Core 里没有内置的 Range 透传,需要自己解析。下面是一个处理单段 Range 的最小实现,适用于私有文件下载场景:
[HttpGet("download-range/{id}")] public IActionResult DownloadWithRange(string id) { if (Path.GetFileName(id) != id) return BadRequest("invalid id"); var filePath = Path.Combine(_env.ContentRootPath, "uploads", id); if (!System.IO.File.Exists(filePath)) return NotFound(); var total = new FileInfo(filePath).Length; var range = Request.Headers["Range"].ToString(); long start = 0; var end = total - 1; if (!string.IsNullOrEmpty(range) && range.StartsWith("bytes=")) { var part = range["bytes=".Length..].Split('-'); start = long.Parse(part[0]); if (part.Length > 1 && !string.IsNullOrEmpty(part[1])) end = long.Parse(part[1]); end = Math.Min(end, total - 1); if (start > end || start >= total) return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } var length = end - start + 1; var buffer = new byte[length]; using (var fs = System.IO.File.OpenRead(filePath)) { fs.Seek(start, SeekOrigin.Begin); fs.Read(buffer, 0, buffer.Length); } Response.Headers["Accept-Ranges"] = "bytes"; Response.Headers["Content-Range"] = $"bytes {start}-{end}/{total}"; return File(buffer, "application/octet-stream"); }参数说明:Range头的格式是bytes=start-end,我们只处理单段 Range;当客户端没带 Range 时,start 默认为 0、end 为文件末尾,返回完整内容。416RangeNotSatisfiable是客户端请求的起点超出文件大小时的标准状态码。这个实现用byte[]承载文件片段,适合中小文件;如果是超大文件,不要用这种方式,直接用FileStream配合Response.Body分段写入更稳。
多段 Range(比如视频拖动生成多个片段请求)会返回multipart/byteranges,实现复杂度高很多,生产环境如果走私有下载又要对视频做拖动,常见做法是:鉴权通过后重定向到带签名 URL 的静态文件路径,让UseStaticFiles接管 Range,或者直接用成熟的静态文件方案。第 5 章会讲部署时怎么配这一层。
4. 上传下载的避坑清单:上传漏洞、后缀白名单与路径穿越
4.1 后缀过滤的漏洞:正则黑名单为什么挡不住上传漏洞
现象:后端在接收文件时写了一大堆正则做扩展名黑名单,exe|php|asp|aspx|jsp全挡了,自认为安全。但攻击者把文件名改成info.php.rar或info.phtml,在 Apache2 这类对多后缀解析宽容的 Web 服务器上,文件可能被当成脚本执行,这就是典型的文件上传攻击绕过场景。这类问题在安全靶场里被反复演练,核心都是「后端正则和后缀黑名单」与「容器解析规则」之间的错位。
原因:黑名单永远追不上容器特性。不同 Web 服务器对文件名解析的顺序不同:Apache 默认只识别最后一个后缀(多后缀时看配置),Windows 下还有分号截断成info.asp;.jpg的利用方式,大小写、百分号编码又能绕过不严谨的匹配。只要校验逻辑是「允许列表之外都拒绝」的反面,总有绕过的空间。
解决:把方案改成白名单 + 重命名 + 隔离三层。白名单只放业务真实需要的扩展名:图片、PDF、zip、doc 之类的明确清单。上传的文件一律用 GUID 重命名,只保留白名单映射出来的扩展名,原始文件名存入数据库,落盘文件绝不会叫info.php这类名字。存储目录不要放在站点根目录下,或至少让该目录没有脚本执行权限。
提示:如果业务必须允许任意类型文件上传(比如网盘类产品),存储目录绝对不能在 Web 根目录里,下载只能走控制器,且 Content-Type 由服务端映射,不让浏览器猜测和渲染。
4.2 文件名与路径的坑:路径穿越和 Unicode 陷阱
现象:用户上传一个名为../../../../tmp/passwd的文件,或者文件名里带 emoji 和中文,结果落盘失败、路径错乱,甚至覆盖了服务器上其他目录的文件。
原因:代码里直接把file.FileName拼进Path.Combine,用户输入变成了文件系统路径的一部分。Windows 下文件名末尾的点、空格也会导致创建文件失败。
解决:上传和下载两端都用同一套防御逻辑。Path.GetFileName(file.FileName)可以先把用户输入里的路径前缀剥掉,只保留最终段;但更推荐干脆不信原始文件名,一律用 GUID 重命名,原始文件名单独存数据库字段。下载时同样只接受数据库生成的 id,通过 id 查表得到物理路径,而不是把用户输入直接拼进路径。Path.GetFileName(id) != id这个判断就是为了防止下载时传a/../../b这种路径片段。
4.3 413 Request Entity Too Large:Kestrel、IIS 与 multipart 三层限制
现象:上传一个 50MB 的文件,接口返回 413,浏览器控制台看到Request Entity Too Large,应用日志里 Kestrel 报MaxRequestBodySize exceeded。
原因:Kestrel 默认请求体上限约 30MB,IIS 的maxAllowedContentLength默认值也一样是 30MB 这个数量级,multipart 还有独立的MultipartBodyLengthLimit。三层限制只调其中一层,问题照旧。
解决:把三层一起放开。Kestrel 在 Program.cs 里配置,FormOptions 控制 multipart 大小,IIS 靠 web.config:
builder.Services.Configure<FormOptions>(o => { o.MultipartBodyLengthLimit = 200 * 1024 * 1024; o.ValueLengthLimit = 200 * 1024 * 1024; }); builder.WebHost.ConfigureKestrel(o => { o.Limits.MaxRequestBodySize = 200 * 1024 * 1024; });<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="209715200" /> </requestFiltering> </security> </system.webServer>参数说明:MultipartBodyLengthLimit是 multipart 表单整体的大小上限,ValueLengthLimit是单个字段值的上限,上传文件时这两个都要比文件大小大。MaxRequestBodySize是整个请求体的总上限。IIS 的maxAllowedContentLength单位是字节,209715200 正好是 200MB。如果前面还有 Nginx,client_max_body_size 200m也要配上,代理层默认 1MB,是大文件上传重灾区。
4.4 临时文件与缓冲目录:上传接口的内存黑匣子
现象:接口能跑,但并发一高,服务器内存飙升;或者在系统临时目录里看到大量aspnet-*开头的残留文件,几个 GB 没人清理。
原因:IFormFile 底层把超过阈值的 multipart 内容缓冲到临时文件,正常流程下请求结束会清理,但如果你自己读Request.Body又没读完,或者 action 里提前 return 导致 body 未被全部消费,ASP.NET Core 管线的清理逻辑就不会触发,临时文件就残留了。内存飙升通常是因为在 action 里手动file.OpenReadStream()后没有释放,或者把 IFormFile 直接塞进内存集合等后续再处理。
解决:上传接口只做一件事——把文件从 IFormFile 复制到目标存储。拿到文件后立刻CopyToAsync,用完即释放。不要把IFormFile存在缓存或数据库字段里延迟处理。要在接口返回前确保请求体被完整消费,RequestSizeLimit合适的值能避免恶意超长请求拖死管线。排查残留文件时,先看系统临时目录,确认哪些进程占着文件,再回看接口代码有没有提前 return 或分支里漏了读取 body。
5. 发布与部署:把文件服务跑在真实环境里
5.1 发布 webapi 项目:存储目录与站点分离
发布 webapi 项目后,IWebHostEnvironment.ContentRootPath指向发布目录。如果上传文件直接写到这个目录下,下一次重新发布时可能被覆盖或删除,这是一个很隐蔽的数据丢失隐患。发布 webapi 项目时最常见的文件服务事故,就是「发了个版,用户传的文件全没了」。
常见做法是在 appsettings.json 里配置一个存储根路径,让上传下载都走这个配置:
{ "Storage": { "RootPath": "D:\\FileStorage" } }var storageRoot = builder.Configuration["Storage:RootPath"] ?? Path.Combine(builder.Environment.ContentRootPath, "uploads"); builder.Services.AddSingleton(new FileStorageOptions(storageRoot));说明:生产环境把存储根路径指到数据盘,比如 Linux 下的/data/files或 Windows 下的D:\FileStorage,与站点代码完全分离。启动时检查目录是否存在,不存在就Directory.CreateDirectory,避免第一个上传请求因为目录缺失报错。Linux 部署时还要注意运行账号对存储目录有读写权限,systemd 服务里的User=写的是谁,目录属主就要对应。
5.2 反向代理与请求链路:Nginx/IIS 下的 413 和超时
文件服务前面挂 Nginx 时,上传链路多了一处 413 高发点:Nginx 默认client_max_body_size只有 1MB,任何超过 1MB 的请求都会直接在 Nginx 层被挡掉,请求根本到不了 WebApi。这是「Swagger 里能传、线上传不了」最典型的场景。
server { listen 80; server_name files.example.com; client_max_body_size 200m; client_body_timeout 60s; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 300s; } }参数说明:client_max_body_size按业务需要调,必须大于等于后端 Kestrel 的限制;client_body_timeout是两次 body 读取之间的超时,上传慢文件时太短会中断;proxy_read_timeout是等待后端响应的超时,大文件上传后处理时间较长,默认 60 秒在某些慢盘场景不够用。IIS 做反代时对应的点是 URL Rewrite 模块和 requestLimits,排查思路一样:先在 WebApi 这层用 curl 直接打本地端口确认接口本身正常,再逐层检查代理配置。
5.3 下载验证与日志:curl 实测和 Range 验证
文件服务发布后,不要直接拿浏览器测下载,用 curl 看响应头和实际内容更直观。下载失败时,先用 scp 从服务器拉一个本地文件确认网络层通,再怀疑应用层;很多「客户端下载失败」最后查出来是防火墙端口或代理层问题,跟接口代码无关。
# 验证下载接口的响应头:Content-Disposition、Content-Length curl -I "http://localhost:5000/api/files/download/abc123" # 验证 Range 支持:请求前 100 字节,期望返回 206 和 Content-Range curl -i -H "Range: bytes=0-99" \ "http://localhost:5000/api/files/download-range/abc123"# 验证下载内容完整性:客户端下载后和服务器源文件比较哈希 sha256sum /data/files/abc123 curl -s "http://localhost:5000/api/files/download/abc123" | sha256sum参数说明:curl -I发 HEAD 请求,只取响应头,快速确认 Content-Length 和 Content-Disposition;Range: bytes=0-99请求前 100 字节,响应头里有206 Partial Content和Content-Range: bytes 0-99/文件总字节才说明 Range 没失效。哈希比对是文件服务上线前必做的验证,源文件与下载后的内容一致,再往上层接业务。
日志方面,上传和下载接口都要记录完整元数据:文件名、大小、来源 IP、耗时、结果状态。这样线上有人传了奇怪的文件、下载被拒、路径不存在,都能在日志里定位,不需要再问客户端「你刚传的什么文件」。
6. 进阶:大文件分片上传与秒传验证
文件服务跑到 500MB 以上,单请求上传开始变得脆弱:网络抖动就失败、没有进度条、代理层超时、服务端临时文件占用大。这时候行业里普遍的做法是分片上传:前端把文件按固定大小切片(常见 5MB-10MB),每个切片单独发起一个上传请求,后端收到全部切片后合并。实现时切片请求要带上传会话 ID 和序号,后端用临时目录存放切片,全部就绪后再按序号排序合并成最终文件,合并完成后做一次哈希校验再落盘。
秒传是对分片上传的补充:前端先算整个文件的 SHA-256,后端查存储表,发现同样哈希的文件已存在,直接返回已有的文件 id,不再真正上传数据。这个方案对重复上传、多人传同一份文件极其有效,能省大量带宽和磁盘。实现秒传时要注意哈希算法的一致性,前后端必须用同一种算法,哈希值存储时统一小写十六进制。
验证这套方案,我的习惯是准备一个 200MB 左右的随机文件,先算好哈希,走完整上传流程,再走下载接口拉回来,两边哈希一致才算通过。分片合并接口特别容易出现「上传全部成功但下载文件损坏」的问题,原因往往是某个切片重复或顺序错乱,所以合并代码里必须按序号排序,并在合并完成后重算一次完整文件的哈希。
做文件服务这几年,我的第一条经验是把存储目录放到站点外,第二条就是决定文件在服务器上只认 GUID 名字、原始文件名只存在于数据库。这两条守住了,后面加权限、加审计、加 CDN 都是顺手的事。希望这篇笔记能帮你在 .net core WebApi 文件上传和文件下载这条路上少走几个弯。
本文还有配套的精品资源,点击获取