ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

.NET Core 实现文件夹分块上传:前后端完整方案与避坑指南

.NET Core 实现文件夹分块上传:前后端完整方案与避坑指南

做后台管理系统的时候,一旦需求里出现"上传整个文件夹",很多人的第一反应是这东西到底能不能在网页里实现。我最初也怀疑:浏览器对文件选择框的限制大家都清楚,原生 input 选不了目录,更拿不到目录层级。后来折腾完一轮,才把 .NET Core 里做文件夹分块上传的完整方案跑通,这篇就把整个实现思路、关键代码和调试过程中踩过的坑整理出来,给正要做类似需求的朋友一个可以直接参考的例子。适合刚接触文件上传的 .NET 后端开发,也适合负责前端上传逻辑的同学。

1. 文件夹上传的痛点和分块方案的适用边界

先说结论:文件夹上传真正难的从来不是"能不能选目录",而是怎么把目录里几百个文件稳定、快速、可恢复地传到服务器。很多人一上来就想到逐文件上传,这在小文件夹场景下完全没问题,但一旦文件数量上来、单个文件变大,问题就一个接一个地冒出来。

1.1 为什么不能简单粗暴地一次传一个大文件

单个大文件通过一次 HTTP 请求提交,最直接的限制是请求体积。ASP.NET Core 自带的 Kestrel 服务器默认的请求体上限是 30MB 左右,IIS 默认也有 maxAllowedContentLength 限制,超过就直接返回 413 或者干脆断开连接。就算你把上限调高,一个 1GB 的文件一次性塞进请求体,传输过程中只要网络抖动一下导致连接中断,整个请求就废了,浏览器端没有可靠的断点续传能力,只能从头再来。

而文件夹场景更麻烦:目录里可能是几十个小文件加几个大文件混在一起,如果按"每个文件一个请求"的方式挨个传,任何一次中断都要等到重试成功才能继续,用户体验非常差。如果试图把所有文件塞进同一个 FormData 一次性提交,请求体体积直接爆炸,而且目录结构、文件名这些元数据要自己拼,后端解析也很别扭。

1.2 分块方案真正解决的是什么

把文件切成固定大小的分片,一片一片单独发请求,后端收到后先落到临时目录,等所有分片到齐再合并成完整文件。这套逻辑拆开看,解决的就是三件事:

  • 把请求体积控制在服务器可接受的范围内。单次请求只有几 MB,不用担心 413,也不需要盲目调大服务器限制。
  • 让失败重试的代价变低。哪个分片坏了重传哪个分片,而不是整个文件重新上传。配合前端重试逻辑,上传过程的稳定性立刻上一个台阶。
  • 天然支持断点续传和秒传。每个分片是独立存在的,服务器能告诉你哪些分片已经收到,客户端一查就知道从哪继续。

不过也要泼盆冷水:只有十几个小文件的目录,真的没必要做分块。额外引入的复杂度——分片调度、合并流程、临时目录管理——是要付出开发成本的。当单个文件超过 50MB、总目录体积超过几百 MB、或者客户端经常处于不稳定的办公网络环境中时,分块方案才完全值得投入。这个适用边界先说清楚,免得有人为了一个小小的需求把自己绕进去。

2. 前端目录遍历与分片策略:webkitdirectory 与 File API 的组合

分块上传的浏览器端,核心就两步:拿到目录下的文件列表,再把每个文件切成若干分片。这两步都有现成 API,但细节决定体验。

2.1 目录选择的两种方案

最常见的是给 input 加上webkitdirectory和directory属性:

<input type="file" id="folderInput" webkitdirectory directory multiple />

这样用户就能在弹出的文件选择框里直接选择一个文件夹。选择之后,input.files里会包含目录下所有文件的扁平列表,每个文件对象上有两个关键属性:fileName和webkitRelativePath。其中webkitRelativePath保存的是文件相对于所选目录的完整路径,比如assets/images/logo.png,合并时要用它重建目录结构。

另一个方案是用 File System Access API 的showDirectoryPicker(),它能拿到目录句柄,然后递归读取子目录。优点是交互更现代、能自定义目录名,但目前浏览器支持范围有限,Safari 一直不支持,生产环境里我建议还是以webkitdirectory为主,把showDirectoryPicker当成渐进增强的加分项处理。

