ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

.NET Core WebApi文件上传下载完整方案:流式写入与断点续传实践

.NET Core WebApi文件上传下载完整方案:流式写入与断点续传实践

简介:基于.NET Core WebApi的文件上传下载服务示例项目,面向初中级后端开发者,聚焦文件接口设计与实现。压缩包共50个文件,以25个C#源码为核心,配合9个JSON配置、5个工程文件、容器化配置、前端演示页及说明文档,直观展示服务端、中间件、测试客户端的多项目划分,整体压缩后仅206KB;其中还包括演示页面、示例图片与解决方案文件,便于本地运行调试。已有1921人学习下载。代码覆盖multipart/form-data与IFormFile的文件上传、下载响应头设置、文件流式写出等基础能力,同时针对大文件给出异步控制器、分块传输与缓存策略的性能优化思路;安全方面包含令牌权限校验、文件名清理与路径穿越防护,并附缩略图中间件、上传中间件等可复用组件。整体结构清晰,可直接在真实项目中二次扩展。

1. 一个文件服务不是只有上传下载:这套 .NET Core WebApi 方案解决了什么

某公司内部知识库系统每个月要处理几千次文件交互,之前用传统方式实现上传接口时,大文件把服务器内存直接打满,下载又只支持整包拉取,同事在办公网络里传一个 200MB 的压缩包,等到超时都没拿到文件。后来我基于 .NET Core WebApi 重新搭了一套文件上传下载服务,把上传改成流式写入磁盘、下载交给 FileStreamResult 托管、再配合断点续传,才彻底解决这些问题。这套资源就是完整的工程实现,适合刚接触 .NET Core 的开发者照着跑通全流程,也适合在既有项目里需要快速加文件模块的熟手。它解决的不只是“能传能下”的表面需求,更是“如何让文件服务稳定、可控、可维护”的工程问题——这也是我把它整理成一份完整项目笔记的原因。

2. 技术选型与工程结构:从目录看清一条请求的完整链路

2.1 技术栈画像:为什么选用 .NET Core WebApi 实现文件服务

选 .NET Core 做文件服务,核心原因是它把流式传输的底层细节封装得足够好。IFormFile直接绑定了 multipart/form-data 的解析过程,FileStreamResult天生支持输出流的封装,这两点让开发者不用去碰HttpContext.Request.Body那一层原始字节流,代码量和出错概率都大幅下降。

这套实现基于 .NET Core 的 WebApi 模板,类库引用保持最小化:Microsoft.AspNetCore.Mvc负责路由和参数绑定,System.IO处理文件流,System.Text.Json序列化返回信息。没有引入第三方包,所有功能都建立在框架自带能力之上。以前我用某个早期版本写类似的接口时,还需要手动处理multipart边界和流拷贝,到了 .NET Core 时代,框架把脏活累活都干了,这也是我现在给内部系统做基础组件时默认选它的原因。

在开发调试阶段,项目直接跑在 Kestrel 上;生产环境则通过反向代理(IIS 或 Nginx)暴露 80/443 端口。Kestrel 的MaxRequestBodySize默认是 30MB(约 30,000,000 字节),这个值对于上传场景需要显式调大,否则文件一旦超过限制,请求会被直接拒绝。框架的默认配置偏向安全保守,做文件服务的人第一件事就是跟它打交道。

2.2 目录结构:Controllers、Services、Models 各自负责什么

这套工程的目录结构遵循了 WebApi 最常见的三层划分,但针对文件场景做了微调。完整结构如下:

FileService/ ├── Controllers/ │ └── FileController.cs ├── Services/ │ ├── IFileStorageService.cs │ └── FileStorageService.cs ├── Models/ │ ├── UploadFileInfo.cs │ └── DownloadFileInfo.cs ├── wwwroot/ │ └── uploads/ ├── appsettings.json ├── Program.cs └── FileService.csproj

Controllers/FileController.cs是唯一暴露 HTTP 入口的地方,只做三件事:接收参数、调用服务、封装返回结果。这里刻意不写业务逻辑,因为文件操作的读写细节后续一定会调整,把逻辑隔离在 Service 层,改存储策略时 Controller 一行都不用动。

Services/IFileStorageService.cs定义了接口,FileStorageService.cs是它的实现。接口方法只有三个:SaveFileAsync保存上传流、GetFilePath解析文件存储路径、DeleteFileAsync删除指定文件。接口化设计的好处是方便替换实现——比如从本地磁盘切到云存储时,只需要重写这层。

