ARTICLE DETAIL

资讯详情

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

改造WebUploader:军工级大文件分片上传与断点续传实践

改造WebUploader:军工级大文件分片上传与断点续传实践 1. 军工业务环境里的文件上传跟“做网盘”完全是两码事1.1 卫星视频文件到底有多大前端面对的不只是体量先说一个现实军工行业里的“卫星视频上传”不是你在内部办公系统里传个培训录像那么简单。项目里我遇到的实际业务文件单段下来动辄几个GB而且格式很杂mp4、m2ts、ts、avi、dat都有部分记录文件还带配套的索引文件和时间戳文件。也就是说我们要做的不只是一个“大文件上传器”而是一套能和现有资料归档流程打通的数据入库通道。体量一大很多前端惯用做法就失效了。普通方案里常见的“整个文件放进FormData一次性POST”在浏览器层面对几十GB的文件基本没有任何可行性读取阶段就会把内存打爆更不要提网络抖动带来的全程重传成本。分片是唯一正确的路子这点大家应该都能认同。但分片怎么切、切完怎么保证每一片都被服务端收到、客户端崩溃或者网络断开之后如何继续这些才是真正拉开方案差距的地方。而且军工业务还有个很特殊的点文件通常不是给人看的视频素材而是带业务属性的原始记录上传失败导致数据缺损后续分析工作会直接受影响。1.2 内网传输链路远比外网脆弱我在这类系统上踩过一个大坑内网环境并不天然比公网稳定。系统内部可能串着网闸、隔离设备、审计网关某些链路出口带宽只有几兆再加上安全设备会周期性休眠或做会话回收长连接和长时间占用的大包传输经常被掐断。TCP层断一下没关系应用层如果不知道已经断了用户看到的就是上传进度卡在65%再也不动最后页面超时整包重来。外网产品常见的“断点续传”大多停留在产品层面用户手动点重新上传客户端把同一文件重新选一遍服务端按文件名查一下是否传过若有就直接返回成功。这种近似“假续传”的方案在军工场景完全不合格因为文件不是用户手动上传的照片它会持续产生、按批次入库同一个文件可能由不同终端在不同时间段重复提交同时卫星视频数据非常强调完整性和可校验性“传过了就跳过”不能替代“所有分片都真实落在存储里且能通过完整性校验”。1.3 军工现场浏览器环境的复杂程度还有一个所有做大型政企系统的人都懂的问题用户侧的浏览器环境完全是“自由生长”的。去现场之前我列过Chromium、Edge、Firefox这些现代浏览器结果到了内网机房里发现大量终端还在用老版本安全浏览器甚至有人开着IE内核的兼容模式访问应用。不是他们不想升级而是这些机器上有第三方控件和业务系统不能随便动。所以“跨浏览器”这个需求在军工系统里是硬指标。在这种环境里如果还指望WebUploader原版那种“优先HTML5不支持就退Flash”的方案等于把自己架在火上烤。Flash在2020年后已经彻底失去生态支持军工终端又不会随意安装第三方插件Flash这条退路等于没有。真正要解决的是让基于HTML5 File API的分片上传逻辑在五花八门的浏览器内核里都能稳定工作。2. 为什么最后选了WebUploader来做改造而不是另起炉灶2.1 先给结论选型对比里没有完美答案标题里说的是“改造WebUploader”可能有人会问WebUploader已经很老了百度也不维护了为什么不直接用axios手写分片或者套用商业组件我当时做的对比是这样的方案队列调度能力断点续传基础二次开发成本离线内网部署最终判断WebUploader原版有完整队列和并发调度几乎没有安全的断点续传中等需要大改友好可全本地打包主体骨架可留用axios/自研分片全部要自己写也要自己从零设计高至少多开发两周友好工程量大容易返工商业大文件组件较好较好低但定制受限制很多依赖云端授权不适合隔离内网使用WebUploader虽然停止更新但它的队列模型、事件机制、分片请求结构并没有过时代码写得也足够规整。我们需要补的不是“怎么把文件切成片”而是“切完的请求怎么在异常环境下可靠地跑完”。2.2 改造边界哪些代码留用哪些必须推倒接手后我先把最终要保留的部分划清楚了文件选择、拖拽、队列面板、错误提示、并发线程数控制这些“上层交互”WebUploader已经做得很好全部保留。请求层的基础封装Transport也保留因为它兼容了XMLHttpRequest和自定义xhr能拿到每一片的独立进度事件。真正要推翻的是三块Flash runtime无条件移除。原始的“随机文件名无状态上传”逻辑必须替换成“文件指纹服务端状态校验”的协议。本地不做任何持久化的队列恢复机制要补上断点快照和服务端状态同步。这个边界非常重要。如果不划清边界很容易陷入“在旧代码的Bug上修修补补”的泥潭。例如WebUploader里有些事件只在特定runtime下触发如果不动运行时抽象光是排查事件丢没丢就能耗掉一整天。2.3 读源码时最值得花时间的几个文件改造之前我逼着自己把WebUploader的源码认真读了一遍。它本身是一个打包后的卷宗核心类都在webuploader.js这一个文件里刚开始看会有点晕但拆开就简单了Uploader类是外观入口所有对外的方法和事件都集中在这层。RuntimeClient负责跟底层能力打交道HTML5模式下封装了File、Blob、XMLHttpRequest这些对象。lib/file和lib/transport是队列与发送的底层实现分片上传的主链路就在这里。想改造断点续传就必须搞清楚Transport是怎么发送请求的。我可以给你一个阅读入口在Uploader的startUpload方法里找到request(upload, file)再顺着找transport的事件绑定。看明白“一个分片对应一个XMLHttpRequest”这个事实之后后面所有重试、进度统计、跳过已传分片都变得好实现了。3. 分片链路靠得住但“刷新后断点续传”基本等于零3.1 原版分片请求到底是怎么发出去的WebUploader开启分片很简单WebUploader.create({ server: /api/satellite/video/upload, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3 });设置了chunked和chunkSize之后源码会在上传时把File对象按字节区间切段对每段创建一个transport请求。每个请求除了携带真正的二进制Blob之外还会带几个约定好的字段比如文件名、总片数、当前片序号、文件大小等。服务端收到之后按片落盘等所有片都到了再合并。这套分片逻辑本身是成熟的。它解决了“一次性读取大文件”的内存问题也通过threads参数控制并发不会把内网带宽吃满。很多做上传的朋友自己从零写写到最后也就是这么一套东西而且不一定有WebUploader这么健壮的队列状态管理。3.2 一个致命缺陷刷新后队列就“失忆”了但原版方案里分片请求的状态全部存在内存里。用户上传到一半页面一刷新uploader实例销毁队列里的文件列表、已完成分片序号、失败记录全部清空。再打开页面用户必须重新选文件、重新走上传流程服务端虽然已经收到了一堆分片却没有办法告诉前端“你其实已经传了43/120片”。更头疼的是原版对“文件是否同一文件”的判断基本靠文件名。业务人员把“卫星01_轨道段A.mp4”改名成“轨道段A_final.mp4”再传一遍服务端根本认不出来于是重复占用存储空间。对普通网盘来说这只是一个存储优化问题但军工行业有严格的归档和审计要求重复录入会导致后续数据管理出现麻烦。3.3 改造前先定好断点协议别急着写代码做断点续传最忌讳一上来就写前端代码。我建议先跟服务端约定好一套“状态查询协议”在动手前把下面这些问题回答清楚文件用什么作为唯一标识文件名、大小、修改时间都不可靠方案是给文件算内容指纹。服务端如何知道前一次上传是否完成需要维护一个以指纹为主键的状态表。分片什么时候可以判定为“已经传过”是按文件名判断还是按“指纹分片序号”判断全部片传完后谁来触发合并哪个接口负责完整性的二次校验同一个用户在多台终端上传同一个文件怎么处理我最后敲定的协议是前端计算文件指纹在上传前先调一个状态查询接口服务端返回“上传完成/上传中/新文件”三种状态若是“上传中”服务端把已经收到的分片序号列表一并返回前端据此跳过这些分片。4. 第一处核心改造给文件算指纹让“之前传没传过”有据可查4.1 指纹算法选型MD5能用于业务去重完整性另算给几十GB的文件做内容寻址业内常用的是MD5但军工环境到处在提国密算法所以这里第一个争论就是用什么算法。我详细算过一笔账。SparkMD5支持流式计算做增量哈希时性能很好。普通机械硬盘上读取速度大约100~150MB/s一个5GB文件全量读完算完大约需要30~60秒如果把计算放在Web Worker里这个时间对用户体验完全可接受。如果改用国密SM3目前前端常用库sm-crypto对全局数据一次性计算的支持还可以但对“边读边算”的流式支持并不好几十GB数据如果全部读进内存再算浏览器必然崩溃。所以最终方案是前端用MD5做文件指纹用途只限定在“去重和断点定位”不把MD5当成完整性校验依据文件真正落库后的完整性校验由服务端在合并完成后用SM3再做一次并把这个校验值写入归档记录。前端负责“快速认出文件”服务端负责“最终确认文件没坏”两边各司其职。4.2 计算过程放在Worker线程别堵住上传线程前端算大文件指纹时最容易犯的错是把FileReader放在主线程里。几GB的文件即使分块读也会让页面卡顿用户觉得浏览器死了。必须把读取和计算放进Web Worker。工程上的实现可以这样做。先写一个worker脚本接收主线程传过来的File对象然后按2MB一段循环使用FileReaderSync读进ArrayBuffer再灌进SparkMD5的ArrayBuffer实例// spark-md5-worker.js importScripts(/static/vendor/spark-md5.min.js); self.onmessage function (e) { const file e.data.file; const chunkSize 2 * 1024 * 1024; const spark new self.SparkMD5.ArrayBuffer(); let start 0; try { while (start file.size) { const end Math.min(start chunkSize, file.size); const buffer new FileReaderSync().readAsArrayBuffer(file.slice(start, end)); spark.append(buffer); start end; self.postMessage({ type: progress, loaded: start, total: file.size }); } self.postMessage({ type: done, fingerprint: spark.end() }); } catch (err) { self.postMessage({ type: error, message: err.message }); } };主线程里这样调度function calcFileFingerprint(file) { return new Promise(function (resolve, reject) { const worker new Worker(/static/spark-md5-worker.js); worker.onmessage function (e) { const data e.data; if (data.type done) { worker.terminate(); resolve(data.fingerprint); } else if (data.type error) { worker.terminate(); reject(new Error(data.message)); } }; worker.onerror function (err) { worker.terminate(); reject(err); }; worker.postMessage({ file: file }); }); }这里有个容易被忽略的点不要在主线程里反复用postMessage把分片Buffer传给Worker。File对象本身可以通过结构化克隆传到Worker之后让Worker自己用FileReaderSync按需读取比每读一块就跨线程传一次Buffer高效得多内存占用也小。4.3 浏览器不支持Worker时的回退方案军工现场浏览器版本老保不齐有不开Worker的情况。这时不能用FileReaderSync只能在主线程里用FileReader异步分块计算并且每读完几块就主动setTimeout让出主线程避免长任务卡死页面。我把两套逻辑封在同一个函数里用能力检测做分支对上层透明const canUseWorker typeof Worker ! undefined typeof FileReaderSync ! undefined;不要一上来就判断“不支持Worker就拒绝上传”用户体验会非常差。退一步说即使是老浏览器只要能用分片上传产品的核心价值就保住了指纹只是一个增强能力。4.4 指纹算完后的状态查询决定了后面所有路径指纹算出来之后下一步是调状态查询接口。这里强烈建议不要走“文件已存在就直接提示成功”的简单逻辑而要返回更细致的状态{ code: 0, data: { status: uploading, uploadedChunks: [1, 2, 3, 5, 8], totalChunks: 120, storedFingerprint: 0b2e1a3c... } }前端需要面对三种局面status为complete可直接提示“秒传成功”本地上传流程直接结束。status为uploading服务端已经有一部分分片前端只传缺失的分片。status为new全新任务从头开始传。为什么要返回uploadedChunks而不是只返回一个百分比因为在分片不按顺序完成、偶发丢片的情况下前端需要精确知道哪些片缺失。只给一个“已传43%”前端还是要瞎猜。5. 第二处核心改造把“继续传”变成可执行的任务队列5.1 改交互相前先想清楚选完文件后别急着入队熟悉WebUploader的人知道默认流程是用户选完文件立即触发fileQueued事件之后进入上传队列。但在有断点续传的场景里如果文件一入队就开传指纹还没算完状态也没查完根本不知道哪些分片要跳过于是就会发生“从头重传”的尴尬。解决办法是在选择文件后先拦住默认入队流程把File对象暂存到一个待处理列表里。等指纹算完、服务端状态查询返回之后再把文件真正加入上传队列并触发上传。代码上我在fileQueued里加了判断如果文件没有经过预处理标记就把它放在pendingMap里不立即调用uploaduploader.on(fileQueued, function (file) { if (!file.__precheckReady) { pendingFiles[file.id] file; runPrecheck(file); } });runPrecheck里面做指纹计算、状态查询拿到结果之后再调用uploader.upload(file)真正开始上传。注意我们要把查询结果挂到file对象上例如file.__uploadState response.data后续分片发送时要用到。5.2 已传分片是“跳过”还是“服务端去重”我选了后者按常理想状态查询返回了uploadedChunks前端就应该跳过传入的分片。但WebUploader没有“跳过列表”这样的内置配置硬要改源码插入拦截逻辑代价高且容易破坏现有调度。我最终采用了更聪明的做法前端照常上传所有缺席的分片但服务端对“指纹分片序号”做唯一索引重复上传的分片直接返回“已存在”不做磁盘写入。这样做好处很明显前端逻辑简单不用跟调度器纠缠。服务端天然幂等即使两个浏览器并发传同一个文件也不会产生分片错乱。前端只管“缺哪片传哪片”上传状态由服务端统一收敛。5.3 断点恢复的关键用本地快照减少一次全量查询实现断点续传时还有一个体验优化点值得做把用户上传中的文件信息快照缓存到localStorage里包括fingerprint、文件大小、totalChunks、服务端返回的uploadedChunks集合。这样页面刷新后前端可以先从localStorage里找到上次的任务快照直接默认这个文件处于“续传”状态后台再异步跟服务端校对一遍。快照的结构大概是这样的{ fingerprint: 0b2e1a3c..., fileName: 轨道A_raw.m2ts, fileSize: 5368709120, totalChunks: 1024, lastUpdateTime: 1704088800000 }localStorage容量有限只存元信息不存分片内容几百字节足够。刷新后用它来恢复界面可以让用户“感觉上传一直在继续”而不是每次都要重新算一遍指纹。5.4 进度条怎么算才不“倒着走”原版WebUploader单文件进度是基于分片请求的实时反馈做的。但到了断点续传场景前50片已经传完现在只传第51到120片进度如果从0%开始显示用户会觉得“我之前传的都白传了”如果直接显示50%新传的分片进度又会把总数拉回50%以下出现进度条倒退的诡异现象。我最后用了一个很朴素但可靠的状态机来处理。每个分片有五种状态pending、uploading、success、failed、skipped。初始化时把服务端返回的uploadedChunks直接标记为success再把其余分片标记为pending。总进度用以下公式计算总进度 已成功分片数 / 总分片数在一个分片完成并置为success后重新计算总进度。这个公式会让进度条呈现“跳跃式增长”5%一下跳到15%再到40%但不会倒退。用户看到的是离散的台阶清晰知道上传在推进。我觉得对于超大文件这种跳变反而比线性进度更真实——用户能明确看到“这一批片传完了”。6. 第三处核心改造并发控制、失败重试与网络波动下的兜底6.1 threads并发数不是越大越好内网环境更要克制WebUploader的threads选项控制上传并发数很多人想当然设成10或者20结果现场出了问题。军工内网链路的带宽和稳定性通常达不到办公楼里千兆光纤的水平一条专线上可能同时还有数据库同步、视频会议、监控回传等业务在抢带宽。我的经验是默认并发设成3遇到稳定专线可以调到5超过5收益就非常有限了。原因很简单超大文件每个分片5MB3个并发同时传内存里最多只保留3个Blob分片的数据占用可控把并发调到10TCP窗口争抢加剧服务端临时文件的打开句柄变多反而更容易触发超时。网络类型建议并发数建议分片大小备注10Mbps以下窄带专线1-22MB减少单请求大包失败概率10-100Mbps专线3-55MB日常推荐配置千兆内网5-810MB带宽足可放大分片减少请求数6.2 原版一失败就整体报错完整排查链路帮你找到根因有一次现场测试传一个6GB文件传到70%左右进度条不动然后直接跳到error状态整个任务中止。最开始我以为是网络断了但ping服务端完全通。复现三次后我做了完整排查第一步在Network面板跟踪请求。发现失败的不是某个特定分片而是“某个时间段内所有分片”几乎同时报错。这立刻排除了单个分片数据损坏的可能更像网络层的批量超时。第二步看服务端日志。发现TCP连接被安全设备重置重置时间点正好是链路空闲超过60秒的时候。我意识到问题不在并发数而在于分片上传长时间没有事件安全设备的会话超时机制把连接清了。第三步针对“空闲会话被回收”问题加了两个处理一是把每个分片请求的超时时间从默认值调大到120秒二是增加传输过程中的心跳保活包在长时间没有分片完成时发送一个轻量请求维持会话。改进后连续上传三个6GB文件均未再复现。这个坑揭示了一个规律内网里“上传失败”的根因经常不在应用层而在链路中间的安全设备。排查时一定要把浏览器、服务端、网络设备的日志串起来看不要只盯前端报错。6.3 失败重试必须做指数退避且要能只重传失败分片WebUploader提供了retry方法但它的粒度是文件级别的。一个文件如果有3个分片失败调用一次retry会把整个文件所有pending和失败的分片都重发一遍窄带环境下浪费严重。我的改造方案是在Transport的请求回调里单独捕获失败状态只对失败的这个分片做重试。具体做法是在beforeSend里给每个分片记录重试次数超过3次就不继续重试把错误抛给上层提醒用户手动干预uploader.on(beforeSend, function (file, data, header) { if (!file.__retryMap) { file.__retryMap {}; } const chunkKey data.chunk; if (!file.__retryMap[chunkKey]) { file.__retryMap[chunkKey] 0; } // 每次请求都带一个自定义header服务端可据此判断第几次重试 header[X-Retry-Count] file.__retryMap[chunkKey]; });在XHR的error回调里file.__retryMap[chunkKey]; const retryCount file.__retryMap[chunkKey]; const delay Math.min(1000 * Math.pow(2, retryCount), 30000); setTimeout(function () { if (retryCount 3) { uploader.retry(file); } else { uploader.trigger(chunkUploadError, file, chunkKey); } }, delay);这里指数退避的底数是2即第一次重试等1秒第二次等2秒第三次等4秒。上限卡在30秒防止网络恢复时间较长时前端频繁做无效重试。6.4 断网/服务端重启后的兜底策略军工内网有时会进行系统维护服务端在一个大文件上传过程中重启前端所有分片请求都会失败。用户看到一堆红色报错然后不知所措。这种情况靠自动重试解决不了因为服务端需要时间恢复。我的做法是监听全局错误当检测到连续多个分片请求因网络错误失败时把上传器状态置为paused并弹出一个可操作的提示“网络连接中断系统正在自动检测服务状态恢复后自动继续上传。”然后前台每10秒轮询一次服务端健康检查接口一旦服务恢复解除暂停调用uploader.upload()继续传。这个设计不复杂但在现场救了很多次场。用户不需要找运维、不需要重新选文件、不用担心已传的片白传体验稳定很多。7. 跨浏览器矩阵、离线交付和国密侧的落地做法7.1 浏览器支持矩阵先做能力探测再做功能分级军工现场浏览器杂我按功能支持情况把浏览器分成了三档级别典型浏览器指纹计算分片上传断点续传A档Chromium 60、Edge 79、Firefox 78ESRWorker加速支持完整支持B档IE11、老EdgeHTML内核主线程分块计算支持基本支持C档使用IE7/8内核的兼容模式不支持不保证不推荐使用对于C档我不建议再做任何Flash回退而是用一个检测页明确告诉用户当前浏览器无法完成大数据文件上传请切换到Chromium内核模式或使用指定的安全浏览器。这比让用户卡在一个永远失败的流程里强得多。能力检测放在上传组件的初始化阶段里不要等用户选了文件才发现不支持const support { file: typeof window.File ! undefined, blobSlice: typeof Blob ! undefined (Blob.prototype.slice || Blob.prototype.webkitSlice || Blob.prototype.mozSlice), xhr2: typeof XMLHttpRequest ! undefined upload in new XMLHttpRequest(), worker: typeof Worker ! undefined }; if (!support.file || !support.blobSlice || !support.xhr2) { renderUnsupportedPage(); }7.2 兼容细节里容易被忽略的三个坑Blob.slice的商标前缀问题。现代浏览器都支持标准slice但某些老版本还要webkitSlice和mozSlice兜底能力检测里要一起判断。localStorage在禁用Cookie/无痕模式下的写入可能抛异常。军工终端的组策略有时会禁用本地存储写快照前必须先try/catch失败时降级为仅依赖服务端状态。Web Worker在部分国产浏览器里虽然存在但importScripts加载本地文件可能受路径影响。部署时不要用相对路径要用相对于网站根目录的绝对路径否则在二级目录部署时Worker会加载失败。7.3 完全离线内网交付的打包要点军工项目部署在隔离网内所有JS依赖必须本地化。我梳理过一份前端资源本地化的交付清单核心是这些WebUploader、SparkMD5、sm-crypto全部下载到本地static目录通过npm或直接script标签引入禁止页面引用任何公网CDN地址。构建出的JS/CSS地图文件map要关掉或者不发布避免源码暴露和不必要的体积。如果系统部署在二级目录上传组件的所有URL都不能写死“/static/...”必须用相对路径或运行时拼接。内网经常有多套系统共用一套前端资源的情况这时要统一版本号避免浏览器缓存了旧版JS导致接口协议对不上。这些看似琐碎的部署问题其实比断点续传本身更容易出故障。很多代码在开发环境跑得好好的一到隔离内网就白屏或者上传失败八成是资源路径或者缓存策略的问题。7.4 国密算法到底该用在哪一层军工环境绕不开国密要求但“国密算法在前端上传插件里怎么落地”这个问题业内存在不少想当然的理解。我把自己的分工方式写出来供参考。SM3负责完整性校验但不在前端全量算。服务端在合并全部分片之后对完整文件用SM3计算摘要作为档案的一部分存下来上传过程中前端传的MD5指纹只用来做去重和断点定位。SM2负责身份认证和数据签名。前端在初始化上传会话时可以通过SM2对“用户标识时间戳文件名”做一次签名把签名结果放到上传请求的header里让服务端校验请求确实来自合法用户防止有人伪造上传接口把人家的系统磁盘塞满。SM4负责链路加密时我建议不要在前端代码里硬编码SM4密钥。密钥应该由密钥管理系统或安全网关统一下发前端只在HTTPS链路之上拿到一个临时会话密钥。前端实在拿不到国密链路能力时也要坚持用HTTPS承载业务数据绝不因为内网而裸奔HTTP。说到底前端上传组件在国密体系里只承担“尽力认证”的职责真正的数据加密还是要依赖网络层和存储层的国密能力。前端要清楚自己的边界别硬造轮子把密钥塞到JS里那反而会变成安全隐患。回头看这次改造我最大的体会是WebUploader虽然已经不怎么更新了但它把“文件对象管理、分片调度、事件机制”这些最吃劲的骨架都搭好了我们不应该轻易抛弃断点续传真正难的根本不是怎么切文件而是怎么定义“这个文件传到什么状态了”的协议。把状态协议想清楚服务端支持幂等查询和幂等写入前端所做的所有事情就只是把状态表里的pending清空而已。如果你也要在类似场景里做超大附件上传建议先画好服务端状态表再回头动JS代码顺序反了后面大概率会返工。
返回列表