☰
ASP.NET Core WebApi文件服务:上传下载与分层设计实践
2026/10/29 11:18:01 网站建设 项目流程

简介:这份资源面向正在学习或使用 .NET Core 开发 WebAPI 的开发者,聚焦文件上传与下载服务的完整实现,涵盖 multipart/form-data 表单处理、IFormFile 接收、Content-Disposition 与 Content-Type 响应头设置、流式输出、权限验证、路径与文件名安全、异步与分块传输等常见工程要点,适合希望把接口能力落地到实际项目的中级读者参考。压缩包共 50 个文件,约 206KB,以 25 个 cs 源码文件为核心,配合 5 个 csproj 项目文件、9 个 json 配置、4 个 js 前端脚本及 Dockerfile、sln 解决方案等,构成可运行的多项目示例结构。内容预览显示其中包含上传服务、前端演示、客户端 Demo 与中间件等模块,便于对照理解上传中间件、缩略图处理与负载均衡上传等设计思路。已有 1922 人学习,可作为搭建文件服务的实践起点。

1. 一个上传接口写到第三版,我才想明白文件服务该怎么拆

去年接手一个后台管理系统,前端要传产品图、Excel 导入模板、用户头像,后端一开始图省事,所有上传逻辑全塞在一个 Controller 里,IFormFile收进来直接File.WriteAllBytes落盘,下载就拼个物理路径返回。上线两周就翻车:同名文件互相覆盖、大文件把内存顶满、前端拿不到下载进度、文件路径写死在代码里换台机器就跑不起来。后来我把这块单独抽成一个 .NET Core WebApi 文件服务,上传、下载、元数据查询、删除四件事各归各位,才算把坑填上。这篇就按我重构后的这套服务来讲——它适合正在用 ASP.NET Core 做 WebApi、需要一套能直接跑起来的文件上传下载骨架的开发者,新手能照着把服务跑通,熟手能看清分层和几个关键实现点。

2. 项目定位与技术选型:为什么是 WebApi 而不是 MVC

2.1 这个服务到底解决什么问题

先把边界划清楚。这套文件服务只做四件事:接收上传(单文件、多文件)、按标识下载、返回文件元信息、删除文件。它不负责图片压缩、不负责视频转码、不负责权限体系——这些是业务层的事,文件服务只保证「存得进、找得到、取得出、删得掉」。这个边界很重要,我见过太多项目把缩略图生成、水印、格式校验全塞进上传接口,结果一个上传请求要跑三秒,前端体验直接崩。

技术栈上,运行时是 .NET Core(现在叫 .NET,3.1 之后统一命名,代码层面差异不大),Web 框架用 ASP.NET Core WebApi,依赖注入用框架自带的IServiceCollection,配置走appsettings.json的IOptions模式,日志用内置ILogger。没有引入第三方对象存储 SDK,文件落在本地磁盘的一个可配置根目录下——这是最朴素的形态,也是二次开发时最容易替换成对象存储的形态。

为什么选 WebApi 而不是 MVC?因为这个服务没有页面,纯接口消费,WebApi 的[ApiController]特性自带模型验证、自动 400 响应、参数绑定推断,比 MVC 少一层视图渲染的开销和心智负担。前端可以是任何东西:Vue 后台、小程序、甚至 curl 测试,接口契约稳定就行。

2.2 分层结构与目录长什么样

我把项目拆成四层,目录结构大致如下:

FileService.Api/ ├── Controllers/ │ └── FilesController.cs # 只做参数接收和响应封装 ├── Services/ │ ├── IFileStorageService.cs # 存储抽象 │ └── LocalFileStorageService.cs# 本地磁盘实现 ├── Models/ │ ├── FileUploadResult.cs # 上传返回的元数据 │ └── FileMetadata.cs # 文件信息模型 ├── Options/ │ └── StorageOptions.cs # 存储根目录、大小限制等配置 ├── Middleware/ │ └── ExceptionHandlingMiddleware.cs ├── appsettings.json └── Program.cs