Models/目录下的两个类承载了文件元数据。UploadFileInfo记录上传文件名、扩展名、大小、存储路径;DownloadFileInfo返回给前端下载链接使用。我没有把文件元数据直接塞进 Controller 的参数里,因为后续如果要加数据库表,这些 Model 可以直接映射过去。

wwwroot/uploads/是默认的存储根目录,运行时按日期自动创建子目录。.NET Core会把wwwroot下的文件当作静态资源处理,这带来一个隐患:如果上传目录暴露在wwwroot下,文件会直接通过静态文件中间件被匿名访问。我在代码里通过配置项把默认路径指向了自定义位置,具体做法在 3.4 节展开。

2.3 配置设计:appsettings 里存什么、IFormFile 的生命周期

配置集中在appsettings.json里,我不喜欢把文件路径硬编码在代码中。核心配置项如下:

{ "FileStorage": { "RootPath": "D:\\FileStorage", "MaxFileSizeMB": 200, "AllowedExtensions": [".jpg", ".jpeg", ".png", ".gif", ".zip", ".rar", ".pdf", ".docx", ".xlsx"] }, "Kestrel": { "Limits": { "MaxRequestBodySize": 209715200 } }, "Logging": { "LogLevel": { "Default": "Information" } } }

FileStorage:RootPath是存储根目录,用绝对路径避开wwwroot静态目录的暴露问题。MaxFileSizeMB是业务层的限制,用于代码逻辑判断;Kestrel:Limits:MaxRequestBodySize是服务器层的硬限制,单位是字节,200MB 对应 209715200。两处都要配置,业务层的限制只是友好提示,服务器层才是真正拦截的地方。

IFormFile的生命周期值得单独说。在 ASP.NET Core 中,IFormFile在 Action 方法执行时已经由框架完成了上传数据的解析,IFormFile.OpenReadStream()拿到的是一个可读的流。但这个流从哪来取决于数据量——小文件在内存缓冲区里,大文件会落临时文件。所以在上传接口里,必须在 Action 返回前把流读完并写入目标文件,不能把IFormFile对象缓存在内存里供后续使用,一旦请求结束,底层缓冲区就被回收了。我见过有人把IFormFile存到缓存里,下次取出来时流已经不可读,这种坑在文件服务里非常典型。

3. 核心实现拆解:上传流的写入与下载流的返回,代码逐行走一遍

3.1 上传链路:IFormFile 到 FileStream 的流式写入

上传接口的核心逻辑是流式拷贝,整个过程不要出现byte[]全量缓存。很多初学者喜欢MemoryStream中转一下,这在小文件场景没问题,但 200MB 的文件会让内存瞬间飙升。正解是直接用FileStream作为目标,让数据从请求流直接落到磁盘。

[HttpPost("upload")] [RequestSizeLimit(200 * 1024 * 1024)] public async Task<IActionResult> Upload(IFormFile file) { if (file == null || file.Length == 0) return BadRequest(new { message = "文件不能为空" }); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var allowedExts = _config.GetSection("FileStorage:AllowedExtensions").Get<string[]>(); if (!allowedExts.Contains(ext)) return BadRequest(new { message = $"不允许的文件类型: {ext}" }); if (file.Length > _maxFileSizeBytes) return BadRequest(new { message = $"文件大小超过限制({_maxFileSizeMB}MB)" }); var fileInfo = await _storageService.SaveFileAsync(file); return Ok(new UploadFileInfo { FileName = fileInfo.FileName, StoredPath = fileInfo.StoredPath, Size = fileInfo.Size, UploadTime = fileInfo.UploadTime }); }

RequestSizeLimit特性把单个请求体的上限锁死在 200MB,这个值要跟appsettings.json里的 Kestrel 配置保持一致,避免出现“业务层放开但服务器层拦截”的尴尬情况。Path.GetExtension拿到文件后缀后统一转小写,跟白名单比较,这里不能直接用Equals做大小写敏感的比较。

SaveFileAsync的核心实现如下:

public async Task<FileInfoModel> SaveFileAsync(IFormFile file) { var dateFolder = DateTime.Now.ToString("yyyyMMdd"); var rootPath = _config["FileStorage:RootPath"]; var targetDir = Path.Combine(rootPath, dateFolder); if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir); var storedName = $"{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}"; var fullPath = Path.Combine(targetDir, storedName); await using (var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920)) { await file.CopyToAsync(stream); } return new FileInfoModel { FileName = file.FileName, StoredPath = Path.Combine(dateFolder, storedName), Size = file.Length, UploadTime = DateTime.Now }; }

