ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

.NET Core WebApi 文件上传下载:从接口设计到部署的完整指南

.NET Core WebApi 文件上传下载:从接口设计到部署的完整指南 简介这份资源面向.NET Core后端开发者与WebAPI初学者聚焦文件上传与下载服务的完整实现帮助解决multipart/form-data解析、流式响应、权限校验与性能优化等常见痛点。压缩包共50个文件约206KB以25个C#源码文件为核心配合9个JSON配置、5个csproj项目文件、4个JavaScript脚本及Dockerfile、sln解决方案、readme说明等涵盖控制器、中间件、配置模型与前端演示模块结构清晰便于按模块研读。已有1921人学习下载。通过其中的示例代码读者可掌握IFormFile接收上传、Content-Disposition与Content-Type响应头设置、异步流式读写、分块传输、JWT鉴权、路径遍历与文件类型限制等安全策略并参考中间件实现缩略图、负载均衡上传等扩展思路快速搭建可落地的文件服务。1. .NET Core WebApi 文件上传下载从接口设计到落地部署的完整路径文件上传下载看起来是 Web 开发里最没有技术含量的活但真正在生产环境跑过一轮的人都知道这里面的坑一点都不少。用 .NET Core 搭一个 WebApi 文件服务核心要解决四件事上传接口怎么接住大文件不炸内存、下载接口怎么支持断点续传和范围请求、文件存哪里怎么防止路径穿越、以及发布到服务器后 Swagger 和静态资源路径怎么不翻车。这套方案适合正在用 ASP.NET Core 做后端服务的开发者尤其是需要给前端或第三方提供文件能力的场景。下面按「接口设计 → 上传实现 → 下载实现 → 安全加固 → 部署排查」的顺序把每一步的参数和踩坑点讲清楚。2. 上传接口怎么设计IFormFile 与流式处理的选型2.1 为什么大文件不能直接用 IFormFile 一把梭ASP.NET Core 默认的IFormFile绑定会把整个文件缓冲到内存或临时磁盘对于几十 MB 的文件没问题但一旦上传几百 MB 甚至上 GB 的文件内存压力和临时文件管理就会变成灾难。常见做法是设置MultipartBodyLengthLimit但更稳妥的方案是对大文件走流式读取直接边读边写目标存储。先看默认配置下上传接口的最小实现[ApiController] [Route(api/[controller])] public class FileController : ControllerBase { private readonly IWebHostEnvironment _env; public FileController(IWebHostEnvironment env) { _env env; } [HttpPost(upload)] [RequestSizeLimit(100 * 1024 * 1024)] // 限制 100MB public async TaskIActionResult Upload(IFormFile file) { if (file null || file.Length 0) return BadRequest(文件为空); // 生成安全的存储文件名避免用户文件名直接落盘 var ext Path.GetExtension(file.FileName); var safeName ${Guid.NewGuid():N}{ext}; var saveDir Path.Combine(_env.ContentRootPath, uploads); Directory.CreateDirectory(saveDir); var savePath Path.Combine(saveDir, safeName); using (var stream new FileStream(savePath, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { fileName safeName, original file.FileName, size file.Length }); } }这段代码的逻辑很直白接收IFormFile生成 GUID 作为存储文件名保留原始扩展名写入uploads目录。参数上RequestSizeLimit控制单次请求体上限CopyToAsync内部会异步写盘。但问题在于IFormFile本身已经完成了缓冲RequestSizeLimit只是限制请求体大小并不能避免内存占用。2.2 流式上传用 MultipartReader 处理超大文件当文件超过 100MB 时我一般会改用MultipartReader手动解析请求体边读边写内存占用可以控制在几 MB 以内[HttpPost(upload-stream)] [DisableRequestSizeLimit] public async TaskIActionResult UploadStream() { if (!Request.HasFormContentType) return BadRequest(不是 multipart 请求); var boundary HeaderUtilities.RemoveQuotes( MediaTypeHeaderValue.Parse(Request.ContentType).Boundary).Value; var reader new MultipartReader(boundary, Request.Body); var section await reader.ReadNextSectionAsync(); while (section ! null) { var hasContentDisposition ContentDispositionHeaderValue .TryParse(section.ContentDisposition, out var contentDisposition); if (hasContentDisposition contentDisposition.DispositionType.Equals(form-data) !string.IsNullOrEmpty(contentDisposition.FileName.Value)) { var ext Path.GetExtension(contentDisposition.FileName.Value); var safeName ${Guid.NewGuid():N}{ext}; var savePath Path.Combine(uploads, safeName); using var target new FileStream(savePath, FileMode.Create); await section.Body.CopyToAsync(target); return Ok(new { fileName safeName }); } section await reader.ReadNextSectionAsync(); } return BadRequest(未找到文件字段); }关键参数说明DisableRequestSizeLimit去掉请求体大小限制由 Kestrel 的MaxRequestBodySize兜底MultipartReader按 boundary 逐段读取section.Body是当前段的流直接CopyToAsync到目标文件。这样即使上传 2GB 文件服务端内存也不会超过几十 MB。注意DisableRequestSizeLimit只是去掉 MVC 层的限制Kestrel 层还有MaxRequestBodySize需要在Program.cs里单独配置。2.3 上传接口的参数配置与边界在Program.cs里和上传相关的配置主要有三处builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 2L * 1024 * 1024 * 1024; // 2GB }); builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 2L * 1024 * 1024 * 1024; options.ValueLengthLimit int.MaxValue; options.MultipartHeadersLengthLimit int.MaxValue; });MaxRequestBodySize是 Kestrel 层的硬限制MultipartBodyLengthLimit是表单解析层的限制两者要匹配。如果只改一个另一个会先触发 413 错误。另外ValueLengthLimit和MultipartHeadersLengthLimit在文件字段名或 header 特别长时也需要放宽否则会出现莫名其妙的解析失败。3. 下载接口怎么做范围请求、断点续传与 Content-Disposition3.1 用 PhysicalFileResult 返回文件最简单的下载实现是直接返回PhysicalFileResult[HttpGet(download/{fileName})] public IActionResult Download(string fileName) { // 防止路径穿越只取文件名部分 var safeName Path.GetFileName(fileName); var filePath Path.Combine(uploads, safeName); if (!System.IO.File.Exists(filePath)) return NotFound(); return PhysicalFile(filePath, application/octet-stream, safeName); }PhysicalFile的第三个参数是下载时浏览器显示的文件名会写入Content-Disposition: attachment; filename...。application/octet-stream表示二进制流浏览器会直接触发下载而不是尝试预览。3.2 支持 Range 请求实现断点续传PhysicalFileResult内部已经支持 Range 请求但如果你需要自己控制下载逻辑比如做权限校验后再返回文件流就需要手动处理Range头[HttpGet(download-range/{fileName})] public async TaskIActionResult DownloadRange(string fileName) { var safeName Path.GetFileName(fileName); var filePath Path.Combine(uploads, safeName); if (!System.IO.File.Exists(filePath)) return NotFound(); var fileInfo new FileInfo(filePath); var fileLength fileInfo.Length; var rangeHeader Request.Headers[Range].ToString(); if (string.IsNullOrEmpty(rangeHeader)) { Response.Headers[Accept-Ranges] bytes; return PhysicalFile(filePath, application/octet-stream, safeName); } // 解析 Range: bytesstart-end var range rangeHeader.Replace(bytes, ).Split(-); var start long.Parse(range[0]); var end string.IsNullOrEmpty(range[1]) ? fileLength - 1 : long.Parse(range[1]); var length end - start 1; Response.StatusCode 206; Response.Headers[Content-Range] $bytes {start}-{end}/{fileLength}; Response.Headers[Accept-Ranges] bytes; Response.ContentLength length; using var stream new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); stream.Seek(start, SeekOrigin.Begin); var buffer new byte[81920]; long remaining length; while (remaining 0) { var read await stream.ReadAsync(buffer, 0, (int)Math.Min(buffer.Length, remaining)); if (read 0) break; await Response.Body.WriteAsync(buffer, 0, read); remaining - read; } return new EmptyResult(); }这段代码的核心是解析Range头返回 206 状态码和Content-Range响应头。Accept-Ranges: bytes告诉客户端支持范围请求下载工具和浏览器据此实现断点续传。缓冲区用 80KB 是常见做法太小会增加 IO 次数太大对内存不友好。3.3 下载时的 Content-Disposition 与中文文件名中文文件名在Content-Disposition里需要 URL 编码否则会出现乱码或 header 解析失败var encodedName Uri.EscapeDataString(originalFileName); Response.Headers[Content-Disposition] $attachment; filename*UTF-8{encodedName};filename*UTF-8是 RFC 5987 定义的编码方式现代浏览器都支持。如果只写filename中文会被截断或变成乱码。这个细节在对接第三方系统时经常被忽略导致下载下来的文件名全是问号。4. 安全加固上传漏洞、路径穿越与文件类型校验4.1 上传漏洞的常见形态文件上传漏洞是 Web 安全里最经典的攻击面之一。常见形态包括上传 webshell 脚本文件后通过 URL 直接访问执行、利用路径穿越覆盖系统文件、通过双扩展名绕过类型检查如test.asp;.jpg、以及利用解析漏洞让图片文件被当作脚本执行。在 .NET Core 环境下虽然不像 PHP 那样容易直接执行上传文件但如果存储目录被配置为静态文件目录且服务器支持某种解析风险依然存在。OWASP ZAP 等工具在上传接口扫描时会尝试上传各种畸形文件和超大文件测试服务端是否有类型校验、大小限制和路径处理缺陷。CTF 中常见的上传题也基本围绕这几个点出题。4.2 三层校验扩展名白名单、Content-Type 检查、文件头魔数只靠扩展名黑名单是不够的因为后端正则限制了很多后缀攻击者可以用大小写、双写、特殊字符绕过。我一般用三层校验private static readonly HashSetstring AllowedExtensions new(StringComparer.OrdinalIgnoreCase) { .jpg, .jpeg, .png, .gif, .pdf, .docx, .xlsx, .zip }; private static readonly Dictionarystring, byte[] FileMagicNumbers new() { { .jpg, new byte[] { 0xFF, 0xD8, 0xFF } }, { .png, new byte[] { 0x89, 0x50, 0x4E, 0x47 } }, { .pdf, new byte[] { 0x25, 0x50, 0x44, 0x46 } }, { .zip, new byte[] { 0x50, 0x4B, 0x03, 0x04 } } }; private bool IsValidFile(IFormFile file) { var ext Path.GetExtension(file.FileName); if (!AllowedExtensions.Contains(ext)) return false; using var stream file.OpenReadStream(); var header new byte[4]; stream.Read(header, 0, 4); stream.Position 0; if (FileMagicNumbers.TryGetValue(ext, out var magic)) { for (int i 0; i magic.Length; i) { if (header[i] ! magic[i]) return false; } } return true; }扩展名白名单是第一层文件头魔数是第二层Content-Type 可以作为第三层参考但不可单独依赖因为客户端可以伪造。三层都通过才允许落盘。4.3 路径穿越防护与存储目录隔离路径穿越的典型攻击是上传文件名带../../试图写到 Web 根目录。防护手段很简单永远不要用用户提供的文件名直接拼接路径存储文件名用 GUID 重新生成原始文件名只存在数据库里用于展示。下载时同样用Path.GetFileName剥离路径部分。另外上传目录不要放在wwwroot下避免被静态文件中间件直接暴露。如果确实需要提供访问通过 API 接口做权限校验后再返回文件流而不是让文件直接可访问。5. 避坑与排查发布后 Swagger 404、大文件超时、静态文件失效5.1 发布后访问 /swagger/v1/swagger.json 提示 Not Found现象本地调试 Swagger 正常发布到 IIS 或 Linux 后访问/swagger/v1/swagger.json返回 404。原因Swagger 中间件只在开发环境注册或者发布时ASPNETCORE_ENVIRONMENT被设成了Production而代码里写了if (app.Environment.IsDevelopment())才启用 Swagger。解决如果生产环境也需要 Swagger把判断条件去掉或改成配置项控制。另外检查 IIS 的 URL Rewrite 是否把/swagger路径拦截了以及应用是否部署在虚拟目录下导致路径前缀不匹配。5.2 大文件上传返回 413 或连接中断现象上传超过一定大小的文件时返回 413 Request Entity Too Large或者连接直接断开。原因Kestrel 的MaxRequestBodySize、表单的MultipartBodyLengthLimit、IIS 的maxAllowedContentLength、Nginx 的client_max_body_size任何一层没放开都会触发。解决逐层检查。Kestrel 在Program.cs配置IIS 在web.config里设requestLimits maxAllowedContentLength2147483648 /Nginx 在nginx.conf里设client_max_body_size 2048m;。四层都对齐才能传大文件。5.3 下载大文件时请求超时现象下载几百 MB 的文件时客户端等待一段时间后超时断开。原因Kestrel 的KeepAliveTimeout和RequestHeadersTimeout默认值较短或者反向代理的超时设置太短。解决适当调大KeepAliveTimeoutNginx 侧设置proxy_read_timeout和proxy_send_timeout为较大值。同时确保下载接口用的是流式写出而不是一次性读入内存否则大文件会先撑爆内存再超时。5.4 上传文件后无法覆盖同名文件现象上传同名文件时提示文件被占用无法写入。原因下载接口或读取接口没有及时释放FileStream导致文件句柄被占用。解决所有FileStream都用using包裹读取时用FileShare.Read允许其他进程读。如果确实需要支持覆盖先检查文件是否被占用或者用FileMode.Create配合重试逻辑。5.5 Linux 下路径大小写敏感导致文件找不到现象Windows 上开发正常部署到 Linux 后下载接口返回 404。原因Linux 文件系统区分大小写代码里写的路径大小写和实际存储不一致。解决统一用 GUID 小写存储路径拼接用Path.Combine而不是字符串拼接避免硬编码大小写不一致的目录名。6. 进阶技巧用统一前缀和中间件把文件服务收拢成一个模块6.1 给所有 API 加统一前缀在Program.cs里用UsePathBase或路由前缀可以让文件服务的所有接口都挂在/api/files下app.UsePathBase(/files-service);或者在控制器路由上统一加前缀[Route(api/files/[controller])]这样 Swagger 页面里的接口路径也会带上前缀前端对接时只需要改一个 base URL。如果 Swagger 页面本身也需要加前缀在UseSwagger里配置RoutePrefix。6.2 用中间件统一处理上传大小和异常与其在每个接口上贴RequestSizeLimit不如写一个中间件统一拦截public class UploadLimitMiddleware { private readonly RequestDelegate _next; private readonly long _maxSize; public UploadLimitMiddleware(RequestDelegate next, long maxSize) { _next next; _maxSize maxSize; } public async Task InvokeAsync(HttpContext context) { if (context.Request.ContentLength _maxSize) { context.Response.StatusCode 413; await context.Response.WriteAsync(文件超过大小限制); return; } await _next(context); } }注册时传入限制值所有请求先过这一层超限直接返回 413不用等到 MVC 层再报错。6.3 验证清单与我的习惯每次部署文件服务前我会按这个清单过一遍检查项验证方式常见问题上传大小限制传一个超过限制的文件413 或连接断开扩展名白名单传 .exe 或 .sh 文件被拒绝路径穿越文件名带 ../被剥离断点续传用下载工具暂停再继续206 响应中文文件名下载中文名文件文件名乱码Swagger 路径发布后访问 swagger404静态文件访问上传目录不应直接可访问这套东西我踩过最狠的一次坑是本地测试一切正常发布到服务器后大文件上传全部失败排查了半天才发现是 Nginx 的client_max_body_size默认只有 1MB而 Kestrel 和 IIS 都放开了。后来养成的习惯是任何文件服务上线前先把四层大小限制全部对齐再用一个 500MB 的文件实测一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表