首页 / 资讯中心 / 文章详情

ASP.NET Core实现网页文件夹分块上传与合并

ASP.NET Core实现网页文件夹分块上传与合并 ★ FEATURED ARTICLE
1. 项目概述与需求拆解先把标题拆开看.NET Core、文件夹分块上传、网页。这三个词放到一起本质上是两个需求叠加——第一要在网页端把整个文件夹传上去而不是一个文件一个文件地选第二文件体积可能很大或者网络不稳定所以要分块分片上传把大文件切成若干块逐块传最后在服务端合并。这个需求在真实业务里非常常见。某公司内部的知识管理系统用户要把本地几百份文档传到网页端归档某个资源共享平台运营人员需要按目录结构上传整套素材甚至个人开发者在做网盘类应用时都会遇到“用户想直接把一个文件夹拖进浏览器然后整个目录连同子文件夹一起传上去”的场景。文件夹上传和单文件上传最大的区别在于浏览器原生的input typefile没办法拿到整个目录。要靠webkitdirectory属性现代浏览器叫directory让用户选择目录然后通过File System Access API或者webkitRelativePath去获取文件的相对路径才能还原目录结构。分块上传则是为了解决大文件和弱网环境的问题一次性把整个文件读进内存再传给服务器内存容易爆网络一抖动就全废切成几MB一块逐块传每一块有独立的重试机制进度也看得见体验完全不同。适合谁来参考这篇内容后端是.NET Core、前端用Vue或原生JavaScript的开发者项目里有文件管理、素材上传、网盘类功能的同学以及对“分块上传”仅有概念、没实际落地过、想找一份完整可跑通的示例代码的人。下文我会把前后端完整代码贴出来把每一步为什么这么做讲清楚顺便把我踩过的坑一并交代。2. 技术方案选型与整体设计思路2.1 先定分块粒度为什么是4MB或者8MB分块上传最核心的参数就是分块大小。这个值不是随便定的需要综合考虑几个因素。内存占用前端读取文件用的是File.slice它不会真把整个文件读进内存而是从磁盘里切一段出来。但上传队列如果做得不好并发好几个大文件内存还是会涨所以块不能太大。网络环境如果是公网传输建议4MB~16MB。太小了比如1MB会产生大量HTTP请求服务端IO压力大前端队列调度也费劲太大了比如64MB一旦网络抖动导致失败重传成本就很高服务端接收时也容易超时。服务端框架配置ASP.NET Core默认请求体大小限制是30MBKestrel默认请求体限制也只是几十MB的量级。用分块上传每一块必须低于这个限制否则还没走到业务代码就被拦截了。所以4MB是相对稳妥的起点8MB次之超过16MB就需要调Kestrel配置了。我实际项目中默认用的是5MB一块为什么选这个数因为当时上游要求上传的附件最大可能到2GB5MB一块算下来400块前端队列并发控制在3个浏览器和服务端都不会很吃力。如果你不确定就按4MB来后面所有代码示例都以4MB演示。2.2 文件夹结构怎么还原webkitRelativePath是核心以前做文件夹上传最头疼的就是“用户选了一个文件夹前端只拿到一堆文件根本不知道谁在哪个子目录里”。现在浏览器给我们提供了一个关键属性File.webkitRelativePath。比如用户选择了一个叫我的资料的文件夹里面有个docs/报告.docx那么file.webkitRelativePath的值就是我的资料/docs/报告.docx。有了这个值前端就能还原目录结构后端也能依据它把文件存到对应的相对路径下。有一点要注意不同浏览器的行为略有差异。Chrome、Edge、Firefox都支持webkitdirectory但Safari老版本对webkitRelativePath的支持有bug某些场景下返回空字符串。保险起见前端代码里要做兜底如果webkitRelativePath为空就用文件名本身作为相对路径至少保证文件能传上去目录结构丢了至少内容还在。2.3 上传流程设计前端并发控制加服务端分块合并整体流程是这样的用户在网页选择文件夹前端遍历得到文件列表每个文件带relativePath。前端生成一个全局的uploadId其实就是GUID代表这一批上传任务。每个文件按4MB切块每个块有自己的chunkIndex和chunkTotal。文件信息先通过一个接口注册给后端后端返回一个fileId。前端并发上传这些块每传完一块就回调进度。所有块传完后前端调用一个“合并”接口后端把临时目录里的块按顺序拼接成完整文件。后端按relativePath把完整文件写入最终目录返回结果。这个流程和单文件分块上传本质上是一样的只是因为多了“文件夹”这一层所以每个文件都要额外携带relativePath字段。后端收到后把它作为存储路径的一部分目录结构就自然还原了。这里有个设计细节值得聊一下先注册再上传。为什么不直接传块因为我们需要在服务端保存每个文件的元信息比如文件名、相对路径、总块数、当前已上传块数。如果直接用块请求里的参数拼后期做断点续传、块校验会很麻烦。先注册后端用fileId作为唯一标识后续所有块请求都带着fileId走逻辑清晰也好排查问题。2.4 为什么要做分块校验文件传输最怕静默损坏——网络传输过程中数据可能被截断、篡改如果不校验合并出来的是一个坏文件。所以前端在切块时计算每个块的MD5随块请求一起发给后端后端每收一块重新计算MD5做比对不一致就返回错误前端触发重传。MD5对4MB的块计算前端大概几十毫秒性能可以接受。如果你想更极致可以用CRC32速度更快但碰撞概率稍高对大文件上传场景MD5足够用了。3. 后端服务端实现分块接收与合并核心代码3.1 项目结构与依赖准备后端我用的是ASP.NET Core 8 Web API项目结构大致如下FileUploadDemo/ ├── Controllers/ │ └── UploadController.cs ├── Models/ │ ├── UploadRegisterRequest.cs │ ├── ChunkUploadRequest.cs │ └── ChunkMergeRequest.cs ├── Services/ │ └── FileUploadService.cs ├── UploadTemp/ // 临时保存分块 └── UploadFiles/ // 最终保存的完整文件依赖就一个Newtonsoft.Json主要为了处理前端传过来的动态结构其实System.Text.Json也完全可以看个人习惯。3.2 数据库表或内存存储如何记录上传状态生产环境肯定要用数据库记录每个文件的上传状态这里为了演示简洁我用一个静态字典加ConcurrentDictionary来模拟。public class FileUploadService { // 用静态字典模拟存储key为fileId private static readonly ConcurrentDictionarystring, UploadFileInfo FileInfos new(); public class UploadFileInfo { public string FileId { get; set; } public string FileName { get; set; } public string RelativePath { get; set; } public int ChunkCount { get; set; } public long FileSize { get; set; } public Listint UploadedChunks { get; set; } new(); public string TempDir { get; set; } public DateTime CreateTime { get; set; } } }UploadedChunks记录哪些块已经上传成功作用是断点续传。前端在注册之后可以调用一个查询接口告诉后端fileId后端返回UploadedChunks前端就跳过已经上传成功的块只传缺失的。这个能力在弱网场景非常实用。3.3 文件注册接口前端选择完文件夹后会遍历所有文件然后逐个调用注册接口。为什么是逐个而不是一次性把所有文件都注册因为一个文件夹下可能有几千个文件一次性提交所有元信息请求体太大不说后端初始化任务也需要时间用户体验反而不好。逐文件注册前端可以做“先注册一波然后排队上传”的流式效果。[HttpPost(register)] public async TaskIActionResult Register([FromBody] UploadRegisterRequest request) { if (string.IsNullOrEmpty(request.FileName) || request.ChunkCount 0) return BadRequest(文件信息不合法); var fileId Guid.NewGuid().ToString(N); var tempDir Path.Combine(_uploadTempPath, fileId); Directory.CreateDirectory(tempDir); var info new FileUploadService.UploadFileInfo { FileId fileId, FileName request.FileName, RelativePath request.RelativePath, ChunkCount request.ChunkCount, FileSize request.FileSize, TempDir tempDir, CreateTime DateTime.Now }; FileUploadService.FileInfos.TryAdd(fileId, info); return Ok(new { fileId }); }fileId用Guid.NewGuid().ToString(N)不带短横线看起来清爽也方便拼接路径。临时目录名直接用fileId这样服务端不会出现不同任务之间文件互相覆盖的问题。3.4 分块上传接口这一部分是核心。前端以multipart/form-data方式提交分块文件同时带上fileId和chunkIndex。[HttpPost(chunk)] public async TaskIActionResult UploadChunk([FromForm] ChunkUploadRequest request, IFormFile file) { if (file null || file.Length 0) return BadRequest(分块内容不能为空); if (!FileUploadService.FileInfos.TryGetValue(request.FileId, out var info)) return NotFound(上传任务不存在请重新注册); // 块索引越界检查 if (request.ChunkIndex 0 || request.ChunkIndex info.ChunkCount) return BadRequest(分块索引越界); // 保存分块文件chunk_000001.dat var chunkFileName $chunk_{request.ChunkIndex:D6}.dat; var chunkFullPath Path.Combine(info.TempDir, chunkFileName); await using (var stream new FileStream(chunkFullPath, FileMode.Create)) { await file.CopyToAsync(stream); } // 记录已上传的块索引 if (!info.UploadedChunks.Contains(request.ChunkIndex)) info.UploadedChunks.Add(request.ChunkIndex); return Ok(new { received request.ChunkIndex }); }代码看起来简单但有几个细节值得注意。第一块文件的命名方式chunk_{index:D6}.dat。D6的意思是索引不足6位时前面补0这样在后续合并时按文件名排序就是自然的数字顺序chunk_000000.dat、chunk_000001.dat不会出现chunk_10排在chunk_2前面的问题。第二FileMode.Create会覆盖同名文件。如果前端已经传过chunk_2但后端没记录成功前端重传chunk_2这里会直接覆盖不会产生脏数据。第三这里没有做MD5校验。我在生产版本里会引入一个request.ChunkMd5字段前端传MD5后端算出文件的MD5比对不一致则返回403。为了让示例代码好读先省略实际落地时务必加上。3.5 合并接口按索引拼接分块当某个文件的所有块都传完后前端调用合并接口。[HttpPost(merge)] public async TaskIActionResult Merge([FromBody] ChunkMergeRequest request) { if (!FileUploadService.FileInfos.TryGetValue(request.FileId, out var info)) return NotFound(上传任务不存在); // 校验是否所有块都已上传 if (info.UploadedChunks.Count ! info.ChunkCount) return BadRequest($还有 {info.ChunkCount - info.UploadedChunks.Count} 个分块未上传不能合并); // 最终文件路径UploadFiles/{相对路径} var finalDir Path.Combine(_uploadFinalPath, Path.GetDirectoryName(info.RelativePath) ?? ); Directory.CreateDirectory(finalDir); var finalFile Path.Combine(finalDir, Path.GetFileName(info.RelativePath)); await using var outputStream new FileStream(finalFile, FileMode.Create); for (int i 0; i info.ChunkCount; i) { var chunkPath Path.Combine(info.TempDir, $chunk_{i:D6}.dat); if (!System.IO.File.Exists(chunkPath)) { return BadRequest($分块 {i} 不存在合并失败); } await using var chunkStream new FileStream(chunkPath, FileMode.Open); await chunkStream.CopyToAsync(outputStream); } // 合并完成后清理临时目录 Directory.Delete(info.TempDir, true); FileUploadService.FileInfos.TryRemove(request.FileId, out _); return Ok(new { path finalFile }); }合并的核心逻辑非常直接——按顺序找出所有chunk_*.dat文件逐个复制到目标流中。但有一个隐患如果合并过程中掉电或服务崩溃临时目录里的分块文件还在但最终文件只有一半。生产环境处理办法是先合并到同一个目录下的临时文件比如xxx.merge.tmp合并完成后用File.Move原子性地替换目标文件。File.Move在同一个分区内是原子操作可以最大限度避免脏文件。注意Path.Combine处理相对路径时如果RelativePath包含../这种恶意字符串会引发路径穿越漏洞。示例代码里直接用了Path.GetFileName和Path.GetDirectoryName做了基本防护但在生产环境一定要对RelativePath做合法性校验比如只允许字母、数字、下划线和斜杠。3.6 查询已上传分块接口断点续传支持这个接口麻雀虽小五脏俱全前端在网页刷新或上传中断恢复时调用。[HttpGet(status/{fileId})] public IActionResult GetStatus(string fileId) { if (!FileUploadService.FileInfos.TryGetValue(fileId, out var info)) return NotFound(上传任务不存在); return Ok(new { fileId info.FileId, uploadedChunks info.UploadedChunks, chunkCount info.ChunkCount, completed info.UploadedChunks.Count info.ChunkCount }); }UploadedChunks直接用列表返回数据量不大几千个块也就几千个整数JSON传输毫无压力。如果你有更极端的需求可以考虑用位图压缩这里不展开。4. 前端实现目录遍历、分块切片与并发控制4.1 目录选择与文件遍历前端我用原生JavaScript写方便放进任何框架项目里。核心就一行——设置webkitdirectory属性。input typefile idfolderPicker webkitdirectory directory multiple /拿到文件列表后重点是通过file.webkitRelativePath还原目录结构。const input document.getElementById(folderPicker); input.addEventListener(change, async (e) { const files Array.from(e.target.files); const taskList files.map(file ({ file: file, relativePath: file.webkitRelativePath || file.name, name: file.name, size: file.size, chunkTotal: Math.ceil(file.size / CHUNK_SIZE) })); // 过滤掉空文件夹浏览器不会返回空文件夹的file对象 const validTasks taskList.filter(t t.size 0); // 估算总大小用于整体进度 const totalSize validTasks.reduce((acc, cur) acc cur.size, 0); let uploadedSize 0; await uploadAllFiles(validTasks, (loaded, total) { uploadedSize loaded; showProgress(uploadedSize / totalSize); }); });这里有一个容易被忽略的点e.target.files返回的不是数组而是一个FileList它没有map、filter这些方法必须先Array.from转成数组。对文件夹里的空文件0字节文件我直接过滤掉了因为空文件没必要走分块流程注册后合并出来本来就是空文件没必要浪费一次HTTP请求。但如果你的业务要求保留空文件占位可以在注册接口时单独处理。4.2 单文件分块切片File.slice和MD5计算核心代码如下const CHUNK_SIZE 4 * 1024 * 1024; // 4MB function createChunks(file) { const chunkCount Math.ceil(file.size / CHUNK_SIZE); const chunks []; for (let i 0; i chunkCount; i) { const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const blob file.slice(start, end); chunks.push({ index: i, blob: blob, size: blob.size }); } return chunks; }file.slice(start, end)是Blob接口的原生方法它不会把整个文件读进内存这点和FileReader.readAsArrayBuffer完全不同。浏览器底层是惰性读取性能很好可以放心对大文件使用。然后是MD5计算我用的是库spark-md5。注意如果对大文件一次性readAsArrayBuffer整个读入再计算浏览器内存会直接爆掉。正确做法是分片增量计算async function calcFileMd5(file) { const spark new SparkMD5.ArrayBuffer(); const chunkSize 2 * 1024 * 1024; // 计算MD5时的读取分片2MB即可 const totalChunks Math.ceil(file.size / chunkSize); for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(file.size, start chunkSize); const arrayBuffer await file.slice(start, end).arrayBuffer(); spark.append(arrayBuffer); } return spark.end(); }这里读2MB一截就有个性能权衡arrayBuffer()每次是同步分配内存的2MB很安全500MB的文件也就250次循环速度完全在可接受范围。4.3 并发控制不一次性全塞给浏览器假设一个文件夹下有2000个文件每个文件切成10块那就是20000个请求。如果一次性全部发动浏览器瞬间创建大量并发连接轻则卡页面重则强杀进程。所以必须做并发池控制。我这里实现了一个简单但有效的并发池async function poolExecutor(tasks, limit, handler) { const results []; const executing new Set(); for (const task of tasks) { const promise Promise.resolve().then(() handler(task)); results.push(promise); executing.add(promise); const clean () executing.delete(promise); promise.then(clean).catch(clean); if (executing.size limit) { await Promise.race(executing); } } return Promise.all(results); }用法const limit 3; // 同时上传3个分块 await poolExecutor(allChunks, limit, async (chunk) { const formData new FormData(); formData.append(fileId, chunk.fileId); formData.append(chunkIndex, chunk.index); formData.append(chunkMd5, chunk.md5); formData.append(file, chunk.blob, ${chunk.fileName}.part${chunk.index}); await fetch(/api/upload/chunk, { method: POST, body: formData }); });并发数定多少我实测的经验值3到5个比较稳。太少了带宽用不满太多了服务端并发写临时文件导致磁盘IO骤升反而变慢。如果走的是内网千兆可以调到8公网上行带宽只有5Mbps并发再大也就那样调成3反而减少网络重传率。提示FormData的file字段第三个参数是文件名。这里必须带上文件名参数否则后端拿到的file.FileName会是blob不利于排查问题。我习惯命名为原文件名.part{索引}日志里一眼能看出是哪个文件的哪个块。4.4 整体调度串行处理文件并行处理分块还有一个调度层面的问题几千上万个分块放在一个并发池里如果某个文件传失败整个队列都要等它。更合理的策略是文件级别串行分块级别并行。也就是说同时处理N个文件每个文件内部的块并发上传但这个文件的块全部完成后/至少注册并传完所有块后才开始下一批文件。实现方式可以简化我是用了一个“先注册所有文件然后把所有块打散放进‘原子块任务’列表按fileId分组组间串行、组内并发”。伪代码如下const fileGroups groupBy(validTasks, fileId); for (const group of fileGroups.values()) { // 组内块并发3个 await poolExecutor(group.chunks, 3, uploadChunk); // 组内所有块传完后调用合并 await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId: group.fileId }) }); }这样做的好处是前端可以“每传完一个文件就合并一个文件”页面上的进度条实时感知每个文件的状态用户体验很好。而且如果一个文件失败不会影响其他文件前端只需要单独重传失败的文件。4.5 断点续传的前端逻辑断点续传是分块上传的最大红利。前端在正式开始上传前先调用status接口拿到uploadedChunks然后过滤掉已经上传的块const statusResponse await fetch(/api/upload/status/${fileId}); const status await statusResponse.json(); const uploadedSet new Set(status.uploadedChunks || []); const pendingChunks allChunks.filter(chunk !uploadedSet.has(chunk.index));这个操作在网页刷新重进后特别有用。用户传了80%的文件夹网断了重新打开页面选择同一文件夹前端可以直接跳过已上传的块只补剩下的省时省流量。5. 服务端配置与潜规则Kestrel、IIS和反向代理5.1 Kestrel请求体限制ASP.NET Core默认的Kestrel最大请求体大小是30MB30,000,000字节如果前端一个块切得超过了这个值请求会被413 Payload Too Large拦截。解决方法是显式调整builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 10 * 1024 * 1024; // 10MB });要注意如果部署在IIS后面IIS自身还有maxAllowedContentLength限制默认约30MB需要在web.config里同步调整system.webServer security requestFiltering requestLimits maxAllowedContentLength10485760 / /requestFiltering /security /system.webServer这段配置是很多人容易漏掉的。前端块大小、Kestrel限制、IIS限制三者必须同时匹配否则就会出现“本地调试好好的部署到服务器就上传失败”的奇怪问题。5.2 临时目录的磁盘空间管理分块上传会产生临时文件如果有多个用户同时上传大文件临时目录的磁盘占用会飙升。试想一下一个用户传10GB文件临时分块就是10GB100个用户同时传瞬间1000GB。所以生产环境要做两件事定时任务清理定期清理超过24小时、仍未合并的临时目录。磁盘空间预警临时目录所在磁盘剩余空间低于阈值时拒绝新的上传请求。public class UploadTempCleaner : BackgroundService { private readonly string _tempPath; private readonly TimeSpan _expireTime TimeSpan.FromHours(24); public UploadTempCleaner(string tempPath) { _tempPath tempPath; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { foreach (var dir in Directory.GetDirectories(_tempPath)) { var lastWrite Directory.GetLastWriteTime(dir); if (DateTime.Now - lastWrite _expireTime) { Directory.Delete(dir, true); } } } catch { } await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } }这个后台服务虽然简单但非常实用。没有它的项目运行三个月后临时目录里会堆满用户中途放弃的垃圾分块。6. 实测过程与踩坑记录6.1 测试环境与测试数据我的测试环境本地开发机16GB内存Windows系统后端ASP.NET Core 8前端纯静态页面托管在wwwroot下。测试数据集是模拟的某项目素材库总共包含212个文件分成7层目录总大小约3.7GB其中有一个单文件1.2GB的视频素材。为了验证分块效果我用浏览器的开发者工具把网络节流到“Fast 3G”约1.6Mbps下行、750Kbps上行模拟真实弱网环境。6.2 弱网环境下的表现与故障第一次测试并发数设为8分块大小4MB。结果不到30秒页面就开始出现大量Failed to fetch错误服务端日志显示大量IOException: The process cannot access the file because it is being used by another process。排查后发现两个问题前端并发太高同一个fileId的多个块同时写入同一个临时目录Windows下频繁的文件创建和写入导致磁盘锁竞争严重。把并发降到3后这个现象明显好转。弱网下大量请求超时但前端fetch没有设置超时时间导致executing集合里堆积了大量卡死的Promise后面的任务永远排不进去。必须给每个分块请求设置超时。前端超时设置很简单用AbortController包一层。async function fetchWithTimeout(url, options, timeout 30000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } }超时时间设为30秒配合重试机制弱网下明显稳了很多。6.3 重试机制的两种做法失败重传是分块上传的保命手段。我的做法是单个块请求失败后先重试当前块3次每次间隔指数退避也就是1秒、2秒、4秒。如果3次都失败标记这个块为失败继续其他块最后统一汇总失败块让用户选择重试还是放弃。注意重试时要重新生成或者复用同一个chunkMd5。如果前端是每次请求前临时计算MD5重试时可能因为网络时序产生了新的Blob对象但内容是一样的MD5也会一样问题不大。怕的是你用了随机值导致每次重传的MD5不同服务端校验就永远过不了。6.4 服务端收到“0字节”分块的坑还有一次本地测试时发现合并出来的文件比源文件小了约几KB排查了很久最后定位到某个分块在保存时用的是FileMode.CreateNew而这个块之前已被失败重试的半截写占用了后续请求拿到的IFormFile.Length是0CopyToAsync没写入任何内容文件却已经创建了。之后再改成FileMode.Create问题消失。这类问题其实是多线程并发下临时文件管理的经典坑。建议所有分块落盘都用FileMode.Create并且保存后立刻校验文件长度if (new FileInfo(chunkFullPath).Length ! file.Length) { return BadRequest(分块写入不完整请重试); }长度都匹配不上直接让前端重传比后端默默存一个坏块要可靠得多。7. 常见问题排查速查表症状可能原因排查与解决方案前端报413 Payload Too LargeKestrel或IIS限制请求体大小统一调整Kestrel和IIS的maxAllowedContentLength确保大于分块大小合并后的文件损坏某个分块写入不完整或丢失检查UploadedChunks与磁盘上分块文件是否一致加MD5校验前端上传很慢并发数太低或网络拥塞调整并发数观察浏览器网络面板确认带宽是否跑满页面卡死崩溃并发数太高降到3~5用并发池而不是一次性Promise.all断点续传失效UploadedChunks记录丢失服务进程重启时需恢复状态生产环境必须持久化到数据库/Redis文件夹层级丢失webkitRelativePath为空前端兜底传文件名或换浏览器验证中文文件名乱码路径编码问题服务端统一使用UTF-8前端FormData不需要额外处理检查日志乱码时注意终端编码临时文件堆积没有清理机制部署后台清理任务定期删除过期临时目录前端内存上涨MD5计算一次性读取大文件改用增量分片计算MD5块大小2MB即可上传一半刷新页面进度丢失前端没有持久化上传任务上传任务状态存入sessionStorage或数据库刷新后用status接口恢复8. 补充建议生产化改造方向上面的示例能跑通整个流程但真要上生产还需要做几件事。第一存储层改造。静态字典换数据库上传任务信息持久化服务重启后可以恢复未完成的上传任务。数据库结构至少要有上传任务表upload_task、分块记录表chunk_record、文件映射表file_meta。分块记录表用taskId chunkIndex做联合主键避免重复插入。第二多机部署的坑。如果服务部署在多节点后面加负载均衡临时目录必须换成共享存储比如NAS或对象存储。分块上传的临时文件写到共享目录否则A节点收到注册请求B节点收到分块请求B节点找不到临时目录直接404。这也是生产环境最常见的分布式上传问题。第三对象存储的分块上传对接。如果你的最终文件要放到对象存储可以不走本地合并而是用云厂商提供的Multipart Upload接口前端切块直接传对象存储后端只负责生成上传凭证、记录状态。这样服务端磁盘压力小得多但实现复杂度会高一些。具体接口各家有差异这里不展开。第四安全防护。上传接口必须鉴权否则任何人都能往你的服务器写文件。路径参数必须白名单校验防止恶意../穿越。合并且文件落盘后最好用杀毒或文件头校验确认文件不是伪造的可执行文件。9. 个人实际落地体会与避坑总结做了几次分块上传后我最深的体会是分块上传真正难的点不在分块本身而在状态管理和异常处理。分块、切片、拼文件这些核心代码加起来也就两百行但你要应对网络超时、进程崩溃、磁盘满了、并发写冲突、浏览器兼容性这些才是真正耗精力的地方。几个最终提醒分块大小、并发数、超时时间、重试策略这四个参数是一套调的时候要一起调别只改一个。一定要在弱网环境测试。本地回环网络速度太快很多并发问题、超时问题只有模拟弱网才会暴露。用户上传进度条块级进度就行别做字节级精确进度后者实现复杂不说浏览器对进度事件的触发频率并不稳定。文件夹目录层级多了之后最终路径不能无限深。Windows下默认路径长度限制是260个字符但ASP.NET Core在较新版本里自动启用了长路径支持不过Linux部署时依然要注意路径深度。上面这套方案我从最早只支持单文件分块上传到改造为支持整个文件夹再到断点续传和后台清理任务前后迭代了三版。现在的版本稳定跑了大半年最大单文件传过9GB文件夹一次传过几千个文件没有再出过幺蛾子。如果你正在做类似需求这个示例足够你起步了剩下的边做边踩边补就行。
阅读完成 · 觉得有帮助?
咨询建站