这段代码有几个细节值得注意。FileStream的缓冲区大小我设成 81920 字节,这是 .NET 文档推荐的大文件拷贝缓冲区大小,比默认的 4096 字节效率高很多。FileShare.None表示写入期间不允许其他进程读取该文件,避免并发写入同一个文件句柄导致数据错乱。文件名用Guid.NewGuid():N生成,不带短横线的 32 位十六进制字符串,再拼接原始扩展名——这样的好处是存储名完全唯一,且扩展名得到保留。

存储路径记录的是相对路径(如20250601/3f2b...jpg),而不是绝对路径。这样做的原因是后续如果迁移存储位置,数据库里已有的记录不需要更新。读取时只需要用配置的根路径拼上相对路径就能定位文件。

3.2 文件校验:扩展名白名单与文件大小限制的双层防线

只做扩展名校验远远不够,攻击者可以把可执行文件改成.jpg上传。我在这个项目里加了文件头魔数校验,读取文件的前几个字节与已知格式的特征码比对。这是常见做法中成本最低的一层防护。

private static readonly Dictionary<string, string> FileSignatures = new() { { ".jpg", "FFD8FF" }, { ".jpeg", "FFD8FF" }, { ".png", "89504E47" }, { ".gif", "47494638" }, { ".pdf", "25504446" }, { ".zip", "504B0304" } }; private bool ValidateFileSignature(Stream stream, string ext) { stream.Position = 0; if (!FileSignatures.ContainsKey(ext)) return true; using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); var bytes = reader.ReadBytes(4); var hex = BitConverter.ToString(bytes).Replace("-", ""); var expectedPrefix = FileSignatures[ext]; stream.Position = 0; return hex.StartsWith(expectedPrefix); }

BinaryReader读取前四个字节转成十六进制字符串,与特征码字典的表头比对。注意leaveOpen: true参数,读取完后不关闭底层流,因为后面CopyToAsync还需要从头读取。stream.Position = 0做了两次复位,保证文件流从头被读取。

这个校验有一个边界情况:字典里没有的扩展名直接返回true,原因是不认识的文件类型无法通过特征码判断,强校验可能导致误杀。生产中如果对安全要求高,应该做成白名单模式——不在字典里的类型直接拒绝,但这个项目的定位是内部文件服务,保留扩展性即可。

大小限制在前端也做了一层,但在服务端同样必须校验。后端的校验用file.Length判断,这个值由浏览器根据上传文件的实际大小填充,不存在跑在中间层被篡改的问题。真正的威胁来自直接构造 HTTP 请求绕过前端,所以服务端检查是最后一道防线,不可省略。

3.3 下载链路:FileStreamResult 与 Range 断点续传

下载接口是文件服务的另一个关键点。FileStreamResult会把文件以流的形式写回响应,同时支持断点续传——只要客户端请求带上Range头,框架会自动处理206 Partial Content响应。

[HttpGet("download/{fileName}")] public async Task<IActionResult> Download(string fileName) { var fileInfo = await _storageService.FindByStoredNameAsync(fileName); if (fileInfo == null) return NotFound(new { message = "文件不存在或已被删除" }); var fullPath = Path.Combine(_config["FileStorage:RootPath"], fileInfo.RelativePath); if (!System.IO.File.Exists(fullPath)) return NotFound(new { message = "物理文件缺失" }); var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var ext = Path.GetExtension(fileInfo.FileName).ToLowerInvariant(); var contentType = GetContentType(ext); return File(stream, contentType, fileInfo.FileName); }

File(stream, contentType, fileInfo.FileName)返回值会自动设置Content-Disposition: attachment头,文件名交给框架处理。这里要注意FileShare.ReadWrite设置为允许其他进程同时读文件,否则下载请求并发量上来后,文件会被句柄锁死。

GetContentType是一个辅助方法,负责把扩展名映射到 MIME 类型:

