1. 认识大文件上传的真相与核心矛盾
很多人在项目做到“上传一个几百MB甚至几个GB的文件”时,才发现传统的“一次请求发整个文件”的方式根本不顶用。不是代码写错了,而是从底层设计上就走不通。ASP.NET这类Web应用在处理大型二进制文件时,最让人头疼的问题有三个:
第一,服务器的内存和请求体缓冲区扛不住。IFormFile默认会把整个请求体缓冲到内存或磁盘临时文件中,文件一大,内存就像吃了膨胀剂一样往上飙,低配服务器可能直接被拖垮。第二,大请求在到达业务代码之前就可能被IIS或Kestrel拦掉,默认配置下超过30MB的请求会直接返回413错误,前端连个正经的报错都看不到。第三,网络随时会抖动,一个2GB的文件传了90%,突然断开,如果整个文件重传,成本高到让人崩溃。
所以,分块上传的思路很简单:把一个大文件切成若干小块,逐块上传,后端按顺序存放,最后合并。这样每次传输的数据量小,单次失败影响范围被限制在一个分块内,甚至可以做到断点续传,传失败的分块重传就行,不需要从头再来。
文件夹选择这个需求,通常和分块上传是两件独立的事,但在很多项目里会同时出现。比如后台管理系统里要批量导入素材、代码、文档目录,如果让用户一个一个选文件,体验极差;而让用户选一个文件夹,一次性把目录里的文件带进来,再在后端保留目录结构,就顺手得多。浏览器端要通过webkitdirectory属性实现文件夹级选择,这一小块内容也很有讲究。
这篇文章会把两件事都讲透:一是大文件的分块上传,在ASP.NET Core里到底该怎么办,前端要怎么切,后端要怎么存、怎么合并;二是文件夹选择怎么做,以及如何把目录树结构转化成可上传的任务流。我会把设计思路、关键代码、部署时的坑,都放在一起聊,尽量让没有接触过这个方向的朋友也能直接照着做。
2. 分块上传的整体流程设计与API设计思路
2.1 一条完整的分块上传链路长什么样
先理顺一整套流程,再来拆细节。一个靠谱的分块上传方案,至少要包含四个阶段。
第一阶段是“初始化任务”。前端在拿到文件后,先调后端接口,把文件名、文件大小、分块大小、总分块数、文件唯一标识(通常用GUID或文件哈希)发给服务端。服务端为这个文件创建一个上传任务记录,分配一个文件夹或ID,返回给前端一个可以继续上传的凭证或任务ID。这个阶段的目的是让后端知道:接下来会有一批分块要过来,请提前准备好存储空间和状态记录。
第二阶段是“逐块上传”。前端把File对象切片后,按顺序或并发地把每个分块POST到后端。每个分块都需要携带:任务ID、分块序号、当前块数据、总块数、文件原始名。服务端收到一个分块后,将二进制流写入以分块序号命名的临时文件,并对分块状态做标记。这个阶段是整个方案的核心,它的鲁棒性直接决定了后续合并的成功率。
第三阶段是“合并完成”。当前端把所有分块都传完后,它调用合并接口,服务端按照分块序号顺序,把临时文件按顺序拼接到最终文件中。拼接结束后,可选地计算整体文件哈希,与客户端一开始提交的哈希比对,确认没有缺块或错序。最后清理临时文件,把正式文件移动到目标存储目录。
第四阶段是“状态查询”。这个阶段是为了支撑断点续传。前端在刷新页面或网络恢复后,调用查询接口,传入任务ID,服务端返回“已收到的分块序号集合”。前端过滤掉这些分块,只传缺失的部分。没有这个接口,分块上传就只能防内存峰值,防不了网络中断。
2.2 API接口应该怎么拆
很多初学者喜欢把“上传全部”做成一个接口,里边又传文件又传元数据,还要求后端自动识别分块。这样做不是不行,但会让逻辑耦合严重,出问题难排查。我建议把接口拆成下面四个独立的端点,职责单一,前端也好理解:
| 接口 | 作用 | 请求方式 | 关键参数 |
|---|---|---|---|
/api/upload/init | 创建上传任务 | POST | fileName, fileSize, chunkSize, totalChunks, fileId |
/api/upload/chunk | 上传单个分块 | POST | fileId, chunkIndex, chunk(二进制), fileName |
/api/upload/merge | 合并已上传分块 | POST | fileId, totalChunks, md5(可选), fileName |
/api/upload/status | 查询已上传分块 | GET | fileId |
这里有几个细节值得展开说。
初始化接口不是必需的,但强烈建议加上。它可以做权限校验、资源配额校验、重复文件识别。比如某些系统要求只有会员才能传超过1GB的文件,这个限制放在初始化阶段最合理,分块上传阶段就不用重复判断了。再比如,如果文件已经传过了,初始化时可以直接返回“秒传”结果,前端就不用再传一遍。
分块上传接口是最核心的接口,性能关键点在读写文件和释放资源。使用框架内置的模型绑定IFormFile来接收文件,这没什么问题,但要注意,这种方式在分块数据进入模型绑定层时,已经被框架解析并保存到临时缓冲区了。对于中小分块没毛病,对于超大分块或并发很高的情况,建议直接操作 HttpContext.Request.Body 做流式解析,把数据直接写磁盘,能显著降低内存压力。
合并接口要注意“幂等性”。如果前端因为网络超时没收到合并成功响应,它会重发合并请求,这时后端如果再次合并,可能会重复追加文件,把文件搞坏。所以合并接口里建议存在一个状态标记,比如检查最终目标文件是否已生成,如果已生成且大小正确,直接返回成功。这个检查成本很低,却能在弱网环境下救你一次。
2.3 为什么文件Id要用服务端生成的GUID而不是文件名
唯一标识的设计很多人不重视,但它是整个方案的粘合剂。如果你只用文件名来做唯一标识,一旦用户上传了两个同名文件,或者改了文件内容但名字没变,分块就会互相覆盖。建议在初始化阶段由服务端生成一个GUID作为fileId,前端在后续所有请求中带上这个GUID。同时可以记录原始文件名和文件最后的修改时间,这些信息对断点续传时判断文件是否被修改非常有帮助。
另外,后端存储临时分块的目录结构可以设计成这样的形式:
uploads/ temp/ 3f2a9b7c-1d4e-4f6a-8b2c-9a0e1d2f3a4b/ 0 1 2 ... final/ 3f2a9b7c-1d4e-4f6a-8b2c-9a0e1d2f3a4b_v1.mp4每个任务一个目录,目录名是fileId,里面按chunkIndex为文件名保存分块。合并完成后删掉整个temp目录,把最终文件移动到final目录。这种设计清理方便、结构清晰,也方便做定时垃圾回收——任务超时未合并就把整个目录清掉。
3. 前端切块与并发控制的实用方案
3.1 从文件选择到生成分块列表
浏览器的File API提供了强大的Blob切片能力,这是分块上传的地基。拿到用户选择的文件后,第一件事不是直接开传,而是先计算出分块列表。
常规的分块大小建议设定在1MB到10MB之间。具体多少合适,我在后面专门讲。这里先给出一个计算分块的JavaScript示例:
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB function generateChunkList(file, chunkSize = DEFAULT_CHUNK_SIZE) { const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ index, start, end, size: end - start, blob: file.slice(start, end) }); start = end; index++; } return chunks; }这里的核心是file.slice(start, end),它不会把文件数据立刻读进内存,而是产生一个指向原始文件某段数据的Blob视图。真正发送时,浏览器才会读取对应片段的数据。这样做的好处是,哪怕一个10GB的文件,前端内存占用也不会直接跟着涨10GB。你在这个场景里生成的chunk对象集合,体积很小,只是元数据。
有一个小细节容易忽略:最后一个分块的大小,往往不是整数倍的chunkSize,而是剩余字节数。这个值不能想当然地认为是固定值,必须在遍历时用Math.min算出来。后端合并时也要依靠每个分块的实际大小,而不是“块序号乘以块大小”。我会在后端代码里再次强调这一点。
3.2 并发控制:不是越快越好
分块上传天然适合并发,但不代表并发开得越高越好。每个分块请求都是一次HTTP连接,如果一次性开10个并发,可能会占满服务器连接池,也可能会导致带宽争抢,反而整体速度变慢。我习惯的做法是写一个简单的并发控制函数,限制同时上传的请求数量。
下面是一个简易的并发队列实现,适合放在纯前端的业务代码里,不需要引入额外依赖:
async function uploadAllChunks(chunks, uploadFn, concurrency = 3) { const queue = [...chunks]; const workers = []; for (let i = 0; i < concurrency; i++) { workers.push((async () => { while (queue.length > 0) { const chunk = queue.shift(); if (!chunk) break; await uploadFn(chunk); } })()); } await Promise.all(workers); }这段代码用固定数量worker从队列里取任务,每次取一个分块上传,上传完成后再取下一下。它保证了最多只有concurrency个请求在飞行。并发数根据网络环境的建议如下:
- 企业内网、高带宽:并发数5,分块5MB-10MB
- 公网宽带、家庭网络:并发数3,分块2MB-4MB
- 移动端弱网:并发数1,分块512KB-1MB
并发数的本质是在“连接建立成本”和“带宽占用量”之间找平衡。分块太小会导致HTTP请求数太多,服务端日志和请求解析开销上升;分块太大会让单次传输时间过长,网络波动失败率上升。实测下来,公网场景下并发3+分块2MB,综合表现比较稳。
3.3 断点续传:状态存储与重传过滤
断点续传实现的核心是:每次上传分块成功后,把该分块的索引记录在浏览器的持久化存储里。现在主流做法是记录在localStorage或IndexedDB里,而不是只存在内存变量中。
代码可以这样设计:
async function uploadWithResume(file, fileId) { const totalChunks = Math.ceil(file.size / DEFAULT_CHUNK_SIZE); const storedKey = `upload_${fileId}_${file.lastModified}`; const uploaded = JSON.parse(localStorage.getItem(storedKey) || '[]'); // 从服务端获取已上传分块,去重 const serverUploaded = await fetch(`/api/upload/status?fileId=${fileId}`).then(r => r.json()); const uploadedSet = new Set([...uploaded, ...serverUploaded.uploadedChunks]); for (let index = 0; index < totalChunks; index++) { if (uploadedSet.has(index)) { continue; } const blob = file.slice(index * DEFAULT_CHUNK_SIZE, Math.min((index + 1) * DEFAULT_CHUNK_SIZE, file.size)); const ok = await uploadSingleChunk(fileId, index, totalChunks, blob, file.name); if (ok) { uploaded.push(index); localStorage.setItem(storedKey, JSON.stringify(uploaded)); } } await mergeRequest(fileId, totalChunks, file.name); }这里强调一个容易踩坑的点:localStorage的键名最好把文件的lastModified时间戳带进去。如果用户在上传了一半之后修改了文件,这个值会变,下次打开页面时就不会复用旧的分块记录,从而避免把新旧版本的内容拼接成一个损坏文件。你可能会觉得这是小概率事件,但在实际工程里,我遇到过因为文件在断点后被重新导出而合并失败的情况,教训很深刻。
如果系统对可靠性要求极高,分块上传成功后还可以在服务端记录每个分块的MD5或SHA哈希,前端在上传前计算分块哈希并在上传时传给服务端。服务端比对一致后才标记成功。这样能防止分块在传输过程中损坏,也能防止用户从非正常渠道伪造分块。
4. 文件夹选择与树形目录重建的完整处理
4.1 用webkitdirectory获取文件夹内的文件列表
文件夹选择的能力来自一个非标准但已被广泛支持的HTML属性:webkitdirectory。在input上加上它之后,用户点击选择框时会弹出文件夹选择器,而不是文件选择器。选择完成后,input.files里会包含该目录下所有文件的File对象,并且每个File对象身上会带上webkitRelativePath属性。
示例:
<input type="file" id="folderInput" webkitdirectory multiple />注意,虽然属性名带webkit前缀,但现代版本的火狐、Chrome、Edge、Opera都支持得很好。只有部分iOS Safari和旧版桌面Safari有兼容性问题。如果目标用户群高度依赖Safari,这里就必须做一个降级方案:提示用户在Chrome或Edge中打开,或者提供一个压缩包上传后解压的替代流程。
获取文件列表后,遍历出来:
const files = Array.from(e.target.files); for (const file of files) { console.log(file.webkitRelativePath); // 类似 "myFolder/sub/photo.jpg" console.log(file.name, file.size, file.lastModified); }这里的关键数据就是webkitRelativePath。它包含了文件在所选根目录下的完整相对路径,路径分隔符是正斜杠/。拿到了它,前端就能还原整棵目录树,后端也能据此重建目录结构。
4.2 保留目录结构:两种数据传递方式
第一种方式是把目录树直接构建成JSON传给后端。前端遍历所有文件,构建出嵌套对象。比如:
function buildTree(files) { const root = { name: 'root', children: {} }; for (const file of files) { const parts = file.webkitRelativePath.split('/'); const fileName = parts.pop(); parts.shift(); // 移除根目录名 let node = root; for (const part of parts) { if (!node.children[part]) { node.children[part] = { name: part, children: {} }; } node = node.children[part]; } node.children[fileName] = { name: fileName, file }; } return root; }然后把这个树形结构序列化成JSON,通过初始化接口传给后端。这种方式的优点是信息完整、后端可以一次性创建整个目录骨架;缺点是如果文件数量很大,JSON会非常大,而且树的定义需要前后端协商好,解析代码容易写得啰嗦。
第二种方式是扁平化传输,我更喜欢这种方式。后端不需要知道树形结构,前端只需要把每个文件的相对路径和文件数据一起放进分块上传请求里。比如用一个字段叫relativePath,值为sub/photo.jpg。后端在保存文件时,只需要把relativePath作为目录层级参数,动态创建子目录并保存文件即可。
第二种方式的好处是,上传过程和普通分块上传几乎没有区别,只是每个请求多带了一个元数据字段;所有文件仍然是按分块方式走到合并阶段;后端接收分块、合并后,根据relativePath把最终文件移动到目标目录。不需要额外的树解析逻辑,出错概率更低。
4.3 后端如何安全地重建多层目录
后端在根据relativePath创建目录时,必须做安全处理,否则会出现路径穿越漏洞。所谓路径穿越,就是文件名或目录名里包含../,如果拼接时不做处理,用户就可以把文件写到任意目录,甚至覆盖服务器上的其他文件。
ASP.NET Core里可以用Path类的方法做规范化校验。做法是:传入的relativePath先标准化,再判断它是否仍然在允许的根目录内。下面给出一个简化但能跑通的示例:
private string SafeCombine(string rootPath, string relativePath) { var root = Path.GetFullPath(rootPath); var full = Path.GetFullPath(Path.Combine(root, relativePath)); if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException("Invalid path."); } return full; }同时,最终保存时不要直接用原始文件名存磁盘。建议用GUID或者“GUID_原始文件名”的方式作为物理文件名,原始文件名存数据库或映射表。这样既能避免非法字符,也能防止文件名冲突。
4.4 文件夹选择的性能陷阱:文件数量过多
文件夹选择最大的性能风险不是文件太大,而是文件数量太多。我曾经遇到过一个素材目录,内含4000多个文件,总大小1GB。遍历没问题,但如果把所有文件一次性切片并生成上传任务,前端内存会瞬间暴涨,页面直接卡死。
应对方法是分批处理。不建议一次把一个文件夹里的所有文件都推入上传队列。可以先把files数组按文件数量切成批次,比如每批100个文件,先上传完一批,再继续下一批。这样内存占用可控,服务端也不会在同一时间收到大量并发分块请求。
批量上传时的伪代码:
const files = Array.from(e.target.files); const BATCH_SIZE = 100; for (let i = 0; i < files.length; i += BATCH_SIZE) { const batch = files.slice(i, i + BATCH_SIZE); await uploadFileBatch(batch); // 批内文件逐个分块上传并等待完成 }同时建议在后端接口里加一层数量限制,返回明确错误信息而不是默默接受所有文件。比如限制单次文件夹选择最多500个文件、总大小不超过2GB。这个限制要放在初始化接口或合并接口里做校验,而不是只靠前端。
5. 后端核心代码:接收分块与合并文件
5.1 ASP.NET Core中的分块接收接口怎么写更稳
后端接收分块最常用的是[FromForm]+IFormFile的模型绑定方式。代码如下:
[HttpPost("api/upload/chunk")] public async Task<IActionResult> UploadChunk([FromForm] ChunkUploadModel model) { var fileId = model.FileId; var chunkIndex = model.ChunkIndex; var tempDir = Path.Combine(_webHostEnvironment.ContentRootPath, "uploads", "temp", fileId); Directory.CreateDirectory(tempDir); var chunkPath = Path.Combine(tempDir, chunkIndex.ToString()); await using (var stream = new FileStream(chunkPath, FileMode.Create)) { await model.File.CopyToAsync(stream); } return Ok(new { chunkIndex, received = true }); }模型定义:
public class ChunkUploadModel { public string FileId { get; set; } public int ChunkIndex { get; set; } public IFormFile File { get; set; } }这种方式能应对绝大多数分块场景。IFormFile会把接收的流先缓冲到临时位置,但分块本身不大,所以没问题。如果非要追求极致的大分块高性能,可以通过Request.EnableBuffering()和Request.Form手动读取流,但代码复杂度会上升不少。我建议按需取舍,不必为了炫技牺牲可维护性。
两个容易出错的点:
第一,FileMode.Create会覆盖已有文件。如果断点续传时前端不小心重传了已上传的分块,这个覆盖是安全的,因为内容相同。但如果你想保留原数据,可以先判断文件是否存在,存在就直接返回成功,不重复写入。
第二,临时目录的权限问题。部署在IIS或作为服务运行时,运行账户必须对upload目录拥有写权限,否则会抛UnauthorizedAccessException。这个问题在新手部署时出现频率很高,要提前处理好目录ACL。
5.2 合并接口的关键逻辑:按顺序拼接与校验
合并接口的工作是读取所有分块,按序号拼接成完整文件。最稳妥的实现方式如下:
[HttpPost("api/upload/merge")] public async Task<IActionResult> Merge([FromBody] MergeModel model) { var tempDir = Path.Combine(_env.ContentRootPath, "uploads", "temp", model.FileId); var finalDir = Path.Combine(_env.ContentRootPath, "uploads", "final"); Directory.CreateDirectory(finalDir); // 幂等处理:已合并过则直接返回成功 var finalPath = Path.Combine(finalDir, $"{model.FileId}.bin"); if (System.IO.File.Exists(finalPath)) { return Ok(new { merged = true, skipped = true }); } var chunkFiles = Directory.GetFiles(tempDir) .Select(f => new FileInfo(f)) .OrderBy(f => int.Parse(f.Name)) .ToList(); if (chunkFiles.Count != model.TotalChunks) { return BadRequest($"Chunk count mismatch. expected {model.TotalChunks}, got {chunkFiles.Count}"); } await using (var outStream = new FileStream(finalPath, FileMode.CreateNew)) using (var sha256 = System.Security.Cryptography.SHA256.Create()) { foreach (var chunk in chunkFiles) { await using (var inStream = chunk.OpenRead()) { await inStream.CopyToAsync(outStream); } } // 如需校验哈希,可先flush后计算,再与客户端传入值比对 outStream.Flush(); } Directory.Delete(tempDir, true); return Ok(new { merged = true, finalPath }); }这段代码里有几个细节值得展开。
OrderBy(f => int.Parse(f.Name))是关键。分块文件名是序号,必须按数字顺序排序,不能用字符串排序,否则10会在2之前。这个坑我见过不止一次,合并后文件错乱,整个任务作废。
临时分块文件的格式是纯数字命名,如果将来想存更多元信息,可以改成chunk_0001.dat这种带前缀的格式,取序号时再剥离前缀。但不管叫什么,最终排序逻辑都要以数字为准。
合并时对分块数量做校验,是防止前端漏传或后端文件丢失。如果数量对不上,直接返回错误。前端收到错误后要主动查一次状态接口,补传缺失分块。
另外,合并操作如果用FileStream进行顺序写,IO效率是比较高的。但如果文件特别大、分块文件很多,可以考虑在合并过程中限制同时打开的文件数,避免文件句柄耗尽。上面的写法是一次只打开一个分块流,句柄占用少,适合大多数场景。
5.3 IIS和Kestrel的请求体大小限制
这部分经验价值极高。无论你的代码写得多么完美,只要部署环境的请求体限制没放开,分块上传永远会以失败告终。ASP.NET Core默认的Kestrel请求体大小上限是30MB(约30000000字节)。如果你把分块设成5MB,只看单块并不超限,但有些场景下浏览器或代理可能会把多个分块合并成一个大请求发送,或者某些网络代理会缓冲请求体直到到达一个阈值,导致后端看到的请求体超过限制。
这里分两层说明:
在Kestrel层,可以在Program.cs里全局放开限制,并提供明确的业务限制值:
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB,按分块上限配 });在IIS层,如果应用部署在IIS上,还需要修改web.config中的maxAllowedContentLength:
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="104857600" /> </requestFiltering> </security> </system.webServer>这里的单位是字节,100MB就是104857600字节。很多人只调Kestrel不调IIS,或者反过来,结果部署到某个环境时始终413。建议项目里把两处配置都写上,并在前端给用户一个友好的提示,说明当前环境允许的单次请求上限。
我建议把分块大小设置为小于请求体上限的四分之一,留足余量。比如IIS限制100MB,分块就用10MB到20MB,别卡在边缘上。
6. 分块参数设计、断点续传的工程细节
6.1 分块大小与并发数怎么定,为什么不能拍脑袋
参数设计是大文件上传方案里最容易被低估的部分。很多人问“分块大小多少合适”,其实没有绝对标准,但有一组可以遵循的决策逻辑。
分块大小的下限受制于网络请求开销。每个分块上传都要经过:TCP握手(如果复用连接则能省掉)、HTTP请求构建、服务端解析、磁盘写入。如果分块太小,比如64KB,一个1GB文件要切16000块,光是请求数和日志量就能把小服务拖垮。所以分块不能太小。
分块大小的上限受制于单次请求失败的影响范围和中间设备的缓冲区。分块越大,单次传输时间越长,遇到网络抖动的概率越高,失败重传的代价也越大。比如你是100MB一个分块,断一次网就要重传100MB,体验非常糟糕。
实操建议:
| 网络环境 | 分块大小 | 并发数 | 备注 |
|---|---|---|---|
| 内网/高带宽 | 10MB | 5 | 连接稳定,可激进 |
| 公网宽带 | 2-4MB | 3 | 平衡速度和稳定性 |
| 移动端/弱网 | 512KB-1MB | 1 | 宁可慢,不能老失败 |
参数确定后,前端和后端的配置要统一。服务端初始化接口可以校验前端传的chunkSize是否超过服务端上限,如果超了就返回错误,让前端改用更小的分块重新初始化。这样即使前端被篡改,服务端也能兜底。
6.2 断点续传的可靠性:状态记录与幂等上传
断点续传要真正可靠,前端和后端必须同时维护状态,不能只靠一端。
前端维护的是“进度日志”,记录哪些分块已经上传成功。后端维护的是“已分块文件列表”,通过文件系统或数据库记录分块是否存在。查询接口就是让前端和后端对账的。
对账时有一个常见坑:如果前端只记录“我上传成功过哪些序号”,但服务端实际因为磁盘故障或误删除丢失了部分分块,前端就会跳过那些分块,导致合并失败。所以查询接口应该以后端实际存在的分块为准,而不是前端记录的为准。我推荐在status接口内部遍历临时目录中的分块文件,返回实际存在的序号集合。
在传输过程中,为了进一步防止数据损坏,可以给每个分块附加上一个简单的字符串校验值。前端的做法是对分块做MD5或SHA-1计算,计算过程会读取整个分块,虽然有一定CPU开销,但能换来很高的可靠性。服务端收到分块后也计算哈希,不一致就直接返回失败。这个逻辑特别适合视频、设计稿、招标文件这类“不能错一位”的资源。
6.3 任务超时与临时文件夹清理
分块上传是异步过程,任务可能因为用户中途放弃、浏览器关闭、网络长时间中断而永远完不成。如果不做清理,临时目录里会堆积大量分块文件,占满磁盘。
我的经验是设置两层清理机制。
第一层是业务清理。在初始化接口里写入一个过期时间,比如创建任务后24小时未合并则自动标记为超时。定时任务每小时扫描一次,删除超时的临时目录。
第二层是启动清理。应用启动时扫描uploads/temp目录,删除所有最后修改时间超过24小时的目录。这样即使服务崩溃了,重启后也能自动清理残局。
示例的定时清理代码可以使用BackgroundService实现,在ASP.NET Core里很常见:
public class TempFileCleanupService : BackgroundService { private readonly string _tempDir; public TempFileCleanupService(IWebHostEnvironment env) { _tempDir = Path.Combine(env.ContentRootPath, "uploads", "temp"); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { if (Directory.Exists(_tempDir)) { var dirs = Directory.GetDirectories(_tempDir); foreach (var dir in dirs) { if (Directory.GetLastWriteTime(dir) < DateTime.Now.AddHours(-24)) { Directory.Delete(dir, true); } } } } catch (Exception) { // 记录日志,不要影响主循环 } await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } }这个服务不复杂,但能避免很多“上传传一半,磁盘被占满”的事故。在项目里把它注册进依赖注入即可。
7. 完整可运行的示例:前端加后端串联起来
7.1 前端完整上传流程
为了让你能直接理解并上手,我把前端逻辑串成一个最小的可运行流程。
HTML部分:
<input type="file" id="fileInput" /> <button id="uploadBtn">开始上传</button> <div id="progress"></div>JavaScript部分:
const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const progress = document.getElementById('progress'); uploadBtn.addEventListener('click', async () => { const file = fileInput.files[0]; if (!file) return; const fileId = crypto.randomUUID(); const chunkSize = 2 * 1024 * 1024; const totalChunks = Math.ceil(file.size / chunkSize); // 1. 初始化任务 const initResponse = await fetch('/api/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, chunkSize, totalChunks, fileId }) }); if (!initResponse.ok) throw new Error('init failed'); // 2. 并发上传分块 let uploaded = 0; async function uploadOne(index) { const start = index * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('fileId', fileId); formData.append('chunkIndex', index); formData.append('file', blob, file.name); const resp = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); if (!resp.ok) throw new Error(`chunk ${index} failed`); uploaded++; progress.textContent = `progress: ${uploaded}/${totalChunks}`; } const queue = Array.from({ length: totalChunks }, (_, i) => i); const workerCount = 3; await Promise.all(Array.from({ length: workerCount }, async () => { while (queue.length) { const index = queue.shift(); await uploadOne(index); } })); // 3. 合并 const mergeResponse = await fetch('/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId, totalChunks, fileName: file.name }) }); const result = await mergeResponse.json(); alert('上传完成,文件ID: ' + fileId); });这段代码没有处理失败重试,但结构已经足够作为原型。生产环境里,你要在uploadOne里加失败重试逻辑,比如最多重试3次,次数用尽后把index重新推入队列末尾。或者在网络断开时,全局捕获异常后,从断点继续上传。
7.2 后端最小Web API示例
对应的后端代码,假设你已经创建了一个ASP.NET Core Web API项目。下面给出Minimal API风格的路由定义,比较简洁:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddHostedService<TempFileCleanupService>(); var app = builder.Build(); app.UseStaticFiles(); app.MapControllers(); app.MapPost("/api/upload/init", async (HttpContext context) => { var body = await context.Request.ReadFromJsonAsync<InitRequest>(); var fileId = body?.FileId ?? Guid.NewGuid().ToString(); var tempDir = Path.Combine(app.Environment.ContentRootPath, "uploads", "temp", fileId); Directory.CreateDirectory(tempDir); return Results.Ok(new { fileId }); }); app.MapPost("/api/upload/chunk", async (HttpContext context) => { var form = await context.Request.ReadFormAsync(); var fileId = form["fileId"].ToString(); var chunkIndex = int.Parse(form["chunkIndex"]); var file = form.Files["file"]; var tempDir = Path.Combine(app.Environment.ContentRootPath, "uploads", "temp", fileId); Directory.CreateDirectory(tempDir); var chunkPath = Path.Combine(tempDir, chunkIndex.ToString()); await using (var stream = new FileStream(chunkPath, FileMode.Create)) { await file.CopyToAsync(stream); } return Results.Ok(new { chunkIndex }); }); app.MapPost("/api/upload/merge", async (HttpContext context) => { var body = await context.Request.ReadFromJsonAsync<MergeRequest>(); var tempDir = Path.Combine(app.Environment.ContentRootPath, "uploads", "temp", body!.FileId); var finalDir = Path.Combine(app.Environment.ContentRootPath, "uploads", "final"); Directory.CreateDirectory(finalDir); var finalPath = Path.Combine(finalDir, $"{body.FileId}.bin"); if (System.IO.File.Exists(finalPath)) { return Results.Ok(new { merged = true, skipped = true }); } var chunkFiles = Directory.GetFiles(tempDir) .Select(f => new FileInfo(f)) .OrderBy(f => int.Parse(f.Name)) .ToList(); if (chunkFiles.Count != body.TotalChunks) { return Results.BadRequest(new { error = "chunk count mismatch" }); } await using (var outStream = new FileStream(finalPath, FileMode.CreateNew)) { foreach (var chunk in chunkFiles) { await using (var inStream = chunk.OpenRead()) { await inStream.CopyToAsync(outStream); } } } Directory.Delete(tempDir, true); return Results.Ok(new { merged = true }); }); app.Run(); public record InitRequest(string FileId, string FileName, long FileSize, int ChunkSize, int TotalChunks); public record MergeRequest(string FileId, int TotalChunks, string FileName);这段代码可以直接丢到一个空项目里跑。要注意,Minimal API的ReadFormAsync()对multipart/form-data是支持的,但需要调用IFormCollection,上面写法没有任何问题。如果你用的是Controller(MVC)方式,模型绑定更简单,逻辑完全一致。
上面没有把FileModel和脏路径清理等细节加上,但不影响你理解主流程。要在生产中用,把安全校验、哈希比对、数据库记录补上即可。
7.3 前端与后端联调时最容易遇到的三个问题
第一个问题是CORS。如果你的前端站点和后端API不在同一个域名下,浏览器会拦截跨域请求。需要在后端配置CORS策略,允许前端的来源。尤其要注意,上传分块时请求头里的Content-Type可能是multipart/form-data,这本身就属于非简单请求,浏览器会先发OPTIONS预检请求。后端一定要处理OPTIONS请求,并返回正确的Access-Control-Allow-Headers。
第二个问题是请求体被代理服务器拦截。如果前端和后端之间有Nginx等反向代理,它们可能也有body大小限制。Nginx默认client_max_body_size是1MB,不改这个值,哪怕后端允许大请求,分块也会在代理层被拒掉。配合上面的IIS配置,这些运行时环境问题经常让人排查到怀疑人生。
第三个问题是上传后的文件权限。如果最终文件在Linux上运行的服务中,打开写入时可能因为运行用户没有目录写权限而失败。解决方法是把uploads目录的权限授权给服务运行用户,或者更改存储目录到有权限的挂载盘。
8. 常见问题排查与实测踩坑记录
8.1 排查表:从现象到根因
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 分块请求返回413 | IIS或代理限制请求体大小 | 改web.config的maxAllowedContentLength、Nginx的client_max_body_size |
| 内存占用飙高 | 前端一次读取整个文件,或后端IFormFile缓冲过大 | 使用Blob.slice懒加载,调整分块大小 |
| 合并后文件损坏 | 分块排序用了字符串排序,导致10排在2前 | 用数字序号排序 |
| 合并后文件大小偏小 | 某个分块因为网络原因只传了一半,服务端却返回成功 | 给分块做MD5校验,读取完整再确认成功 |
| 同一文件多次上传时被覆盖 | 只使用文件名作为存储键 | 改用GUID或文件哈希作为任务ID |
| 断点续传后文件合并异常 | 文件在续传前被修改,旧分块与新内容不匹配 | 在续传前比较文件大小、修改时间 |
| 文件夹选择后无法获取文件列表 | Safari不支持webkitdirectory | 提供降级方案,改用压缩包上传并解压 |
| 分块上传速度比预期慢 | 并发过小或分块过大、带宽受限 | 调整并发数和分块大小,测试不同组合 |
8.2 几个真实场景的经验分享
场景一:部署到Windows Server + IIS后,所有分块一律413。前端调/api/upload/chunk返回413,但初始化接口正常。排查后发现web.config没有配置maxAllowedContentLength,IIS默认值只有30MB。修改配置后立即恢复。这个案例提醒我,在架构阶段就要把部署环境考虑进去,最好在README里写上IIS和Nginx需要调整的参数清单。
场景二:用户上传一个8GB的稀缺视频文件,前1GB上传速度正常,之后速度骤降。排查发现前端并发数是5,分块大小是10MB,请求全部发到同一台服务器的同一块磁盘,形成IO瓶颈。后来把分块改为5MB、并发改为3,速度恢复。这个案例说明,分块大小与并发数的组合需要根据服务器IO能力来定。
场景三:文件夹选择后,前端构建目录树导致页面卡死。原因是一次性遍历了6000多个文件并为每个文件生成了树节点和File对象引用。后来改成先取元数据,不在内存中构建完整树,而是分批上传时再动态解析webkitRelativePath,卡死问题消失。处理文件夹大场景时,建议不要一次把所有文件都塞进树结构。
8.3 日志与监控建议
大文件上传流程周期长、环节多,出问题时如果没有日志,排查会像大海捞针。我建议在后端每个接口的开始和结束都打一行结构化日志,至少包含:请求路径、fileId、分块序号、分块大小、耗时、结果状态。分块上传接口的日志可以记录到独立日志文件,避免与其他业务日志混在一起。
如果项目已经接入了日志平台(如ELK或类似系统),把fileId作为追踪维度,可以方便地查看某一次上传任务的完整链路。一个文件分块数量再多,通过fileId也能把相关日志全部串起来。
客户端侧的监控也有必要。JavaScript里可以统计每个分块的上传耗时、重试次数,上传结束后上报一段汇总数据。这样你能知道用户在哪个环节体验最差,从而调整分块参数或提示方式。
9. 最后再分享一个多人协作时的实用技巧
做这类上传功能,最怕的是前后端对不同术语的理解不一致。比如前端说“分块”,后端可能叫“切片”;前端说“合并”,后端叫“整合”。在多人协作时,我建议先花十分钟定义一个统一的术语表,并且把接口字段名写死成双方都能接受的不变名称。字段名的变更,比代码逻辑的变更更难迁移。
另一个容易被忽略的点是:上传接口应该独立于业务接口。不要把上传接口挂在某个业务控制器里,也不要让上传过程强依赖登录用户的其他业务数据。上传功能最好做成一个独立的模块,内部只管文件的“接收-暂存-合并-校验”流程,业务层再去引用最终文件地址。这样以后做断点续传、云存储迁移,都能轻松替换而不用大改业务代码。
我在实际项目中,就是按这样的结构来组织代码:最底层是文件存储抽象层,上层是上传API,再上层是业务回调。调用方只需要等待一个上传任务完成,然后通过任务ID查询文件的存储路径。这样不仅清晰,也让单元测试和并发测试容易得多。
这套方案我从三年前开始用,一步步踩坑修出来的。项目里从几十MB到几GB的文件都不在话下,文件夹导入也稳定跑了大半年。如果你正面临同样的问题,照着上面的思路走一遍,应该能少走很多弯路。如果在实操中遇到别的坑,欢迎回来交流。