遍历拿到文件列表后,过滤掉大小为 0 的空文件,把文件信息整理成统一结构:

const files = Array.from(input.files) .filter(f => f.size > 0) .map((f, idx) => ({ id: idx, // 文件在本次上传中的唯一编号 name: f.name, relativePath: f.webkitRelativePath, size: f.size, file: f, totalChunks: Math.ceil(f.size / CHUNK_SIZE), uploadedChunks: 0 }));

2.2 分片读取与上传任务调度

分片本身不复杂,用Blob.prototype.slice就能按偏移量切出子 Blob。但要注意一点:File本身就是Blob的子类,所以file.slice(start, end)返回的依然是一个Blob,不会把整个文件读进内存,只是引用原文件的一段区间,这在浏览器端是零拷贝的,放心用。

真正需要设计的是上传任务的调度。如果几百个分片同时发请求,浏览器和服务器都扛不住;如果一次只发一个,速度又太慢。我建议用一个固定并发数的任务队列,比如同时允许 3 到 5 个分片在途,既不会打满服务器连接数,也能保持不错的吞吐:

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB const CONCURRENCY = 3; const queue = []; let active = 0; function enqueue(task) { return new Promise((resolve, reject) => { queue.push({ task, resolve, reject }); pump(); }); } function pump() { while (active < CONCURRENCY && queue.length > 0) { const { task, resolve, reject } = queue.shift(); active++; task().then(resolve, reject).finally(() => { active--; pump(); }); } } function uploadChunkTask(uploadId, fileInfo, chunkIndex) { const start = chunkIndex * CHUNK_SIZE; const chunk = fileInfo.file.slice(start, start + CHUNK_SIZE); const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('fileIndex', fileInfo.id); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', fileInfo.totalChunks); formData.append('fileName', fileInfo.name); formData.append('relativePath', fileInfo.relativePath); formData.append('fileSize', fileInfo.size); formData.append('chunk', chunk, fileInfo.name); return fetch('/api/upload/chunk', { method: 'POST', body: formData }) .then(r => { if (!r.ok) throw new Error(`chunk ${chunkIndex} failed`); return r.json(); }); }

注意 FormData 里我把uploadId放在了前面,chunk文件字段放在最后。这只是习惯,真正重要的是后端解析 FormData 时字段名必须一一对应,很多前后端联调对不上号的问题都是字段名拼写不一致导致的。

2.3 进度计算与失败重试

进度计算的思路很简单:遍历文件列表累加每个文件的总字节数,每成功上传一个分片就把该分片的字节数加到已上传字节数里,用uploadedBytes / totalBytes得到整体进度。这里的细节是,已上传字节数应该按"分片成功"来累计,而不是按"请求发送"来累计,否则失败重试时进度会虚高。

失败重试建议采用指数退避策略,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,超过最大次数就标记该分片失败并终止整个上传,提示用户重试。实测下来,办公网络环境下偶发的连接重置,重试一两次基本都能恢复:

async function uploadChunkWithRetry(uploadId, fileInfo, chunkIndex, retries = 3) { for (let attempt = 0; ; attempt++) { try { return await uploadChunkTask(uploadId, fileInfo, chunkIndex); } catch (err) { if (attempt === retries) throw err; await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))); } } }

另外一个很实用的细节:不要把分片任务提前全部丢进队列。文件数多的时候,先按文件遍历、把每个文件的所有分片注册成任务,再让队列控制并发。这样做的好处是内存里只有任务描述,真正会占内存的 Blob 分片只在被发送的那一刻才创建,发完就释放,不会因为一个超大文件把浏览器内存吃满。

3. 后端核心接口设计:.NET Core 如何接收分片并完成合并

前端把分片数据发过来之后,后端的工作就是三板斧:收分片、查状态、合并文件。接口设计上尽量保持简单,三个端点就够了。

3.1 分片接收接口与临时目录结构

分片接收用 POST + multipart/form-data,C# 侧用IFormFile接收文件字段,其余元数据用普通字段绑定。定义一个请求模型:

public class ChunkUploadRequest { public string UploadId { get; set; } public int FileIndex { get; set; } public int ChunkIndex { get; set; } public int TotalChunks { get; set; } public string FileName { get; set; } public string RelativePath { get; set; } public long FileSize { get; set; } public IFormFile Chunk { get; set; } }

控制器动作的写法很直接:

[HttpPost("chunk")] [RequestSizeLimit(10 * 1024 * 1024)] public async Task<IActionResult> UploadChunk([FromForm] ChunkUploadRequest request) { if (request.Chunk == null || request.Chunk.Length == 0) return BadRequest(new { message = "分片内容不能为空" }); // 按 uploadId / fileIndex / chunkIndex 组织临时目录 var chunkDir = Path.Combine(_uploadRoot, request.UploadId, request.FileIndex.ToString()); Directory.CreateDirectory(chunkDir); var chunkPath = Path.Combine(chunkDir, request.ChunkIndex.ToString()); await using (var stream = new FileStream(chunkPath, FileMode.Create)) { await request.Chunk.CopyToAsync(stream); } return Ok(new { uploaded = true }); }

临时目录用_uploadRoot/{uploadId}/{fileIndex}/{chunkIndex}这种结构,有几个明显的好处:uploadId隔离不同用户的上传任务;fileIndex对应目录里的第几个文件;chunkIndex就是分片序号。合并时按序号依次读取拼接,逻辑非常直观。[RequestSizeLimit]属性把单个接口的请求体上限设为 10MB,因为单个分片只有 2MB,留出 multipart 边界等额外开销的余量就够了。

3.2 合并接口:分片校验与文件拼接

当所有任务都完成后,前端调用合并接口。前端不需要把每个文件的完整路径列表都传过来,只需要传文件元数据数组,后端根据uploadId找到临时目录逐个合并:

public class FileMergeMeta { public int FileIndex { get; set; } public string FileName { get; set; } public string RelativePath { get; set; } public int TotalChunks { get; set; } public long FileSize { get; set; } } [HttpPost("merge")] public async Task<IActionResult> Merge([FromBody] MergeRequest request) { var sourceRoot = Path.Combine(_uploadRoot, request.UploadId); if (!Directory.Exists(sourceRoot)) return BadRequest(new { message = "上传会话不存在" }); var result = new List<string>(); foreach (var meta in request.Files) { var chunkDir = Path.Combine(sourceRoot, meta.FileIndex.ToString()); for (var i = 0; i < meta.TotalChunks; i++) { var chunkPath = Path.Combine(chunkDir, i.ToString()); if (!System.IO.File.Exists(chunkPath)) return BadRequest(new { message = $"文件 {meta.RelativePath} 的分片 {i} 缺失" }); } var safeRelativePath = SanitizeRelativePath(meta.RelativePath); var finalPath = Path.Combine(_finalRoot, safeRelativePath); Directory.CreateDirectory(Path.GetDirectoryName(finalPath)!); await using (var output = new FileStream(finalPath, FileMode.Create)) { for (var i = 0; i < meta.TotalChunks; i++) { var chunkPath = Path.Combine(chunkDir, i.ToString()); await using var input = new FileStream(chunkPath, FileMode.Open); await input.CopyToAsync(output); } } // 校验合并后的文件大小是否与声明一致 if (new FileInfo(finalPath).Length != meta.FileSize) throw new InvalidOperationException($"文件 {meta.RelativePath} 大小校验失败"); Directory.Delete(chunkDir, true); result.Add(meta.RelativePath); } // 所有文件合并完成后清理整个会话临时目录 Directory.Delete(sourceRoot, true); return Ok(new { merged = true, files = result }); }

合并逻辑里有一个看起来多余但绝对不能省的步骤:先遍历一遍确认所有分片都存在,再开始拼接。如果不做这个检查,合并过程中发现分片缺失,就得把已经写入了一半的残缺文件删掉,更麻烦的是一部分文件已合并、一部分没合并,整个目录状态变得不可预期。所以宁可多一次磁盘遍历,也要保证合并的原子性。

合并完成后的文件大小校验也很关键。即使每个分片在写入时都成功,也可能因为并发写入互相覆盖导致最终文件不完整,合并后再比对一次总长度能兜住这类隐蔽问题。

3.3 路径安全与文件名清洗

前端的relativePath是直接拼进后端文件路径的,这必须是安全红线。如果用户上传的webkitRelativePath被伪造,里面加一段../../,就存在路径穿越的风险。我按两层来清洗:

private static string SanitizeRelativePath(string relativePath) { // 第一步:去掉文件名里不合法的字符 var normalized = relativePath.Replace('\\', '/'); var segments = normalized.Split('/') .Select(seg => seg.Trim()) .Where(seg => seg.Length > 0 && seg != "." && seg != "..") .Select(seg => string.Join("", seg.Where(ch => !Path.GetInvalidFileNameChars().Contains(ch)))) .ToArray(); // 第二步:目标路径必须落在允许的根目录内 var safePath = string.Join("/", segments); var fullPath = Path.GetFullPath(Path.Combine(_finalRoot, safePath)); if (!fullPath.StartsWith(Path.GetFullPath(_finalRoot), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("非法路径"); return safePath; }

Path.GetFullPath会把..真正解析出来,再校验前缀是否还在根目录内,这是最后一道保险。此外,后端还应该限制上传文件扩展名白名单,防止用户上传可执行文件或脚本文件到静态目录下,这是另一起安全隐患,但凡是做文件上传的项目都应该默认加这个检查。

4. 断点续传、秒传与并发控制:可靠性设计的关键细节

分块上传做到能跑通只是第一步,真正拉开方案优劣差距的是可靠性的细节处理。这一节说的四个点,我在实际测试中都踩到过。

4.1 uploadId 会话设计与分片去重

前端在开始上传时生成一个 GUID 作为uploadId,整个上传生命周期内都用它标识。这个 ID 需要在前端保存下来,常用做法是放进 localStorage,或者放在一个隐藏字段里,这样即使页面刷新也能找回。

分片写入接口有一个天然存在的问题:同一个分片可能被发送两次。原因可能是用户点了重试、前端网络库自动重发、或者是续传时把已上传的分片又发了一遍。后端如果直接以FileMode.Create覆盖写入,问题不大,但如果并发情况下两个相同分片同时写入,就可能出现文件内容交叉损坏。所以接收分片时我加了幂等判断:

var chunkPath = Path.Combine(chunkDir, request.ChunkIndex.ToString()); if (System.IO.File.Exists(chunkPath)) { var existingLength = new FileInfo(chunkPath).Length; if (existingLength == request.Chunk.Length) { // 分片已存在且大小一致,直接返回成功 return Ok(new { uploaded = true, skipped = true }); } }

有网友可能觉得直接覆盖写就行,但加上幂等判断还有一个额外收益:续传时前端可以把所有分片都发一遍,后端自动跳过已有的,大大简化续传逻辑。

4.2 并发数的选择与服务器连接管理

前端并发数我给的是 3,这个数字不是拍脑袋。并发太高,后端同时处理的请求多,文件句柄、线程池、磁盘 IO 都可能成为瓶颈;并发太低,上传速度上不去。我在相同网络条件下测过并发 1、3、5、8 的效果,并发 3 和 5 差距不大,但并发 8 时丢包率明显上升,综合稳定性考虑,3 是最稳妥的默认值。

后端这侧还需要注意,高并发情况下同一个uploadId下的分片可能同时到达,而合并请求也可能因为用户手滑提前触发。一个可行的防御是在合并接口增加一个简单的互斥锁——用SemaphoreSlim或者直接判断临时目录下是否存在某个"合并中"的标记文件。更简单粗暴的方案是:合并前检查所有分片文件都存在,同时保证合并接口只在所有前端任务完成后才调用,这已经能挡住 99% 的问题。

4.3 续传与状态查询接口:刷新页面不重来

断点续传的实现依赖一个状态查询接口。前端在上传前调用它,查询已经收到哪些分片,然后从前端任务队列里剔除这些分片,只传缺失的部分。

[HttpGet("status")] public IActionResult GetStatus(string uploadId) { var sourceRoot = Path.Combine(_uploadRoot, uploadId); if (!Directory.Exists(sourceRoot)) return Ok(new { exists = false, uploadedChunks = new Dictionary<string, List<int>>() }); var result = new Dictionary<string, List<int>>(); foreach (var fileDir in Directory.GetDirectories(sourceRoot)) { var fileIndex = Path.GetFileName(fileDir); var chunkIndexes = Directory.GetFiles(fileDir) .Select(f => int.Parse(Path.GetFileName(f))) .OrderBy(i => i) .ToList(); result[fileIndex] = chunkIndexes; } return Ok(new { exists = true, uploadedChunks = result }); }

前端拿到uploadedChunks后,对于每个文件,把已经存在的分片序号从待上传列表里移除。这里有个小陷阱:后端如果只判断"分片文件是否存在"来判断是否已上传,如果某个分片写入了一半(比如进程崩溃),状态查询会认为它存在,但实际是残缺的。所以接收分片时用临时文件加重命名的方式:先写到chunkPath + ".tmp",写完再改成正式名。状态查询只认正式文件名,就能避免半成品被误认为已上传。

秒传本质上也是同一个机制:如果整个文件之前已经上传过,合并后做一个文件指纹登记,下一次客户端查询时就返回"此文件已存在,无需上传"。指纹计算属于额外复杂度,但如果是内部系统高频重复上传的场景,很值得做。

5. 实测中的性能表现与避坑经验

最后这部分是实际操作中沉淀下来的调优参数和坑位记录。没有这些,代码照抄也可能在特定环境里翻车。

5.1 分片大小怎么选:一个实测对比

分片大小直接影响请求数量、单请求体积和重试代价。我在同一个测试环境里分别用 1MB、2MB、5MB、10MB 的分片上传一个 200MB 的文件,结果如下:

分片大小请求数单请求体积200MB 总耗时失败重试代价
1MB200约 1MB偏慢,请求开销占比高最小
2MB100约 2MB均衡,推荐很小
5MB40约 5MB较快中等
10MB20约 10MB最快但波动大较大

1MB 的表现在我这边反而不理想,主要原因是每次请求的 HTTP 握手、multipart 解析、磁盘写入等固定开销占比太高。10MB 虽然请求数最少,但一旦网络抖动,重传一个 10MB 分片的代价也大,而且容易被服务器端的默认请求体上限卡住。2MB 是速度和稳定性的平衡点,如果网络环境特别好,可以适当上调到 5MB。

5.2 服务器限制与常见报错排查

后端实测时最常见的三个报错,列出来给遇到的人指个方向:

  • 413 Request Entity Too Large:请求体超过服务器限制。先看 Kestrel 的MaxRequestBodySize,默认约 30MB,看是否被某个中间件或特性覆盖;再看 IIS 场景下web.config里的maxAllowedContentLength,很多人改完 Kestrel 配置却忘了 IIS 这层限制。
  • 502 Bad Gateway:如果部署在反向代理后面(比如 Nginx),代理层默认也会限制请求体大小,需要在代理配置里同步调大client_max_body_size。
  • 超时中断:分片上传本身已经规避了大请求,但如果并发数开得太大,某些分片长时间排队,可能导致代理层超时。优先调低并发数,而不是调大超时时间。

如果你在 Program.cs 里显式配置 Kestrel 请求体上限,写法是这样的:

builder.WebHost.ConfigureKestrel(options => { // 20MB,大于单个分片 + multipart 开销 options.Limits.MaxRequestBodySize = 20 * 1024 * 1024; });

5.3 临时目录清理与其他注意事项

临时分片文件是磁盘上的常驻垃圾,如果用户上传到一半放弃,这些文件会一直留着。我建议的清理策略是:合并成功后立即删除会话目录;另外定期跑一个后台任务,删除最后修改时间超过 24 小时的临时目录,覆盖掉"上传中断且不再回来"的场景。别小看这个问题,分片文件加起来很容易吃掉几个 G 的磁盘空间。

还有几个细节一并说:前端上传大文件列表时,不要一次性把所有文件的元数据展示到界面上,列表过长会导致 DOM 渲染卡顿,建议只显示前几十个文件和整体进度;后端合并时如果是超大文件(超过内存),要确保用的是流式复制而不是ReadAllBytes;如果服务部署在多台机器上,临时目录需要落在共享存储里,否则负载均衡会把分片分散到不同服务器导致合并失败,单机部署则没这个烦恼。

最后分享一个我个人的体会。文件夹分块上传这个需求,本质是"把一个大任务拆成很多个小任务,每个小任务都可独立重试,最后再汇总"的经典思路。这套前后端代码并不复杂,但真正让它稳定运行的,是那一堆看起来不起眼的细节:幂等判断、路径清洗、半成品保护、临时目录清理、并发数选择。我在调试过程中被 413、路径穿越、分片覆盖、进度虚高这几个坑轮番教育过,上面这些点都是实战换来的。搞明白这些,再去看别的语言或框架下的分块上传实现,会发现思路全是相通的。

返回列表