private string GetContentType(string ext) { return ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".pdf" => "application/pdf", ".zip" => "application/zip", ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", _ => "application/octet-stream" }; }

MIME 类型没有写全时浏览器可能把文件当纯文本渲染,导致下载变成乱码。application/octet-stream作为兜底值,能强制浏览器触发下载行为而不是尝试打开。

3.4 文件目录规划:按日期分桶的存储结构

存储结构采用“日期目录 + GUID 文件名”的机制。DateTime.Now.ToString("yyyyMMdd")生成日期目录,同一个目录下聚集当天的文件,方便做定时清理和归档。按日期分桶还有一个好处:统计“今天的上传量”只需要Directory.GetFiles(targetDir).Length,不用把所有文件遍历一遍。

删除文件时同理,按yyyyMM/dd/定位目录后执行File.Delete。为了避免删文件时出现 IO 异常,我在FileStorageService里包了一层 try-catch:

public bool DeleteFile(string relativePath) { try { var fullPath = Path.Combine(_config["FileStorage:RootPath"], relativePath); if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); return true; } catch (IOException) { return false; } }

Path.Combine在不同操作系统上会自动选择正确分隔符,Windows 上用\,Linux/macOS 上用/。我踩过坑:手动拼接字符串路径,导致部署到 Linux 环境后文件找不到。凡是用到路径拼接的地方都交给Path.Combine,这是项目里的一条规定。

4. 从编译到部署:运行配置、踩坑记录与排查思路

4.1 本地运行:从 dotnet build 到 Swagger 调试

拿到项目后先在本地跑起来,验证整套流程。环境要求是 .NET 6.0 及以上 SDK,Visual Studio 2022 或 Rider 都可以,命令行方式最轻量:

cd FileService dotnet restore dotnet build --configuration Debug dotnet run

dotnet restore从 NuGet 拉取依赖包,dotnet build编译整个解决方案,dotnet run启动 Kestrel 宿主。默认监听端口在launchSettings.json里配置,通常是http://localhost:5000或http://localhost:5210,具体以实际输出为准。

启动后在浏览器打开 Swagger 页面(/swagger/index.html),直接测试上传接口。Swagger 会把IFormFile参数渲染成文件选择框,操作起来比 Postman 更直观。测试时注意三点:先用小文件跑通,再逐步加大文件体积;记录请求耗时观察大文件的流式写入是否正常;上传完成后去存储目录确认文件是否真实落盘。

如果项目运行时报缺少包的错误,用dotnet restore拉取;如果端口被占用,在launchSettings.json里改applicationUrl。这两个是最常见的启动问题,很少遇到更隐蔽的故障。

4.2 部署配置:Kestrel 与反向代理的大小限制

生产部署时,Kestrel 作为后端服务,前面通常挂 IIS 或 Nginx。这时候上传大小限制有两层需要同时调整:Kestrel 自身的MaxRequestBodySize,以及反向代理的client_max_body_size。

Nginx 的默认client_max_body_size是 1MB,不配置的话 2MB 的文件就会被 413 拒绝。Nginx 配置如下:

