简介:本资源是一套基于.NET Core WebAPI构建的轻量级文件上传与下载服务完整实现,面向C#后端开发者及WebAPI初学者,解决企业级应用中常见的文件管理、安全传输与异步处理等核心问题。压缩包共50个文件,包含25个C#核心代码文件(如UploadFileMiddleware.cs、ThumbnailMiddleware.cs、UFSResult.cs等)、9个JSON配置文件(含多环境appsettings)、5个CSProj项目文件及Dockerfile、README.md等,整体仅206KB,结构清晰、模块解耦,便于快速集成与二次开发。已有1921人学习下载,资源涵盖从控制器设计、IFormFile文件解析、流式下载、Content-Disposition头设置,到JWT权限校验、路径遍历防护、文件类型白名单等关键安全实践,并提供前后端分离示例(FrontEndDemo)与Node.js客户端参考,助开发者一站式掌握生产级文件服务的落地细节。
1. .NET Core WebAPI 文件上传下载服务:不是配个IFormFile就能上线的生产级文件服务
你写了个 WebAPI,用IFormFile接收前端传来的 Excel,再用FileStreamResult返回一个 PDF,本地测试全绿,一上测试环境就报 400 或 500 —— 这不是玄学,是缺了文件服务最基础的三道防线:边界校验、流式处理、路径安全。这个资源包不是“Hello World”示例,而是一套经过某跨平台系统实测验证的 .NET Core WebAPI 文件服务骨架,覆盖从千字节小图到百兆日志包的上传下载全流程,含完整中间件拦截、分块续传支持、磁盘空间预检、MIME 类型白名单、文件名消毒逻辑,以及最关键的——不依赖Temp目录、不把整个文件 load 进内存的流式管道实现。它适合正在搭建内部文档中心、IoT 设备固件分发接口、或需要对接第三方存储(如 MinIO)但又不想直接暴露底层 SDK 的开发者。如果你还在用Request.Form.Files[0].CopyToAsync()硬拷贝,或者把byte[]当参数传进 Service 层,这份代码就是你的后悔药。
2. 从零构建可落地的文件上传下载服务:核心组件选型与结构设计
2.1 为什么不用IFormFile做业务层入口?—— 流式处理的不可妥协性
很多教程教你在 Controller 里直接接收IFormFile,然后.OpenReadStream()处理。这在小文件场景看似简洁,但埋下三个硬伤:
- 内存泄漏风险:
IFormFile默认会将整个文件缓存到内存或临时目录,大文件上传时可能触发OutOfMemoryException; - 无法控制读取节奏:你无法在读取过程中动态判断是否超限、是否需中断、是否要加进度回调;
- 耦合 HTTP 生命周期:
IFormFile只在当前请求上下文中有效,无法传递给后台任务(如异步转码、病毒扫描)。
正确做法是:在 Middleware 层就接管原始HttpRequest.Body,封装为自定义FileUploadStream,并注入业务 Service 层。
本资源包中FileUploadMiddleware在InvokeAsync中完成以下动作:
- 检查
Content-Type是否为multipart/form-data; - 解析 boundary,逐 part 提取
filename、Content-Disposition、Content-Type; - 对每个文件 part,创建
FileUploadStream实例(继承Stream,重写ReadAsync),内部持有一个PipeReader,按需拉取数据; - 将
FileUploadStream和元数据(原始名、大小、MIME)打包为UploadFileContext,通过HttpContext.Items透传至 Controller。
这样,Controller 方法签名变成:
[HttpPost("upload")] public async Task<IActionResult> Upload([FromServices] IFileStorageService storage, [FromBody] UploadRequest request) { // request.File 是 FileUploadStream,可被多次 Read,且不占内存 var result = await storage.SaveAsync(request.File, request.FileName, request.ContentType); return Ok(result); }提示:
FileUploadStream内部使用System.IO.Pipelines构建无锁缓冲区,单次ReadAsync最多只拉取 8KB 数据,避免大文件阻塞线程池。
2.2 存储策略选型:本地磁盘 + 抽象接口,为未来扩展留白
文件最终存哪?硬编码C:\uploads\是新手第一坑。本包采用策略模式 + 依赖注入:
- 定义
IFileStorageService接口,含SaveAsync,GetStreamAsync,DeleteAsync,ExistsAsync四个核心方法; - 提供
LocalDiskStorageService实现类,负责:- 根据文件哈希(SHA256)生成二级目录(如
/a/b/abc123...),防止单目录文件过多; - 自动创建目录、设置 NTFS 权限(仅
IIS_IUSRS可写); - 写入前检查磁盘剩余空间(阈值可配,默认 <5GB 则拒绝);
- 根据文件哈希(SHA256)生成二级目录(如
- 同时预留
MinIOStorageService和AzureBlobStorageService的空实现类,接口一致,只需补全 SDK 调用即可切换。
配置方式(appsettings.json):
{ "FileStorage": { "Provider": "LocalDisk", "LocalDisk": { "RootPath": "D:\\filestore", "MinFreeSpaceMB": 5120, "MaxFileSizeMB": 200 } } }启动时注册:
var provider = Configuration["FileStorage:Provider"]; if (provider == "LocalDisk") services.AddSingleton<IFileStorageService, LocalDiskStorageService>(); // else if (provider == "MinIO") ...2.3 下载服务的两种模式:直出 vs 分块,按场景选对路
下载不是简单return PhysicalFile(path, mime)。本包提供双模式:
- 直出模式(Default):适用于小文件(<5MB)、高并发、低延迟场景。使用
FileStreamResult,但关键点在于:- 设置
EnableRangeProcessing = true,支持断点续传(浏览器刷新不重下); Last-Modified和ETag自动计算(基于文件修改时间 + 长度),启用 304 缓存;
- 设置
- 分块模式(Chunked):适用于大文件(>50MB)、弱网环境、需进度反馈场景。Controller 返回
206 Partial Content,前端用fetch+ReadableStream分段读取:const response = await fetch('/api/download?id=abc123'); const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; // 处理 value(Uint8Array) updateProgress(value.length); }
后端ChunkedDownloadService使用PipeWriter流式写入响应体,每块 64KB,不加载全文件进内存。
3. 文件上传下载的四大安全边界:校验、消毒、隔离、审计
3.1 MIME 类型白名单:别信前端传来的Content-Type
前端 JS 可以伪造任意Content-Type,比如把.exe改成image/png。本包在FileUploadMiddleware中强制执行双重校验:
- 首字节检测(Magic Number):读取文件头 16 字节,比对已知格式签名(PNG:
89 50 4E 47, PDF:25 50 44 46, ZIP:50 4B 03 04); - 扩展名 + MIME 组合白名单:配置项如下:
仅当扩展名匹配且Magic Number 匹配且MIME 在白名单中,才允许上传。否则返回"AllowedMimeTypes": [ { "Extension": ".jpg", "MimeType": "image/jpeg" }, { "Extension": ".pdf", "MimeType": "application/pdf" }, { "Extension": ".xlsx", "MimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } ]415 Unsupported Media Type。
3.2 文件名消毒:防御路径遍历与空字节攻击
用户上传../../../etc/passwd或shell.php%00.jpg怎么办?LocalDiskStorageService的SanitizeFileName方法执行三步:
- 移除所有控制字符(ASCII < 32)和 Unicode 控制符(
\u202E等); - 替换路径分隔符(
/,\,..)为下划线_; - 截断过长文件名(默认 > 100 字符),保留扩展名。
最终生成安全文件名:original_name___etc_passwd_.txt→original_name____etc_passwd_.txt。
注意:绝不使用
Path.GetFullPath()做“规范化”,它可能绕过消毒逻辑。
3.3 存储隔离:按租户/业务域划分物理目录
多租户系统必须隔离文件存储。本包支持两种隔离策略:
- 租户 ID 隔离:若请求 Header 含
X-Tenant-ID: tenant-a,则文件存入D:\filestore\tenant-a\...; - 业务域隔离:URL 路径隐含域,如
/api/v1/docs/upload→docs目录,/api/v1/iot/firmware→iot目录。LocalDiskStorageService构造函数注入IHttpContextAccessor,解析上下文后动态拼接子路径,确保不同业务数据物理隔离。
3.4 操作审计日志:记录谁、何时、上传了什么
所有SaveAsync和GetStreamAsync调用均触发审计事件:
_logger.LogInformation( "FileOperation: {Action} by {UserId} for {FileName} ({FileSize} bytes) at {Timestamp}", action, userId, sanitizedFileName, fileSize, DateTime.UtcNow);日志包含:操作类型(Upload/Download)、用户标识(从 JWT Claim 或 Header 提取)、消毒后文件名、原始大小、时间戳。日志格式兼容 Serilog 的FileSink,可直接接入 ELK 或 Seq。
4. 避坑指南:生产环境踩过的五个真实血泪坑
4.1 现象:上传大文件时 Nginx 返回 413 Request Entity Too Large
原因:Nginx 默认client_max_body_size为 1MB,超过即拒收,.NET Core 根本收不到请求。
解决:在 Nginx 配置中显式增大:
location /api/ { client_max_body_size 200M; proxy_pass http://backend; }同时,.NET Core 的KestrelServerOptions.Limits.MaxRequestBodySize也需同步设置(默认为 30MB),否则 Kestrel 会二次拦截。
4.2 现象:并发上传多个大文件时,服务器 CPU 100%,响应变慢
原因:IFormFile.CopyToAsync()默认使用同步 IO,在高并发下大量线程阻塞在磁盘写入。
解决:本包彻底弃用IFormFile,改用PipeReader+FileStream异步写入。关键代码:
using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); await pipeReader.CopyToAsync(fileStream); // 真正的异步流复制useAsync: true启用 Windows 的 I/O Completion Ports,避免线程池耗尽。
4.3 现象:下载 PDF 时浏览器提示“文件已损坏”,但本地打开正常
原因:未设置正确的Content-DispositionHeader,导致浏览器误判为 inline 渲染,部分 PDF Viewer 插件解析异常。
解决:强制设置attachment模式,并指定文件名:
Response.Headers.Append("Content-Disposition", $"attachment; filename=\"{Uri.EscapeDataString(sanitizedFileName)}\"");Uri.EscapeDataString处理中文文件名,避免乱码。
4.4 现象:同一文件多次上传,磁盘空间未释放,du -sh显示占用持续增长
原因:FileStream未正确Dispose,或using块被异常提前跳出,导致文件句柄未关闭,Windows 锁定文件无法删除。
解决:所有FileStream创建均包裹在try/finally中,确保Dispose执行:
FileStream fs = null; try { fs = new FileStream(path, FileMode.Create); await pipeReader.CopyToAsync(fs); } finally { fs?.Dispose(); // 即使 CopyToAsync 抛异常也保证释放 }4.5 现象:MinIO 存储切换后,上传速度暴跌 10 倍
原因:MinIO SDK 默认PutObjectAsync使用内存缓冲,大文件先 load 进byte[]再上传,吃光内存后 GC 频繁。
解决:改用PutObjectAsync的流式重载,传入FileUploadStream:
await _minioClient.PutObjectAsync( bucketName, objectName, uploadContext.Stream, // 直接传流,不缓存 uploadContext.Length, uploadContext.ContentType);本包MinIOStorageService已内置此优化。
5. 生产就绪的性能调优与监控集成:让文件服务可观察、可伸缩
5.1 Kestrel 与 IIS 的上传超时协同配置
Kestrel 的KeepAliveTimeout和 IIS 的requestTimeout必须对齐,否则出现“连接被重置”。本包Program.cs中统一配置:
webBuilder.ConfigureKestrel(serverOptions => { serverOptions.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(30); serverOptions.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2); // 关键:上传超时设为 30 分钟,匹配 IIS serverOptions.Limits.MaxRequestBodySize = null; // 交由 Middleware 控制 });IISweb.config同步:
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="2147483647" /> <!-- 2GB --> </requestFiltering> </security> <aspNetCore processPath="dotnet" arguments=".\MyApp.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess"> <handlerSettings> <handlerSetting name="requestTimeout" value="00:30:00" /> </handlerSettings> </aspNetCore> </system.webServer>注意:
requestTimeout单位是hh:mm:ss,不是秒。设为00:30:00表示 30 分钟。
5.2 Prometheus 指标埋点:监控上传成功率、平均耗时、错误分布
本包集成Prometheus.AspNetCore,暴露四个核心指标:
| 指标名 | 类型 | 说明 |
|---|---|---|
file_upload_total | Counter | 按status_code(200/400/500)、file_type(pdf/jpg/zip)打点 |
file_upload_duration_seconds | Histogram | 上传耗时分布,桶(buckets)设为[0.1, 0.5, 1, 5, 10, 30]秒 |
file_download_total | Counter | 同上传,按status_code和range(full/partial)区分 |
file_storage_free_space_bytes | Gauge | 本地磁盘剩余空间(字节),用于告警 |
埋点代码(FileUploadMiddleware中):
// 上传成功后 _metrics.UploadTotal.WithLabels(statusCode: "200", fileType: GetFileType(ext)).Inc(); _metrics.UploadDurationSeconds.WithLabels("200").Observe(elapsed.TotalSeconds); // 磁盘空间定时采集(BackgroundService) _metrics.StorageFreeSpaceBytes.Set(GetFreeSpaceInBytes(_config.RootPath));Grafana 看板可直观看到:
- 上传失败率突增 → 查
4xx错误明细; file_upload_duration_seconds_bucket{le="5"}比例下降 → 检查磁盘 IO 或网络;file_storage_free_space_bytes< 1GB → 触发钉钉告警。
5.3 分布式文件去重:基于 SHA256 的秒级查重
高频上传相同文件(如固件包)浪费带宽和磁盘。本包提供IDuplicateDetector接口,Sha256DuplicateDetector实现:
- 上传时,
FileUploadStream边读边计算 SHA256(HashAlgorithm.Create("SHA256")); - 计算完成后,查 Redis 缓存
sha256:{hash},存在则跳过保存,返回已有 URL; - 缓存 TTL 设为 7 天,Key 结构:
sha256:abc123... -> /files/tenant-a/def456.pdf。
Redis 查询代码:
var cacheKey = $"sha256:{hash}"; var existingUrl = await _redis.StringGetAsync(cacheKey); if (existingUrl.HasValue) { _logger.LogInformation("Duplicate detected: {Hash}", hash); return new DuplicateUploadResult { IsDuplicate = true, ExistingUrl = existingUrl }; } // 否则保存文件,并写入缓存 await _redis.StringSetAsync(cacheKey, finalUrl, TimeSpan.FromDays(7));实测 100MB 文件,SHA256 计算耗时 < 800ms,查重响应 < 5ms。
5.4 前端分块上传的完整链路:从切片到合并
本包附带BlazorWebAssembly前端 Demo,实现标准 Tus 协议兼容的分块上传:
- 前端用
File.slice()切片(每片 5MB); - 每片 POST 到
/api/upload/chunk?upload_id=xxx&chunk_index=0; - 后端
ChunkedUploadService维护ConcurrentDictionary<string, List<string>>记录各 upload_id 的已上传分片路径; - 最后
POST /api/upload/merge?upload_id=xxx,服务端按序FileStream合并,计算最终 SHA256,存入主存储。
关键:合并时使用FileStream的Seek定位,避免内存拼接:
using var mergedStream = new FileStream(mergedPath, FileMode.Create); foreach (var chunkPath in chunkPaths.OrderBy(x => GetChunkIndex(x))) { using var chunkStream = File.OpenRead(chunkPath); await chunkStream.CopyToAsync(mergedStream); }6. 一个必须养成的习惯:每次部署前跑通这三条验证脚本
文件服务上线前,我从不只测“能传能下”。我会强制执行三个自动化验证脚本,它们已集成在本包scripts/目录下,用 PowerShell 编写,5 分钟内跑完:
6.1 验证 1:边界压力测试(test-upload-boundaries.ps1)
模拟 10 个并发上传 100MB 文件,验证:
- 是否全部成功(HTTP 200);
- 内存增长 < 50MB(证明流式生效);
- 磁盘写入速率稳定在 80MB/s(排除 I/O 瓶颈)。
脚本核心逻辑:
$jobs = @() for ($i=0; $i -lt 10; $i++) { $jobs += Start-Job -ScriptBlock { $file = Get-RandomFile -SizeMB 100 $res = Invoke-RestMethod -Uri "https://api.example.com/api/upload" ` -Method Post -InFile $file -ContentType "multipart/form-data" return $res.StatusCode -eq 200 } } Wait-Job $jobs $successCount = ($jobs | Receive-Job | Where-Object { $_ }).Count Write-Host "Success rate: $successCount/10"如果成功率 < 100%,立即检查 Kestrel 日志中的ConnectionReset或OutOfMemoryException。
6.2 验证 2:安全渗透测试(test-security-scans.ps1)
自动发送恶意 payload,验证防护是否生效:
| Payload | 预期响应 |
|---|---|
filename="../../etc/passwd" | 400 Bad Request(文件名消毒) |
Content-Type: text/html+.exe内容 | 415 Unsupported Media Type(MIME 校验) |
Content-Type: image/png+ ZIP 签名 | 415(Magic Number 校验) |
脚本用curl发送,断言响应码: |
# 测试路径遍历 $res = curl -s -o /dev/null -w "%{http_code}" \ -F "file=@malicious.txt;filename=../../etc/passwd" \ https://api.example.com/api/upload if ($res -ne "400") { throw "Path traversal protection failed!" }6.3 验证 3:监控指标基线校准(test-metrics-baseline.ps1)
调用/metrics端点,抓取file_upload_total和file_storage_free_space_bytes,与部署前快照对比:
file_upload_total应为 0(新实例无历史数据);file_storage_free_space_bytes应与df -h输出一致(误差 < 0.1%)。
脚本提取指标:
$metrics = Invoke-RestMethod "https://api.example.com/metrics" $freeSpace = [regex]::Match($metrics, "file_storage_free_space_bytes (\d+)").Groups[1].Value $actualFree = (Get-PSDrive D).Free if ([Math]::Abs($freeSpace - $actualFree) / $actualFree -gt 0.001) { throw "Disk space metric drift > 0.1%!" }从那以后我每次发布文件服务,都强制走一遍这三个脚本——不是为了“测通”,而是为了建立对这套代码在真实环境行为的肌肉记忆。它让我敢在凌晨三点回滚线上版本,也敢对运维说:“磁盘报警?先看file_storage_free_space_bytes,不是监控错了,是你们没清日志。”希望帮到你。
本文还有配套的精品资源,点击获取