Controller 薄,Service 厚,这是这套结构最核心的一条纪律。Controller 里不允许出现FileStream、不允许拼路径、不允许判断磁盘剩余空间,这些全在LocalFileStorageService里。好处是将来要换成 MinIO 或者云对象存储,只需要新写一个IFileStorageService的实现,Controller 一行不改。这个抽象我在第三个项目里才真正体会到价值——当时客户要求文件必须存到内网一台 NAS 上,我花了半天写了个NasFileStorageService,注册换一下,其他全没动。

配置项单独抽成StorageOptions,通过IOptions<StorageOptions>注入,而不是到处Configuration["xxx"]硬读。这样单元测试时可以塞一个假配置,不用起整个宿主。

public class StorageOptions { public const string SectionName = "Storage"; // 文件落盘的根目录,建议用绝对路径,相对路径会跟着工作目录漂 public string RootPath { get; set; } = "uploads"; // 单文件大小上限,单位字节,默认 50MB public long MaxFileSizeBytes { get; set; } = 50 * 1024 * 1024; // 允许的扩展名白名单,空表示不限制 public string[] AllowedExtensions { get; set; } = Array.Empty<string>(); }

SectionName这个常量是为了Configure<StorageOptions>(Configuration.GetSection(StorageOptions.SectionName))时不用手写字符串,改配置节名只改一处。RootPath默认给相对路径只是图省事,生产环境我一定改成绝对路径,原因后面避坑章节会讲。AllowedExtensions用数组而不是逗号分隔字符串,绑定配置时框架能直接解析 JSON 数组,省掉自己 split 的代码。

2.3 上传链路的关键实现

上传接口的签名长这样:

[HttpPost("upload")] [RequestSizeLimit(100 * 1024 * 1024)] // 请求体上限,和业务配置是两道闸 public async Task<ActionResult<FileUploadResult>> Upload( IFormFile file, CancellationToken cancellationToken) { if (file == null || file.Length == 0) return BadRequest("文件为空"); var result = await _storage.SaveAsync(file, cancellationToken); return Ok(result); }

[RequestSizeLimit]和StorageOptions.MaxFileSizeBytes是两道独立的闸:前者是 Kestrel 层面的请求体上限,超了直接 413,请求根本进不到方法体;后者是业务层校验,超了返回友好错误。两道都要有,只配一道都会出问题——只配业务层,超大文件还是会先被读进内存再被拒;只配 Kestrel 层,用户看到的是冷冰冰的 413 而不是「文件超过 50MB」。

真正的存储在SaveAsync里:

public async Task<FileUploadResult> SaveAsync(IFormFile file, CancellationToken ct) { // 1. 扩展名白名单校验 var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (_options.AllowedExtensions.Length > 0 && !_options.AllowedExtensions.Contains(ext)) throw new InvalidOperationException($"不支持的扩展名: {ext}"); // 2. 生成不冲突的存储名:日期分目录 + GUID,避免同名覆盖 var datePart = DateTime.UtcNow.ToString("yyyyMMdd"); var storedName = $"{Guid.NewGuid():N}{ext}"; var dir = Path.Combine(_options.RootPath, datePart); Directory.CreateDirectory(dir); // 3. 流式写入,不整块读进内存 var fullPath = Path.Combine(dir, storedName); await using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await file.CopyToAsync(fs, ct); } return new FileUploadResult { FileId = $"{datePart}/{storedName}", // 对外标识,不含根路径 OriginalName = file.FileName, Size = file.Length, ContentType = file.ContentType, UploadedAt = DateTime.UtcNow }; }

逐段说。扩展名校验放在最前面,ToLowerInvariant是为了防止.JPG和.jpg被当成两种;白名单为空表示不限制,这是给内部系统留的口子,对外服务一定配白名单。存储名用 GUID 而不是原始文件名,直接消灭了同名覆盖和路径穿越两个问题——原始文件名里带../的情况我真实遇到过,用 GUID 之后这类攻击面直接没了。日期分目录是为了单目录文件数不至于爆炸,Linux 下单目录几十万文件时ls都会卡。FileMode.CreateNew配合 GUID 名,理论上不会撞,撞了会抛异常而不是静默覆盖,这是我要的行为。CopyToAsync的缓冲区给 81920 字节(80KB),是 .NET 里流拷贝的常用默认值,太小系统调用多,太大占内存。返回的FileId用日期/文件名的相对形式,不暴露服务器绝对路径,下载时再拼回去。

2.4 下载与删除:把路径拼回去,但别拼错

下载接口接收FileId,拼出物理路径后返回FileStreamResult:

[HttpGet("download/{*fileId}")] public async Task<IActionResult> Download(string fileId) { var (stream, contentType, downloadName) = await _storage.OpenReadAsync(fileId); // enableRangeProcessing 让浏览器支持断点续传和视频拖动 return File(stream, contentType, downloadName, enableRangeProcessing: true); }

路由用{*fileId}而不是{fileId},因为FileId里带斜杠(20240101/xxx.jpg),普通路由参数遇到斜杠就断了,catch-all 才能完整接住。enableRangeProcessing: true这个参数值得单独说:不开的话,大文件下载中断只能从头再来,视频也没法拖动进度条,开了之后框架自动处理Range请求头。这个参数我第一版漏了,测试同学传了个 200MB 的安装包,下到 90% 断网,重下从头开始,被吐槽了一下午。

OpenReadAsync里最关键的一行是路径安全校验:

var fullPath = Path.GetFullPath(Path.Combine(_options.RootPath, fileId)); var rootFull = Path.GetFullPath(_options.RootPath); if (!fullPath.StartsWith(rootFull + Path.DirectorySeparatorChar)) throw new UnauthorizedAccessException("非法路径");

GetFullPath会把..解析掉,然后判断结果是否还在根目录下。这一步不能省,fileId是外部传进来的,不校验的话../../etc/passwd这种就能读到根目录外的文件。删除接口同理,先校验路径再File.Delete,删完顺手清理空目录,不然日期目录会越积越多。

3. 从零跑起来:环境、配置与第一次请求

3.1 环境准备和项目启动

需要 .NET SDK,版本 6.0 及以上都行,代码里没用到 8 的特性。装好后dotnet --version确认一下。项目创建用dotnet new webapi -n FileService.Api,然后把上面说的目录和文件补进去。依赖只有框架自带的,不需要额外dotnet add package,这是这套骨架的一个好处——没有第三方包,升级 .NET 版本时少一层兼容性焦虑。

appsettings.json里加存储配置:

{ "Storage": { "RootPath": "/data/fileservice/uploads", "MaxFileSizeBytes": 52428800, "AllowedExtensions": [".jpg", ".png", ".pdf", ".xlsx", ".docx", ".zip"] } }

Program.cs里注册服务和配置:

var builder = WebApplication.CreateBuilder(args); builder.Services.Configure<StorageOptions>( builder.Configuration.GetSection(StorageOptions.SectionName)); builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>(); builder.Services.AddControllers(); var app = builder.Build(); app.MapControllers(); app.Run();

LocalFileStorageService注册成 Singleton 是安全的,因为它不持有请求级状态,只读配置、操作磁盘。如果将来实现里要注入DbContext之类 Scoped 服务,就得改成 Scoped 或者用工厂,这是 DI 生命周期的一个常见坑,后面会提。

启动用dotnet run,默认监听http://localhost:5000(.NET 6 之后端口可能随机,看控制台输出)。第一次请求用 curl 验证:

# 上传 curl -F "file=@./test.png" http://localhost:5000/api/files/upload # 下载,fileId 用上一步返回的值 curl -o out.png "http://localhost:5000/api/files/download/20240101/abc123.png"

上传成功会返回 JSON,里面有fileId、originalName、size。拿fileId去下载,能拿回原文件就说明链路通了。这一步看着简单,但我建议第一次跑的时候故意传一个超过 50MB 的文件、传一个.exe,确认两道校验都生效,比上线后才发现强。

3.2 用 Swagger 和日志把黑匣子打开

dotnet new webapi默认带 Swagger,开发环境访问/swagger就能看到接口列表,直接页面上传测试,比 curl 直观。生产环境记得关掉,if (app.Environment.IsDevelopment())包一下就行。

日志方面,上传和下载各打一条结构化日志:

_logger.LogInformation("文件上传成功 FileId={FileId} Size={Size} Name={Name}", result.FileId, result.Size, result.OriginalName);

用命名占位符而不是字符串拼接,这样日志系统能按字段检索。文件服务出问题时,最常查的就是「这个 fileId 到底存没存进去」「存到哪个目录了」,有这条日志能省很多事。我一般还会在SaveAsync开头打一条 Debug 级别的「开始接收」,排查大文件上传卡在哪一步时有用。

3.3 多文件上传和进度反馈

单文件接口之外,业务里经常要一次传多张图。加一个多文件接口:

[HttpPost("upload-batch")] public async Task<ActionResult<List<FileUploadResult>>> UploadBatch( List<IFormFile> files, CancellationToken ct) { var results = new List<FileUploadResult>(); foreach (var f in files) results.Add(await _storage.SaveAsync(f, ct)); return Ok(results); }

注意这里是顺序await,不是Task.WhenAll并发写。并发写磁盘在机械盘上会互相抢 IO,反而更慢,而且部分失败时事务语义很难处理。顺序写简单可靠,文件数量不大时完全够用。真要提速,瓶颈通常在网络传输而不是磁盘写入。

进度反馈这块,WebApi 本身不提供上传进度回调,前端拿进度靠XMLHttpRequest.upload.onprogress或fetch配合ReadableStream,这是浏览器侧的事,后端不用管。但下载进度如果要精确控制,就得靠enableRangeProcessing加前端分片请求,前面提过了。

4. 避坑与排查:文件服务最容易翻车的五个地方

4.1 相对路径导致换环境就找不到文件

现象:本地跑得好好的,部署到服务器后上传成功,下载 404。原因:RootPath配的是相对路径uploads,它相对于进程的工作目录,dotnet run时工作目录是项目目录,用 systemd 或 IIS 托管时工作目录可能是/或C:\Windows\System32,文件存到了完全不同的地方。解决:生产环境RootPath一律配绝对路径,启动时打一条日志把Path.GetFullPath(RootPath)打出来,部署后第一件事就是核对这条日志。

4.2 大文件上传报 413 但业务校验没触发

现象:传 100MB 文件,返回 413,日志里连「开始接收」都没有。原因:Kestrel 默认请求体上限约 30MB,请求在进入 Controller 之前就被拒了,[RequestSizeLimit]没配或者配小了。解决:接口上[RequestSizeLimit]和[RequestFormLimits(MultipartBodyLengthLimit = ...)]都要配,后者管的是 multipart 表单体的上限,两个是不同层。配完还要确认反向代理(Nginx 的client_max_body_size)也放开了,否则请求到不了应用。

4.3 下载中文文件名变成乱码

现象:下载的文件名在浏览器里显示成%E4%B8%AD%E6%96%87.pdf或者乱码。原因:HTTP 头里非 ASCII 字符需要按 RFC 5987 编码,直接塞中文到Content-Disposition里,不同浏览器解析行为不一致。解决:用File(stream, contentType, downloadName)这个重载,框架内部会做filename*=UTF-8''编码,别自己手拼Content-Disposition头。如果确实要手拼,记得同时给filename和filename*两个参数。

4.4 Singleton 服务里注入了 Scoped 依赖

现象:启动时报Cannot consume scoped service from singleton,或者运行一段时间后数据错乱。原因:LocalFileStorageService注册成 Singleton,但构造函数里注入了DbContext这类 Scoped 服务,容器直接拒绝;如果用的是IServiceProvider手动取,则会拿到同一个 DbContext 实例跨请求复用,状态污染。解决:存储服务保持无状态、只依赖IOptions和ILogger;确实要访问数据库,把数据库操作放到 Controller 或单独的 Scoped 服务里,存储服务只负责字节流。

4.5 磁盘写满后上传静默失败

现象:某天开始所有上传都返回 500,但代码没改过。原因:磁盘满了,FileStream写入抛IOException,异常中间件统一包成 500,日志里只有堆栈没有「磁盘满」的直观提示。解决:SaveAsync里捕获IOException,判断HResult或消息里是否含磁盘空间相关关键字,转成明确的错误码;更稳妥的做法是上传前检查目标盘剩余空间,低于阈值直接拒绝并告警。这个坑我是被运维打电话叫起来才补上的。

5. 二次开发:把本地存储换成对象存储,以及几个我常用的改造点

这套骨架最大的价值在IFileStorageService这个抽象。接口只有三个方法:SaveAsync、OpenReadAsync、DeleteAsync,返回的都是与存储介质无关的模型。要换成对象存储,新写一个实现类,Program.cs里把注册换掉,Controller 和前端契约完全不动。下面是我实际改造时的一个最小实现骨架:

public class ObjectStorageService : IFileStorageService { private readonly IObjectClient _client; // 假想的对象存储客户端 public async Task<FileUploadResult> SaveAsync(IFormFile file, CancellationToken ct) { var key = $"{DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid():N}"; await using var stream = file.OpenReadStream(); await _client.PutObjectAsync(key, stream, file.ContentType, ct); return new FileUploadResult { FileId = key, /* 其余字段 */ }; } public async Task<(Stream, string, string)> OpenReadAsync(string fileId) { var obj = await _client.GetObjectAsync(fileId); return (obj.Stream, obj.ContentType, obj.Metadata["original-name"]); } public Task DeleteAsync(string fileId) => _client.DeleteObjectAsync(fileId); }

改造时有三个点要注意。第一,原始文件名在对象存储里没有「文件名」概念,得存到对象的 metadata 里,下载时取出来,否则用户下载到的是一串 GUID。第二,OpenReadAsync返回的流如果是网络流,FileStreamResult会边读边传,客户端断开时要确保流被释放,FileStreamResult内部会 dispose,但对象存储 SDK 的流有时需要显式关闭,建议包一层。第三,删除操作在对象存储里通常是幂等的,删不存在的 key 不报错,而本地实现里File.Delete对不存在的文件也不报错,行为一致,这点不用特殊处理。

验证改造是否成功,我一般跑一个三步清单:上传一个小文件拿到fileId;用fileId下载并比对 MD5;删除后再下载应返回 404。三步都过,基本就没问题。这个清单我写成了一个 bash 脚本,每次换存储实现都跑一遍,比手工点强。

另一个常做的改造是加文件元数据持久化。现在元数据只在上传响应里返回,服务重启就丢了。业务上经常要按上传时间、上传人查文件列表,这时候就得把FileUploadResult落库,加一张FileRecords表,FileId做主键,上传时插入、删除时软删。表结构大致是FileId、OriginalName、Size、ContentType、UploadedAt、UploadedBy、IsDeleted几个字段,索引建在UploadedAt和UploadedBy上。这一步做完,文件服务才算真正能支撑业务查询,而不只是个存取管道。

最后一个技巧关于测试。文件服务的测试难点在IFormFile,它不好 mock。我的做法是在测试里用FormFile包一个MemoryStream构造真实实例,而不是 mock 接口:

var bytes = Encoding.UTF8.GetBytes("hello"); var formFile = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "test.txt");

这样测的是真实代码路径,比 mock 可靠。存储根目录在测试里指向Path.GetTempPath()下的临时目录,每个测试用例用完删掉,避免污染。

这套东西我从第一版的「一个 Controller 全包」改到现在,中间踩的坑基本都在上面列出来了。从那以后我每次新建文件服务,第一件事就是把RootPath配成绝对路径并在启动日志里打出来,第二件事是把路径校验那几行抄进去——这两件事花不了五分钟,但能省掉后面一堆半夜排查。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询