server { listen 80; server_name yourdomain.com; client_max_body_size 200m; location / { proxy_pass http://localhost:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

如果走 IIS,需要修改web.config中的maxAllowedContentLength,单位是字节:

<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="209715200" /> </requestFiltering> </security> </system.webServer>

我早期的项目只改了 Kestrel 配置,部署到 IIS 后上传 10MB 文件直接被拒,排查了半天才发现是 IIS 的maxAllowedContentLength默认只有 30MB。现在我的习惯是无论部署到哪个环境,先把代理层的大小限制查一遍再上线,这种部署层的“黑匣子”最坑人。

4.3 避坑:文件服务最常见的五个坑

坑 1:上传超过 30MB 直接报 413现象:通过 Postman 上传大文件,返回 413 Payload Too Large。原因:Kestrel 默认MaxRequestBodySize是 30MB,未显式放开。解决:在Program.cs中配置Kestrel限制,或使用[RequestSizeLimit]特性在控制器级别覆盖。注意两种方式都要设置,只设一处都不完整。

坑 2:下载文件名中文乱码现象:浏览器下载文件时,中文文件名变成一串百分号编码或乱码。原因:Content-Disposition头里的文件名需要 URL 编码,框架默认处理有时不兼容旧版浏览器。解决:手动构造Content-Disposition,同时提供filename*的 RFC 5987 格式。

var encodedFileName = Uri.EscapeDataString(fileInfo.FileName); Response.Headers.ContentDisposition = $"attachment; filename=\"{encodedFileName}\"; filename*=UTF-8''{encodedFileName}"; return File(stream, contentType);

坑 3:上传完成后文件被占用无法删除现象:文件上传成功后,在 Windows 资源管理器里删除该文件,提示文件正被其他进程使用。原因:FileStream未释放或释放不彻底;await using可以保证正常释放,但某些异常路径下流会残留。解决:把await using包在 try-catch 外层,确保任何路径都能释放;也可以调用GC.Collect()强制回收,但不推荐频繁使用。

坑 4:Linux 部署后路径分隔符问题现象:Windows 上开发时文件路径正常,部署到 Linux 容器后提示“找不到路径的一部分”。原因:代码里用了"\\"或"/"硬编码路径分隔符。解决:全部用Path.Combine和Path.DirectorySeparatorChar拼接路径,不要手写路径字符串。这是跨平台部署最容易踩的坑。

坑 5:高并发上传时文件相互覆盖现象:两个请求同时上传同名文件,存储目录里只有一个文件,且内容混乱。原因:文件名用了原始文件名拼接,未做唯一性处理。解决:存储名一律用Guid.NewGuid():N生成,从根上避免冲突。同时FileShare.None防止写入期间被并发读取。

5. 二次开发:把它改成你自己的文件服务

5.1 扩展方向:分片上传与秒传

当前的上传接口是单文件整体上传,生产环境中大文件(超过 1GB)时常因网络抖动导致上传中断,需要从头再来。分片上传是这类场景的标准解法,改造思路分三步:前端把文件切分为固定大小(如 5MB)的分片,逐一上传并携带chunkIndex和fileId;服务端新增合并接口,接收分片元数据后按顺序拼接;上传完成后用前端算好的 MD5 在服务端比对,验证文件完整性。核心拼接代码:

[HttpPost("merge")] public async Task<IActionResult> MergeChunks(MergeRequest request) { var chunkDir = Path.Combine(_config["FileStorage:RootPath"], "chunks", request.FileId); if (!Directory.Exists(chunkDir)) return NotFound("分片目录不存在"); var finalPath = Path.Combine(_config["FileStorage:RootPath"], request.FileName); await using var finalStream = new FileStream(finalPath, FileMode.Create, FileAccess.Write); for (int i = 0; i < request.TotalChunks; i++) { var chunkPath = Path.Combine(chunkDir, $"{i:D5}.part"); if (!System.IO.File.Exists(chunkPath)) return BadRequest($"缺失分片 {i}"); await using var chunkStream = new FileStream(chunkPath, FileMode.Open, FileAccess.Read); await chunkStream.CopyToAsync(finalStream); } Directory.Delete(chunkDir, recursive: true); return Ok(new { filePath = finalPath }); }

合并流程中最重要的是分片顺序的保证,我用五位数字编号00000.part到99999.part,排序后拼接即可。如果分片丢失或顺序错乱,合并结果就会损坏,所以循环中要检查每个分片是否存在。秒传功能的前提是服务端保存了每个文件的 MD5 索引表,上传前先查表,命中直接返回“上传成功”,跳过实际传输。

5.2 安全加固:临时链接与权限校验

文件服务的权限控制常被忽略。当年的做法是所有人知道 URL 就能下载,后来内部出现数据泄露才加上校验。生产级改造至少需要两层:第一层在下载接口里验证登录态或 API Key,第二层对敏感文件生成带时效的临时链接。

临时链接的生成可以用 JWT 承载文件路径和过期时间,签发给有权限的用户,鉴权中间件解密后校验过期时间。成熟的开源中间件有很多现成实现,但自研几十行代码也能做到:

public string GenerateTemporaryLink(string relativePath, DateTime expiryTime) { var payload = $"{relativePath}|{expiryTime:O}"; var token = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)); return $"/api/file/temp/{token}"; } [HttpGet("temp/{token}")] public async Task<IActionResult> DownloadTemporary(string token) { var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(token)); var parts = decoded.Split('|'); var relativePath = parts[0]; var expiryTime = DateTime.Parse(parts[1]); if (DateTime.Now > expiryTime) return Unauthorized("链接已过期"); return await Download(Path.GetFileName(relativePath)); }

这个自研 Token 实现适合内部系统快速落地,安全要求高的场景还是要走 JWT + Redis 的方案,原理保持一致。我把这套文件服务开源给团队后,前后端联调效率提高了很多,从那以后我每次搭建基础服务都强制走一遍“先定义接口契约 → 再实现服务逻辑 → 最后才写控制器”的顺序,这套方法让我几乎告别了返工。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进