简介:这套面向 .NET Core 开发者的 WebAPI 文件服务源码,聚焦上传与下载能力的工程化实现,适合已有一定框架基础、希望参考完整项目结构来搭建文件接口、中间件和演示调用的中级后端开发者。压缩包共 50 个文件、约 206KB,以 25 个 C# 源码和 5 个项目工程文件为核心,辅以 JSON 配置、JavaScript 演示脚本、说明文档与容器构建文件,按多个解决方案和示例项目拆分,涵盖服务端接口、上传及缩略图中间件、客户端调用、配置管理等模块,便于按功能查阅。已有 1924 人学习下载,具备一定参考热度。读者可从中理解表单文件接收、响应头与 MIME 设置、流式下载和异步处理,也能看到权限验证、路径与文件名清理、类型限制、错误日志等安全实践,并借助现成代码快速搭建可运行的文件服务骨架。
1. 从七个重复的复制流说起
某开发者的后台系统里,合同 PDF、Excel 导入模板、用户头像全挤在同一个 WebApi 里,上传逻辑原本只是业务控制器里的十几行代码。等到要换存储位置、要加大小限制、要做断点续传时,才发现有七个地方各写了一遍复制流的代码,改一处漏六处。把文件能力拆出来单独做一个 .netcore Webapi 文件服务,就是从这个痛点开始的。
这套东西对外只留上传、下载、删除、元信息四个接口,用IFormFile接 multipart 表单,用FileStreamResult输出文件流,存储落在本地磁盘或容器挂载卷上,不强制依赖数据库。它适合想给现有系统加文件能力、又不想一上来就接对象存储的人;也适合被业务代码里文件逻辑缠住、想照着拆一遍的人。.NET 6 和 .NET 8 都能跑,代码量不大,坑不少。
2. 项目骨架与技术选型:三层目录怎么切才不用回头改
新建一个dotnet new webapi出来的模板,默认只有Program.cs和一个示例控制器,直接往里塞文件逻辑也能跑。但问题是,文件服务有几个很特殊的诉求:存储位置要能换(本地磁盘 / 挂载卷 / 以后的云存储)、限额要能调、校验规则要能扩。这三件事如果写死在控制器里,第二次需求变更就得动十几个地方。所以我一般会先按职责切三层:接请求的 Controller、管协议的 Service、管数据的 Model。
2.1 目录结构:控制器、存储抽象、模型各管一段
落地后的目录长这样,不是教科书式的分层,而是按「改哪里最频繁」切的:
FileService.Api/ ├── Controllers/ │ └── FilesController.cs # 只做参数接收、状态码、异常转 HTTP ├── Services/ │ ├── IFileStorage.cs # 存储抽象,换后端只实现这一个接口 │ ├── LocalFileStorage.cs # 本地磁盘实现,流式写入 │ └── FileValidator.cs # 大小 / 扩展名 / 文件头校验 ├── Models/ │ ├── FileMetadata.cs # 返回给调用方的元信息 │ ├── UploadResult.cs # 上传结果 DTO │ └── ValidateResult.cs # 校验结果,带错误码 ├── Options/ │ └── StorageOptions.cs # 强类型配置 ├── appsettings.json └── Program.csControllers这一层要尽量简单,只负责三件事:把 HTTP 参数翻译成方法参数、把服务层返回的结果翻译成状态码、把异常翻译成错误响应。凡是「文件该存哪、叫什么名字、允不允许」这类判断,一律不进控制器。
Services是核心。IFileStorage只定义四个方法:SaveAsync、OpenRead、DeleteAsync、FindAsync。这个接口设计的关键是不要泄露物理路径,方法签名里传的是元信息对象,不是string path。一旦接口里出现路径,将来换对象存储就得改调用方,抽象就白做了。
Models里的FileMetadata是我见过最容易被做错的一个类。新手常写成{ Name, Path }两个字段,结果下载接口拿到Path就直接File.OpenRead,客户端传个../../web.config就穿了。合格的写法是只暴露Id、OriginalName、RelativePath、Size、ContentType、UploadedAt,物理路径由存储实现内部拼,外部永远拿不到。
2.2 依赖注入与配置:把限额和根目录从硬编码里赶出去
第一步是把可变的参数抽成强类型配置。这一步不做,后面每换一个环境就得改代码重编译:
// Options/StorageOptions.cs public sealed class StorageOptions { // 物理根目录,容器里通常映射到 /data/files public string RootPath { get; set; } = "App_Data/files"; // 单文件上限(字节),业务侧校验用,和 Kestrel 限额不是一回事 public long MaxFileSizeBytes { get; set; } = 100L * 1024 * 1024; // 允许的扩展名,统一小写、带点 public string[] AllowedExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".pdf", ".xlsx", ".docx", ".zip" }; // 流拷贝缓冲区,81920 是 .NET 里 Stream.CopyToAsync 的默认值 public int CopyBufferSize { get; set; } = 81920; }对应的appsettings.json,注意RootPath在开发环境用相对路径、生产环境用绝对路径,两者都用同一份配置键,靠appsettings.Production.json覆盖:
{ "Storage": { "RootPath": "/data/files", "MaxFileSizeBytes": 104857600, "AllowedExtensions": [ ".jpg", ".jpeg", ".png", ".pdf", ".xlsx", ".docx", ".zip" ], "CopyBufferSize": 81920 } }注册的时候有个细节值得说:LocalFileStorage我注册成Singleton,FileValidator注册成Scoped。原因很实在,前者除了配置和日志没有别的状态,单例省一次分配;后者如果要挂IHttpContextAccessor或者请求级的审计日志,单例就会串号。
var builder = WebApplication.CreateBuilder(args); builder.Services.Configure<StorageOptions>( builder.Configuration.GetSection("Storage")); // 无状态 + 只读配置,单例足够 builder.Services.AddSingleton<IFileStorage, LocalFileStorage>(); // 可能用到请求上下文或作用域服务,注册为 Scoped builder.Services.AddScoped<FileValidator>(); builder.Services.AddControllers(); var app = builder.Build(); app.MapControllers(); app.Run();参数说明:Configure<StorageOptions>把Storage节点绑定到类上,注入时用IOptions<StorageOptions>(启动时快照,性能最好)。如果希望改配置不重启,换成IOptionsMonitor<StorageOptions>,但在文件服务里没必要,根目录中途变化会让已有文件失联,反而是负担。
2.3 请求管线:限额为什么要在三处各设一遍
这是新手最容易翻车的一个点。很多人的上传接口在本地测试 5MB 文件没问题,上线后收到用户反馈「传 40MB 的视频直接报错」,日志里只有一个干巴巴的 413。原因是请求体大小有三道独立的控制限制,任何一道没放开都会失败:
| 层级 | 配置项 | 默认值 | 作用范围 |
|---|---|---|---|
| 服务器 | KestrelServerOptions.Limits.MaxRequestBodySize | 30,000,000 字节 | 整个请求体 |
| 表单解析 | FormOptions.MultipartBodyLengthLimit | 134,217,728 字节 | 单个 multipart 段 |
| 业务 | StorageOptions.MaxFileSizeBytes | 自定义 | 单个文件 |
第一道在Program.cs里改,必须在builder.Build()之前:
builder.WebHost.ConfigureKestrel(o => { // 全局上限,留出 multipart 边界和表单字段的余量,比业务上限略大 o.Limits.MaxRequestBodySize = 200L * 1024 * 1024; }); builder.Services.Configure<FormOptions>(o => { // multipart 单段上限,超过会抛 InvalidDataException,而不是返回 413 o.MultipartBodyLengthLimit = 200L * 1024 * 1024; // 小于该值的表单缓存在内存,超过则落到临时目录 o.MemoryBufferThreshold = 64 * 1024; // 普通文本字段长度,防止有人往表单里塞超长字符串 o.ValueLengthLimit = 1024 * 1024; });MemoryBufferThreshold这个值值得单独提一句。它决定IFormFile是全部驻留内存还是溢写到临时目录。默认 64KB,意味着 64KB 以上的上传会先写临时文件,再被我们拷贝一次到目标目录,是两次磁盘 IO。如果你的服务上传的都是几百 KB 的图片,可以适当调大(比如 1MB),用内存换 IO;如果是几十 MB 的大文件,保持默认甚至调小都行,别把内存吃穿。
第三道是业务层的MaxFileSizeBytes,放在FileValidator里。它和前两道不是重复:前两道是「服务不许收这么大的请求」,第三道是「这种类型的业务不允许这么大的文件」,语义不同,报错信息也应该不同,前端拿到FILE_TOO_LARGE才能给出准确的提示。
还有一个容易忽略的:管道里不要用UseStaticFiles把存储根目录直接暴露出去。见过不止一个项目为了省事这么干,结果上传目录可被目录枚举,用户上传的.html被浏览器当页面渲染,等于自己开了个存储型 XSS 入口。所有下载都走接口,中间才有权限校验的位置。
3. 上传链路拆解:从 IFormFile 到磁盘一共五道关
上传是这个服务里唯一有写入副作用的操作,也是问题最集中的地方。我的习惯是先画一遍链路,再落代码:客户端 POST multipart → Kestrel 收包 → 表单解析器把文件段交给IFormFile→ 控制器交给校验器 → 校验通过后由存储实现流式写入磁盘 → 返回元信息。五道关,每道关都有明确的失败语义,任何一道含糊,前端就没法区分「文件太大」和「服务器挂了」。
3.1 控制器动作:IFormFile 到服务层的一刀切边界
控制器我写得尽量「薄」,因为它是最不适合放业务判断的地方:
[ApiController] [Route("api/files")] public sealed class FilesController : ControllerBase { private readonly IFileStorage _storage; private readonly FileValidator _validator; public FilesController(IFileStorage storage, FileValidator validator) { _storage = storage; _validator = validator; } [HttpPost] [RequestSizeLimit(200L * 1024 * 1024)] // 覆盖 Kestrel 全局值,只对本接口生效 public async Task<ActionResult<FileMetadata>> UploadAsync( [FromForm] IFormFile file, [FromForm] string? bizType, CancellationToken ct) { // 空文件和长度为 0 的段要分开判断,后者常见于前端没选中文件就提交 if (file is null || file.Length == 0) return BadRequest(new { code = "EMPTY_FILE", message = "文件为空" }); var check = await _validator.ValidateAsync(file, ct); if (!check.Passed) return BadRequest(new { code = check.Code, message = check.Message }); // bizType 只用来分目录,必须白名单化,不能直接拼路径 var meta = await _storage.SaveAsync(file, NormalizeBizType(bizType), ct); return Ok(meta); } private static string NormalizeBizType(string? raw) => raw is "avatar" or "contract" or "template" ? raw : "default"; }几个参数值得说明。[FromForm]在[ApiController]下对IFormFile其实可以省略,因为模型绑定器会自动找同名的表单段;但存在多个表单字段时显式标注能避免歧义,也方便阅读。CancellationToken ct会被框架自动绑定到HttpContext.RequestAborted,客户端中途断开时它会取消,把取消令牌一路传到CopyToAsync里,能省下大量无效的写盘操作。
NormalizeBizType这个几行的方法看着不起眼,它是防目录穿越的关键。如果直接Path.Combine(root, bizType),客户端传bizType=../../就能写到存储根目录外面。白名单化之后只剩三个合法值和默认值,攻击面直接消失。
返回Ok(meta)还是CreatedAtAction取决于你的接口风格。我一般用Ok,因为下载地址是/{id}/download这种纯 id 路由,没必要返回 Location 头;如果你们的网关对 201 有特殊处理,改成Created也一样。
3.2 存储实现:流式拷贝、日期分片目录与 GUID 命名
存储实现是整个项目里唯一碰磁盘的地方,也是最能体现「哪些坑可以提前避开」的地方:
public sealed class LocalFileStorage : IFileStorage { private readonly StorageOptions _opt; private readonly ILogger<LocalFileStorage> _logger; public LocalFileStorage(IOptions<StorageOptions> opt, ILogger<LocalFileStorage> logger) { _opt = opt.Value; _logger = logger; } public async Task<FileMetadata> SaveAsync(IFormFile file, string bizType, CancellationToken ct) { var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var id = Guid.NewGuid().ToString("N"); // 按 业务类型/年月 分片,单目录文件数超过几千后 ls 和备份都会明显变慢 var relativeDir = Path.Combine(bizType, DateTime.UtcNow.ToString("yyyyMM")); var absDir = Path.Combine(_opt.RootPath, relativeDir); Directory.CreateDirectory(absDir); var absPath = Path.Combine(absDir, id + ext); // FileMode.CreateNew:同名即抛异常,天然防止并发覆盖 // FileShare.None:写入期间别的进程读不到半成品 await using (var target = new FileStream( absPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, _opt.CopyBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) { // 流式拷贝,文件多大内存占用都是固定的一个缓冲区 await file.CopyToAsync(target, ct); } _logger.LogInformation("saved {Id} -> {Path} ({Size} bytes)", id, absPath, file.Length); return new FileMetadata { Id = id, // 原文件名可能带路径分隔符,必须只取文件名部分 OriginalName = Path.GetFileName(file.FileName), RelativePath = Path.Combine(relativeDir, id + ext).Replace('\\', '/'), Size = file.Length, ContentType = file.ContentType, UploadedAt = DateTimeOffset.UtcNow }; } }逐个参数说清楚。FileMode.CreateNew和FileMode.Create的区别在并发场景下很致命:前者发现同名文件已存在会直接抛IOException,后者会静默覆盖。虽然这里用了 GUID 做文件名,撞名的概率极低,但把语义写对永远是划算的。
FileOptions.Asynchronous | FileOptions.SequentialScan这两个标志不是装饰。Asynchronous让FileStream走真正的异步 IO 而不是线程池伪装;SequentialScan是给操作系统的一个提示,告诉它我们会从头读到尾,内核可以提前预读、减少磁头寻道。写大文件时这两点加起来能明显降低写入耗时。
Path.GetFileName(file.FileName)这一句是安全底线。multipart 表单里的filename字段是客户端完全可控的字符串,某些老的浏览器或恶意构造的请求会塞进C:\Windows\System32\xxx或../../../etc/passwd。虽然我们用 GUID 做真实文件名、原文件名只入库不落盘,但展示层或日志里出现这种字符串一样可能出事,所以入库前就洗净。
Replace('\\', '/')是为了跨平台一致性。开发机是 Windows,Path.Combine出来的是反斜杠;生产容器是 Linux,拼出来是正斜杠。数据库里存着两种格式的相对路径,将来做数据迁移或者写脚本清理时会非常难受,统一成正斜杠省很多事。
如果你想再往前走一步,可以在这里加「秒传」:先对IFormFile做一次 SHA256,把哈希作为 id 的一部分,命中已有记录就直接返回旧元信息,不落盘。代价是要多读一遍流(IFormFile支持OpenReadStream后重置位置),而且哈希计算本身对大文件也有成本,建议只对 50MB 以下的文件开启。
3.3 校验:扩展名白名单只是第一道,文件头才是
很多人以为校验扩展名就够了,实际上Content-Type和文件名都是客户端随便填的,改个后缀名就能绕过。真正有意义的校验是读文件头(魔数):
public async Task<ValidateResult> ValidateAsync(IFormFile file, CancellationToken ct) { if (file.Length > _opt.MaxFileSizeBytes) return ValidateResult.Fail("FILE_TOO_LARGE", $"单文件不能超过 {_opt.MaxFileSizeBytes} 字节"); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!_opt.AllowedExtensions.Contains(ext)) return ValidateResult.Fail("EXT_NOT_ALLOWED", $"不支持的扩展名 {ext}"); // 只读前 8 字节做签名比对,绝不为了校验把整个文件读进内存 var header = new byte[8]; await using var s = file.OpenReadStream(); var read = await s.ReadAsync(header.AsMemory(0, 8), ct); if (!SignatureMatcher.Match(ext, header.AsSpan(0, read))) return ValidateResult.Fail("SIGNATURE_MISMATCH", "文件头与扩展名不符,疑似改后缀"); return ValidateResult.Ok(); }SignatureMatcher本身就是一个字典加前缀比对,够用就行:
internal static class SignatureMatcher { // 常见格式的文件头,键是扩展名,值是十六进制前缀 private static readonly Dictionary<string, string[]> Map = new() { [".jpg"] = new[] { "FFD8FF" }, [".jpeg"] = new[] { "FFD8FF" }, [".png"] = new[] { "89504E47" }, [".pdf"] = new[] { "25504446" }, // %PDF [".zip"] = new[] { "504B0304" }, [".docx"] = new[] { "504B0304" }, // docx 本质是 zip [".xlsx"] = new[] { "504B0304" } }; public static bool Match(string ext, ReadOnlySpan<byte> header) { if (!Map.TryGetValue(ext, out var signatures)) return true; // 未收录的格式放行 var hex = Convert.ToHexString(header); return signatures.Any(sig => hex.StartsWith(sig, StringComparison.OrdinalIgnoreCase)); } }逻辑上有个取舍要说清楚:Map里没有收录的扩展名走「放行」而不是「拒绝」,因为白名单在StorageOptions已经拦过一道,签名表只是补充。如果反过来写成默认拒绝,将来业务加个.csv上传就得改两次配置,容易漏。
ReadAsync只读 8 字节这点很关键。见过有实现为了算 MD5 把整个IFormFile读进MemoryStream,10 个并发 100MB 的上传直接把容器内存打爆,然后 OOM 重启,日志里什么都留不下。文件服务的所有读操作都应该假设「文件可能非常大」。
4. 下载链路拆解:FileStreamResult、Range 与中文文件名
下载看起来比上传简单,实际上坑更隐蔽,因为很多问题只在特定客户端上出现:Chrome 正常、手机端播放器不能拖进度条、IE 内核浏览器下载下来文件名是一串乱码。这些问题都出在响应头的细节上,而不是代码执行路径上。
4.1 三种返回路径:小文件流、大文件流、外链
先明确返回方式的选择。控制器里能用的有File(byte[])、File(Stream, ...)、PhysicalFile(path, ...)、Redirect(url)四种,对应三个场景:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 小于 1MB 的图片/图标 | File(byte[], contentType) | 走内存最快,但要确认大小上限 |
| 常规文件下载 | File(Stream, contentType, name, enableRangeProcessing) | 流式,内存恒定 |
| 已托管到对象存储 | Redirect(presignedUrl) | 不占应用服务器带宽 |
File(byte[])用起来最顺手,也最容易被滥用。它会把整个文件读进内存再写响应,20 个人同时下载 50MB 的安装包就是 1GB 的瞬时内存。我一般在代码评审里看到File(await System.IO.File.ReadAllBytesAsync(path))就会要求改掉,除非明确知道这个接口只服务小图。
主下载接口长这样:
[HttpGet("{id}/download")] public async Task<IActionResult> DownloadAsync(string id, CancellationToken ct) { var meta = await _storage.FindAsync(id, ct); if (meta is null) return NotFound(new { code = "NOT_FOUND" }); // 流由本方法负责创建,MVC 会在响应写完后释放它 var stream = _storage.OpenRead(meta); // enableRangeProcessing: true 才会解析 Range 头并返回 206 // 视频拖动进度条、下载器的多线程分段,全靠这个参数 return File(stream, meta.ContentType, meta.OriginalName, enableRangeProcessing: true); }enableRangeProcessing是这段代码里最重要的一个参数,默认是false。不开它,客户端发Range: bytes=1000-会被忽略,服务端老老实实返回 200 和整个文件;浏览器的<video>标签拿不到 206,进度条就拖不动,下载器也会退化成单线程。这个参数一开,FileStreamResult会自动处理Range、If-Range、Accept-Ranges,并返回 206 和正确的Content-Range。
OpenRead的实现要配合,必须允许共享读:
public Stream OpenRead(FileMetadata meta) { var path = Path.Combine(_opt.RootPath, meta.RelativePath); // FileShare.Read:允许多个下载请求同时读同一个文件 return new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, _opt.CopyBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); }FileShare.Read这一项如果写成FileShare.None,第二个并发下载就会抛「文件正被另一进程使用」。这个问题在单用户测试时永远复现不了,一上线就被投诉。
4.2 Range 与 Content-Disposition:断点续传和中文名一起解决
Content-Disposition是下载接口最容易写错的头。手写Response.Headers.Add("Content-Disposition", $"attachment; filename={name}")是经典翻车写法,中文名直接变乱码,文件名里带空格或分号还会把响应头切断。
正确的做法是把文件名交给框架,File(stream, contentType, fileDownloadName)在检测到非 ASCII 字符时会自动生成filename*=UTF-8''%E5%90%88%E5%90%8C.pdf这种 RFC 5987 格式,同时保留一个 ASCII 降级的filename给老客户端:
[HttpGet("{id}/preview")] public IActionResult Preview(string id) { var meta = _storage.Find(id); if (meta is null) return NotFound(); // 只对图片和 PDF 开放 inline,其余一律 attachment if (!IsInlineSafe(meta.ContentType)) return File(_storage.OpenRead(meta), meta.ContentType, meta.OriginalName, enableRangeProcessing: true); var stream = _storage.OpenRead(meta); // 需要自定义 inline 行为时才手动构造,FileNameStar 会自动做百分号编码 Response.Headers.ContentDisposition = new ContentDispositionHeaderValue("inline") { FileNameStar = meta.OriginalName }.ToString(); return File(stream, meta.ContentType); }IsInlineSafe的白名单只有image/jpeg、image/png、application/pdf这几种。如果把text/html也放成inline,用户上传一个带脚本的 HTML,别人点开预览就在你的域名下执行了脚本,是标准的存储型 XSS。这条线不能松,宁可让所有未知类型都走下载。
还有一个细节:Content-Type不要直接用客户端上传时传来的那个。IFormFile.ContentType是客户端填的,用户可以把一个 exe 声明成image/png。安全一点的做法是以扩展名为准,自己维护一张扩展名到 MIME 的映射表,或者干脆全部返回application/octet-stream,让浏览器强制下载。
4.3 大文件不炸内存:FileStream 的正确打开方式
最后说一下大文件的性能问题。下载大文件时,服务端唯一应该持有的内存是一个缓冲区,其余全部走内核的零拷贝路径。有几条经验:
第一,不要在下载前做文件完整性校验。见过有实现在下载接口里先算一遍 SHA1 再返回流,结果一个 2GB 的文件下载前先卡 30 秒,CPU 还被打满。哈希应该在上传时算好存进元数据,下载时按需返回给客户端自行校验。
第二,PhysicalFile在多数场景下比手动开流更好,它内部用了SendFile一类的系统调用,能减少一次用户态到内核态的数据拷贝:
// 前提:physicalPath 必须由服务端根据 id 推导,绝不能来自请求参数 return PhysicalFile(physicalPath, "application/octet-stream", meta.OriginalName, enableRangeProcessing: true);第三,如果文件已经托管在对象存储上,别再让应用服务器中转。生成一个带过期时间的预签名 URL,返回 302 让客户端直连,应用服务器的带宽压力立刻降一个数量级。这也是我在二次开发里最先做的一步改造。
5. 排查手册:文件服务上线后最常见的五个坑
这一章是按线上真实工单整理的,每条都按「现象 → 原因 → 解决」写,方便对照排查。前两条几乎每个项目都会遇上一次。
5.1 上传返回 413,或者连接直接断开
现象:本地传 5MB 正常,生产传 40MB 时返回 413,或者更糟:没有任何响应,客户端收到连接重置。
原因:三道限额里至少有一道没放开。只改了[RequestSizeLimit]而 Kestrel 的MaxRequestBodySize还是默认 30MB,请求在管道最前端就被拒了;如果前面还有一层网关服务器,它自己也有请求体上限,应用根本收不到请求。
解决:按 2.3 节的表格三处一起调。排查顺序是从外到内:先看网关日志有没有 413,再看 Kestrel 有没有收到请求(可以临时在管道最前面加一行日志),最后看业务校验的返回值。另外注意MultipartBodyLengthLimit超限时抛的是InvalidDataException而不是 413,全局异常中间件要单独处理,否则前端看到的是 500。
5.2 下载下来的中文文件名是乱码,或者变成 download
现象:英文文件名的下载一切正常,中文文件名下载后变成%E5%90%88%E5%90%8C.pdf或者干脆叫download。
原因:手动拼接Content-Disposition头,没有按 RFC 5987 做filename*=UTF-8''编码;或者中间某个环节(网关、CDN)对这个头做了二次处理。
解决:删掉手写的头部拼接,改用File(stream, contentType, fileDownloadName)重载,让框架生成。如果必须手写,用ContentDispositionHeaderValue的FileNameStar属性而不是字符串插值。改完拿三种客户端各测一次:Chrome、移动端浏览器、命令行下载工具,它们对这个头的容忍度差别很大。
5.3 部署到容器后,重启一次文件就全没了
现象:本地调试一切正常,容器化部署后上传成功、下载正常,Pod 重启一次所有文件 404。
原因:RootPath用了容器内的相对路径(比如App_Data/files),文件写进了容器可写层。容器重建时可写层跟着销毁,文件自然消失。
解决:挂载持久卷,把RootPath指到挂载点(比如/data/files),并在启动时校验目录可写:
// Program.cs 启动阶段做一次快速自检,比等用户上传失败再发现要好 var opt = app.Services.GetRequiredService<IOptions<StorageOptions>>().Value; var root = Path.GetFullPath(opt.RootPath); Directory.CreateDirectory(root); var probe = Path.Combine(root, $".probe-{Guid.NewGuid():N}"); await File.WriteAllTextAsync(probe, "ok"); File.Delete(probe); // 写不进去会在这里直接抛,容器起不来,问题暴露在最前面这个自检我后来加进了所有带本地存储的项目里。容器起不来远比上线三天后用户投诉文件丢失好排查。
5.4 下载时报「文件正被另一进程使用」
现象:单用户下载正常,多人同时下载同一个文件时出现IOException: The process cannot access the file because it is being used by another process。
原因:FileStream打开时用了FileShare.None或默认值,或者上传时用了FileShare.None且流没有及时释放(比如await using漏写)。还有一种更隐蔽的:上传和下载并发,写入方还没关闭句柄,读取方就来了。
解决:读打开一律用FileShare.Read,允许并发读;写打开用FileShare.None,保证读到的一定是完整文件。如果业务上必须允许「边传边读」,那就要引入临时文件名 + 原子重命名的方案:先写xxx.tmp,写完再File.Move成正式名,下载接口只认正式名。
5.5 上传大文件时内存曲线飙升,随后 OOM
现象:并发上传几个大文件后容器内存打满被 kill,日志里没有任何异常堆栈。
原因:多半是把IFormFile读进了MemoryStream或byte[],或者为了算哈希、加水印读了整个文件。另一种可能是MemoryBufferThreshold调得过大,所有上传都缓存在内存里。
解决:所有文件处理改成流式,校验只读文件头、哈希用增量计算(IncrementalHash边读边喂)。把MemoryBufferThreshold保持在 64KB 左右,让大文件走临时文件。最后在压测环境用 20 并发 × 100MB 跑一遍,观察dotnet-counters里的 GC 堆大小,如果随并发线性增长就说明还有地方在缓冲整个文件。
6. 二次开发:把本地磁盘换成对象存储要动哪几处
这套代码写完之后,最常被问的问题就是「以后换云存储要改多少」。答案取决于当初的抽象做得干不干净。如果IFileStorage的四个方法签名里没有出现string path、FileInfo、Directory这类本地文件系统特有的类型,那基本上只需要新增一个实现类,改一行 DI 注册。
第一步,确认接口是「面向对象存储友好」的:
public interface IFileStorage { // 返回的元信息里只放相对路径(或对象 key),不放物理路径 Task<FileMetadata> SaveAsync(IFormFile file, string bizType, CancellationToken ct); Stream OpenRead(FileMetadata meta); Task<bool> DeleteAsync(string id, CancellationToken ct); Task<FileMetadata?> FindAsync(string id, CancellationToken ct); }第二步,新的实现里注意三件对象存储特有的事。一是OpenRead在对象存储上其实是发起一次网络请求,返回值要包一层可释放的流,别让调用方以为拿到的是本地句柄。二是SaveAsync要处理分片上传,超过一定大小(比如 5MB)的流必须先切块,否则单次 PUT 容易超时。三是DeleteAsync要面对「删除成功但秒级一致性还没生效」的情况,别写「删完立刻查一次确认」这种逻辑。
第三步,加一个基于签名的下载入口,这是换了对象存储之后收益最大的改造:
[HttpGet("{id}/direct")] public async Task<IActionResult> DirectAsync(string id) { var meta = await _storage.FindAsync(id, CancellationToken.None); if (meta is null) return NotFound(); // 存储实现返回一个带过期时间的临时地址,应用服务器不再中转字节 var url = await _storage.GetPresignedUrlAsync(meta, TimeSpan.FromMinutes(10)); return Redirect(url); }改造后的验证方法我固定跑这三个用例:上传一个 0 字节的文件(应该被拒且不落盘)、上传一个改了后缀的 exe(应该被签名校验拦下)、并发 10 个请求下载同一个 200MB 文件(内存曲线应该平的)。这三个用例覆盖了绝大多数回归问题。
最后一条经验是血泪换来的:改存储实现时,不要顺手改IFileStorage的方法签名。我见过有人为了「顺手支持多文件上传」把SaveAsync的参数从IFormFile改成IEnumerable<IFormFile>,结果所有调用方全部编译不过,本来半天能完成的改造拖了三天。接口是契约,扩展优先用新方法,不动老的。从那以后我每次动这层抽象之前,都强制先把现有实现的单元测试跑一遍再改,希望帮到你。
本文还有配套的精品资源,点击获取