基于.NET Core的文件夹分块上传与断点续传完整实现

📅 发布时间:2026/10/20 8:43:30
基于.NET Core的文件夹分块上传与断点续传完整实现
最近在帮某企业做一个内部资料归档系统时遇到一个典型的麻烦业务方要求用户把整个文件夹——经常是几千个文件、总共好几个 GB 的资料——直接通过网页一次性传上来。打开网页选个文件夹点确定然后等着把整个目录传完。用传统表单上传来扛这种需求几乎等于自杀浏览器长时间卡死、网络中断一切重来、服务端内存被单个大请求塞爆这还只是冰山一角。最后我整的方案是“文件夹分块上传”后端基于 .NET Core 写 Web API前端在浏览器里把文件夹内的每一个文件都拆成一个个小块逐块上传、逐块记进度最后按顺序合并成完整文件。这套方案跑通后体验稳定很多也顺手解决掉了断点续传和失败重试的问题。这篇文章就把整个实现过程、核心代码和踩过的坑完整梳理一遍正在折腾大文件、文件夹上传的朋友可以直接参考。1. 为什么文件夹上传必须走分块方案1.1 传统整包上传的三个致命痛点传统网页上传的逻辑很直接浏览器把整个文件内容塞进multipart/form-data请求体一次性提交给服务端。单文件三五 MB 的时候问题不大一旦进入“文件夹 大批量文件 单文件超大”的场景问题就全暴露出来了。第一个痛点是内存和请求限制。ASP.NET Core 默认对请求体的限制大约是 30MB超过这个量级直接返回 413。就算你手动放宽这个限制浏览器把几个 GB 的数据一次性 POST 出去网络慢的情况下连接几乎必然超时用户体验只能用“绝望”来形容。第二个痛点是失败成本太高。传了 80%网络闪断整包重来。没有断点没有续传没有局部恢复能力这是大文件场景最让人抓狂的一点。第三个痛点是进度和状态不可控。整包上传你拿不到每个文件的传输状态更别说“哪些文件传好了、哪些还没开始传”。对几千个文件的文件夹来说这等于让用户盲等。1.2 分块上传为什么是更合适的解法分块上传的思路很简单把每个文件切成一连串小块前端逐块发请求服务端先临时存储每个分块全部传完后触发拼接把分块拼成完整的文件。这个思路解决了几个关键问题单次请求体积小内存和请求体限制天然绕过单个分块就算失败也只是一小块的数据重传成本极低。天然支持断点续传。前端上传前先问服务端“当前文件哪些分块已经传过了”跳过已存在的块只补缺的部分传了一半断开后刷新页面依然能继续。进度可以做到“块级 文件级 总文件夹级”三个维度交互界面能做得比较精致。后端相对容易做并发和校验块与块之间彼此独立拼接时做一次完整性检查即可。拿生活场景打比方整包上传就像搬家时把所有家当塞进一辆卡车路上一堵车就全卡死在高速上分块上传则是把家当打成一个个小包裹分批走物流哪个包裹遗失就补哪个整体推进一点不受影响。1.3 分块大小和文件标识怎么定分块大小的选择是个工程权衡。块太大上传失败后的重传代价高内存和请求体限制也容易被顶上去块太小请求数量过多HTTP 握手和表单解析开销会把效率拖垮。我实测下来公网场景用 4MB 到 8MB 是比较舒服的区间既要兼顾速度也要降低失败成本。本文后面代码就按 5MB 来设计。文件标识是整个系统的核心保证每个分块都能准确地归到对应的文件下。最简单的做法是前端给每个文件生成一个唯一 ID后端把这个 ID 当临时目录名分块文件放进去。ID 怎么生成我建议用文件的相对路径、大小和最后修改时间拼起来做哈希async function createFileId(file) { const raw ${file.webkitRelativePath}:${file.size}:${file.lastModified}; const hashBuffer await crypto.subtle.digest(SHA-256, new TextEncoder().encode(raw)); const hashArray Array.from(new Uint8Array(hashBuffer)); const hashHex hashArray.map(b b.toString(16).padStart(2, 0)).join(); return hashHex.slice(0, 24); }用这种方式生成的文件 ID在同一个文件夹内容不变的情况下是稳定的。刷新页面、覆盖重传、断点续传都能复用上一次的上传记录。如果你有更高的要求也想做“秒传”则可以用文件整体 MD5 做 ID但大文件计算 MD5 在前端耗时较长需要权衡。2. 后端接口设计与代码实现2.1 核心接口与存储目录规划我把整个上传流程拆成三个接口彼此职责明确POST /api/upload/chunk接收单个分块并落盘。GET /api/upload/status查询某个文件已上传了哪些分块供前端续传用。POST /api/upload/merge校验分块齐全后拼接成完整文件。存储目录方面我在_uploadRoot下划分两块区域。临时分块区放在TempChunks/{fileId}/{chunkIndex}合并完成的文件放在Files目录。文件 ID 作为临时目录名天然隔离了不同文件的块。2.2 分块上传接口实现后端用.dart中的IFormFile接收分块直接将流写入文件。核心代码如下[HttpPost(chunk)] [RequestSizeLimit(10 * 1024 * 1024)] // 限制单次请求最大10MB防止恶意超大请求 public async TaskIActionResult UploadChunk([FromForm] ChunkUploadModel model) { if (model.Chunk null || model.Chunk.Length 0) { return BadRequest(分块内容不能为空); } var chunkDir Path.Combine(_uploadRoot, TempChunks, model.FileId); Directory.CreateDirectory(chunkDir); var chunkPath Path.Combine(chunkDir, model.ChunkIndex.ToString()); await using (var stream new FileStream(chunkPath, FileMode.Create, FileAccess.Write)) { await model.Chunk.CopyToAsync(stream); } _logger.LogInformation(文件 {FileName} 第 {ChunkIndex} 块上传成功共 {TotalChunks} 块, model.FileName, model.ChunkIndex, model.TotalChunks); return Ok(new { model.FileId, model.ChunkIndex }); }模型定义如下public class ChunkUploadModel { public IFormFile Chunk { get; set; } public string FileId { get; set; } public int ChunkIndex { get; set; } public int TotalChunks { get; set; } public string FileName { get; set; } }这里有几个细节值得注意。第一[RequestSizeLimit]既保护了服务端也给代理层一个明显的信号——这个接口的请求体大小是有限的。第二分块文件用FileMode.Create即使同一个块重复上传也会直接覆盖旧块天然幂等断点续传时前端误传了一个已有块也不会出错。第三整个分块落盘全程走流式写入没有把块内容整段读入内存。2.3 合并接口实现合并是整个方案里最需要小心的一环。核心步骤是检查分块数量是否完整、按分块序号排序、逐块追加写入最终文件最后清理临时目录。[HttpPost(merge)] public async TaskIActionResult Merge([FromBody] MergeModel model) { var chunkDir Path.Combine(_uploadRoot, TempChunks, model.FileId); if (!Directory.Exists(chunkDir)) { return BadRequest($找不到文件 {model.FileName} 的分块目录); } var chunkPaths Directory.GetFiles(chunkDir) .OrderBy(f int.Parse(Path.GetFileName(f))) .ToList(); if (chunkPaths.Count ! model.TotalChunks) { return BadRequest($分块数不匹配期望 {model.TotalChunks}实际 {chunkPaths.Count}); } var safeFinalPath SafeCombine(_uploadRoot /Files, model.FileName); var finalDir Path.GetDirectoryName(safeFinalPath); Directory.CreateDirectory(finalDir); await using (var output new FileStream(safeFinalPath, FileMode.Create, FileAccess.Write, FileShare.None)) { foreach (var chunkPath in chunkPaths) { await using (var input new FileStream(chunkPath, FileMode.Open, FileAccess.Read)) { await input.CopyToAsync(output); } System.IO.File.Delete(chunkPath); } } Directory.Delete(chunkDir, true); _logger.LogInformation(文件 {FileName} 合并完成共 {TotalChunks} 个分块, model.FileName, model.TotalChunks); return Ok(new { FileName model.FileName, Url $/files/{model.FileName} }); }合并模型public class MergeModel { public string FileId { get; set; } public int TotalChunks { get; set; } public string FileName { get; set; } }这个接口有两个关键点。一个是排序文件名是分块序号直接按数字排序才能拼出正确文件用字符串排序会出现“10 排在 2 前面”的坑。另一个是流式拼接全程用FileStream对拷不把整个文件读入内存。2.4 状态查询接口实现断点续传的根基就是这个状态查询接口。前端上传前先问一次把已存在的分块序号拿回来跳过这些块。[HttpGet(status)] public IActionResult Status([FromQuery] string fileId) { var uploadedIndexes new Listint(); var chunkDir Path.Combine(_uploadRoot, TempChunks, fileId); if (Directory.Exists(chunkDir)) { foreach (var file in Directory.GetFiles(chunkDir)) { if (int.TryParse(Path.GetFileName(file), out var chunkIndex)) { uploadedIndexes.Add(chunkIndex); } } } return Ok(new { FileId fileId, UploadedChunks uploadedIndexes }); }到这里后端的基础功能已经完整分块接收、状态查询、合并。接下来就是前端怎么把文件夹里的文件拆开、传上去、拼回去。3. 前端遍历文件夹与逐块上传实现3.1 用 webkitdirectory 读取整个文件夹HTML 里实现“选择整个文件夹”需要借助input的一个扩展属性webkitdirectory。虽然不是标准属性但主流浏览器都支持。input typefile idfolderInput webkitdirectory multiple /拿到文件后每个File对象会带一个webkitRelativePath形如项目文档/源码/Program.cs。这个相对路径是还原文件夹层级的关键后端合并时也用它来确定文件落点。const input document.getElementById(folderInput); input.addEventListener(change, async (e) { const files Array.from(e.target.files); if (files.length 0) return; await handleFolderUpload(files); });需要注意的是webkitdirectory这种选择方式在 Safari 以及部分移动端浏览器和旧版 Edge 中支持得并不好。如果要覆盖这些场景就得退而求其次用多选文件加上用户手动“铺平”目录结构或者采用拖拽上传方案。3.2 分块读取与上传主流程拿到文件清单后对每个文件生成稳定的fileId查状态然后把剩余分块逐块上传。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB async function uploadSingleFile(file, updateProgress) { const fileId await createFileId(file); const fileName file.webkitRelativePath || file.name; const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 查询已上传分块实现断点续传 const statusResp await fetch(/api/upload/status?fileId${encodeURIComponent(fileId)}); const status await statusResp.json(); const uploadedSet new Set(status.uploadedChunks || []); for (let chunkIndex 0; chunkIndex totalChunks; chunkIndex) { if (uploadedSet.has(chunkIndex)) continue; const start chunkIndex * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const blob file.slice(start, end); const formData new FormData(); formData.append(fileId, fileId); formData.append(fileName, fileName); formData.append(chunkIndex, chunkIndex); formData.append(totalChunks, totalChunks); formData.append(chunk, blob, chunk-${chunkIndex}); await fetchWithRetry(/api/upload/chunk, { method: POST, body: formData }); if (updateProgress) { updateProgress(blob.size); } } await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId, fileName, totalChunks }) }); }这段代码有几个细节值得说。file.size为 0 的空文件totalChunks会是 0循环直接跳过最后调 merge 创建一个空文件逻辑是自然的。前端对比uploadedSet时用Set来做而不要用includes几千块时性能差异明显。3.3 并发控制与整体进度计算文件夹里有几百个文件时串行上传一个文件传完再传下一个体验太差但全部文件一起并发又会立刻打满浏览器的连接上限导致后面的请求排队甚至超时。所以我的做法是做一个并发池同时只跑固定数量的文件上传任务比如默认 3 个。async function runTasks(tasks, limit 3) { let index 0; const workers Array.from({ length: limit }, async () { while (index tasks.length) { const currentTask tasks[index]; await currentTask(); } }); await Promise.all(workers); }整体进度用“已上传字节数 / 总字节数”来算。一开始先统计所有文件大小总和totalBytes每传完一个分块就把blob.size累加进uploadedBytes百分比就是uploadedBytes / totalBytes * 100。这个数字虽然不会精确到每个块刚发出还在网络传输途中但对用户来说已经完全够用。如果你希望进度更精准可以再加一档“已发送字节 已确认字节”的滑动窗口但要维护的细节会多很多实际价值不高我个人不建议一上来就做复杂。3.4 请求失败自动重试分块上传已经大幅降低了失败成本但弱网环境下单个请求仍然可能瞬间失败。我不希望一张图传了 90% 结果整个任务报错所以给上传请求加了个简单的重试机制async function fetchWithRetry(url, options, retries 3) { for (let attempt 1; attempt retries; attempt) { try { const resp await fetch(url, options); if (!resp.ok) throw new Error(HTTP ${resp.status}); return resp; } catch (err) { if (attempt retries) throw err; await new Promise(resolve setTimeout(resolve, 1000 * attempt)); } } }上传失败时重试间隔逐次拉长避免疯狂重试把服务端打挂。重试前不需要重新切片因为当前分块的数据仍然在内存里直接复用同一个FormData即可。4. 部署配置与常见问题排查4.1 ASP.NET Core 请求大小限制怎么调ASP.NET Core 默认对请求体大小有限制Kestrel 单次请求上限是 30MB。我们正常分块只有 5MB不受影响但有人如果绕过前端直接用大请求打你的接口就会被 413 挡下来。稳妥起见我建议在Program.cs里做统一配置builder.Services.ConfigureFormOptions(options { options.ValueLengthLimit 10 * 1024 * 1024; // 表单值长度 options.MultipartBodyLengthLimit 10 * 1024 * 1024; // 表单体总长度 }); builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 10 * 1024 * 1024; });这里的 10MB 是针对单分块请求的不要因为怕用户传大文件就把这个值设成好几个 GB那就完全失去了分块保护内存的意义。4.2 部署在 IIS 和反向代理时的额外设置如果部署在 Windows Server 的 IIS 上除了 ASP.NET Core 自身的限制IIS 的请求筛选模块也会有一个默认限制大约是 30MB。两个限制是叠加的哪个先触达就返回哪个的错误。IIS 下需要在web.config中显式配置system.webServer security requestFiltering requestLimits maxAllowedContentLength10485760 / /requestFiltering /security /system.webServer如果是 Nginx 反向代理后面还要注意client_max_body_size我一般直接设成跟分块大小一致比如10m避免代理层把请求卡住。没改这个参数很多人会遇到“本地调试正常、线上传小文件正常、传大一点就 413/500”的经典现象。4.3 中文文件名与路径穿越的防护前端传来的fileName是相对路径里面可能包含中文和目录层级绝不能直接拿来拼路径。最危险的是路径穿越有人构造../../etc/passwd之类的名字直接覆盖服务器上的敏感文件。我的做法是写一个白名单式的安全拼接方法逐段校验private static string SafeCombine(string root, string relativePath) { var fullRoot Path.GetFullPath(root); var current fullRoot; foreach (var part in relativePath.Split(/, \\)) { if (string.IsNullOrWhiteSpace(part) || part .) continue; if (part ..) throw new InvalidOperationException(非法的路径); var candidate Path.Combine(current, part); if (!Path.GetFullPath(candidate).StartsWith(fullRoot)) { throw new InvalidOperationException(非法的路径); } current candidate; } return current; }中文文件名在 Windows 上需要注意编码统一。我的习惯是在前端把相对路径做一次normalize(NFC)后端存储时也统一用 UTF-8否则会出现用户传上去的文件名里带乱码或者两次上传重复创建目录的问题。4.4 合并校验与空文件处理合并接口里只校验分块数量并不够还要校验文件大小。我的建议是前端在最后一个分块上传完成后把原始文件总大小也一并传给 merge 接口服务端合并完比对一下大小不一致就删除刚生成的残缺文件并返回错误。空文件是很容易被忽略的边界情况totalChunks 0分块目录可能根本不会创建merge 接口里Directory.Exists会返回 false直接报“找不到分块目录”。所以要在 merge 开头做一层判断如果TotalChunks 0直接创建空文件然后返回成功。我在第一次测试时就踩过这个坑前端明明提示“上传完成”后端却一直报错。4.5 临时分块残留的清理策略用户上传到一半关闭页面或者传完最后一块但没来得及触发 mergeTempChunks目录下就会残留一堆分块。时间一长磁盘空间会被占满。我在项目里加了一个后台轮询任务每小时扫描一次临时目录删除超过 24 小时没有更新的分块目录public class ChunkCleanerService : BackgroundService { private readonly string _tempRoot; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { var timeout DateTime.UtcNow.AddHours(-24); foreach (var dir in Directory.GetDirectories(_tempRoot)) { if (Directory.GetLastWriteTimeUtc(dir) timeout) { Directory.Delete(dir, true); } } } catch (Exception ex) { _logger.LogError(ex, 清理分块失败); } await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } }这个后台清理任务成本很低但对生产环境是必需的建议每个认真落地的上传方案都配上。5. 工程化落地与后续扩展5.1 用数据库记录任务状态纯目录结构在前端页面刷新后还能续传但如果服务端重启或者临时目录被运维手动清理过前端拿着旧的fileId来查状态就会查到空结果用户会以为文件传丢了。根因在于我们没有维护上传任务的元数据。正式环境我建议建一张上传任务表至少包含这些字段file_id全局唯一标识file_name原始文件名relative_path在文件夹中的相对路径total_chunks总分块数total_size文件总大小uploaded_chunks已上传分块集合也可以用子表来存status上传中/已合并/合并失败chunk_size分块大小用于校验有了这张表用户可以中断后换台电脑继续传同一批文件运维也能直观地在后台看哪些任务卡住了。唯一需要操心的是单文件上万个分块时把uploaded_chunks存成逗号分隔字符串性能会很差建议用单独的分块详情表或者 JSON 序列化加索引。5.2 多实例部署下的存储约束本文的方案是单机直接操作本地磁盘服务端重启、未跑的机器用共享目录问题不大。但一旦做水平扩展多台后端实例同时接收同一个文件的不同分块每台机器各自写本地磁盘merge 时数据就全乱了。多实例部署时要么把临时目录和最终目录挂到共享存储上比如 SMB 或 NFS要么换成对象存储的分片上传接口让存储层自己负责分块的聚合和校验。共享存储方案简单但引入文件锁和延迟对象存储方案稳定但改造成本不小。这取决于团队已有的基础设施没有放之四海而皆准的答案。5.3 直接使用第三方库还是继续自研如果你上网搜“前端分块上传”大概率会看到一堆成熟方案和库比如 Resumable.js、tus-js-client、Plupload。它们把切片、暂停、续传、校验都封装好了服务端也有配套实现思路项目时间紧、需求标准的情况下直接用确实是更理性的选择。但我在实际做这个文件夹上传功能时还是选择了自研原因有三个第一文件夹上传里的核心诉求往往不只是传文件还需要完整保留原始目录层级这方面的定制需求第三方库并不好扩展第二自研可以把文件 ID 设计、任务状态、鉴权体系跟业务系统深度融合而不是让上传组件游离在业务架构之外第三在这个功能里我们还要跟后端的动态权限绑定“哪些文件夹、哪些文件类型允许传”需要在前端发起前就拦截第三方库做这件事要么 hack 要么绕过。当然自研的代价是要自己维护切片逻辑、断点逻辑和并发逻辑。所以我建议团队在考虑第三方库之前先列出自己的需求优先级。如果只是简单的“选文件、传上去、有进度条”直接用库如果要深度定制文件夹结构、任务状态和权限体系自研一手更踏实。写在最后的个人体会这套文件夹分块上传方案我从接口设计到前后端联调完整跑通后最大的体会是分块上传不像看起来那么神秘核心无非是“拆开传、记进度、拼回去”三步难点全部隐藏在工程的细节里——对齐分块大小、校验分块数量、安全拼接路径、清理临时残留。尤其当你准备做断点续传的时候一定要在上传设计的第一天就把文件 ID 的稳定性定好否则后面想补续传功能会非常被动。另外一个非常实用的小技巧是在上传前先把整个文件夹的文件列表读出来给用户看一眼“将上传 N 个文件共 X GB”确认后再开工。这个小交互能显著减少用户误操作也能在真正上传时让整体的进度反馈清晰很多。真到了业务上线之后你会发现最常用的不是上传本身而是那个不起眼的状态查询接口——那么多断点续传和重试靠的都是它。