简介基于.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.csprojControllers/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 TaskIActionResult 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).Getstring[](); 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 TaskFileInfoModel 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 Dictionarystring, 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 TaskIActionResult 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-catchpublic 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 及以上 SDKVisual Studio 2022 或 Rider 都可以命令行方式最轻量cd FileService dotnet restore dotnet build --configuration Debug dotnet rundotnet 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 maxAllowedContentLength209715200 / /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()强制回收但不推荐频繁使用。坑 4Linux 部署后路径分隔符问题现象Windows 上开发时文件路径正常部署到 Linux 容器后提示“找不到路径的一部分”。原因代码里用了\\或/硬编码路径分隔符。解决全部用Path.Combine和Path.DirectorySeparatorChar拼接路径不要手写路径字符串。这是跨平台部署最容易踩的坑。坑 5高并发上传时文件相互覆盖现象两个请求同时上传同名文件存储目录里只有一个文件且内容混乱。原因文件名用了原始文件名拼接未做唯一性处理。解决存储名一律用Guid.NewGuid():N生成从根上避免冲突。同时FileShare.None防止写入期间被并发读取。5. 二次开发把它改成你自己的文件服务5.1 扩展方向分片上传与秒传当前的上传接口是单文件整体上传生产环境中大文件超过 1GB时常因网络抖动导致上传中断需要从头再来。分片上传是这类场景的标准解法改造思路分三步前端把文件切分为固定大小如 5MB的分片逐一上传并携带chunkIndex和fileId服务端新增合并接口接收分片元数据后按顺序拼接上传完成后用前端算好的 MD5 在服务端比对验证文件完整性。核心拼接代码[HttpPost(merge)] public async TaskIActionResult 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 TaskIActionResult 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 的方案原理保持一致。我把这套文件服务开源给团队后前后端联调效率提高了很多从那以后我每次搭建基础服务都强制走一遍“先定义接口契约 → 再实现服务逻辑 → 最后才写控制器”的顺序这套方法让我几乎告别了返工。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?