☰
SpringMVC拦截器实现大附件上传进度实时监控的完整方案
2026/10/1 16:32:39 网站建设 项目流程

大附件上传这件事,做后端的人早晚要面对。前几天有同事跟我吐槽,他们系统里上传一份带附件的业务报表,压缩包两百多兆,用户在页面上点了上传按钮之后干等两分钟没有任何反馈,期间也不敢关页面、不敢刷新,生怕传了一半白传了。等终于看到“上传成功”的提示时,很多人已经点了三次重新上传。这个场景太熟悉了:上传本身并不可怕,可怕的是没有进度反馈。

所以就有了今天这篇教程总结的核心问题——SpringMVC 如何通过拦截器实现大附件上传的进度实时监控。这里先给结论:想在服务端实时拿到上传进度,需要自定义 MultipartResolver + ProgressListener 采集数据,HandlerInterceptor 管理进度上下文,再配合一个查询进度的 Controller 接口,前端轮询或者用 upload.onProgress 实时展示。接下来我会把完整思路、代码、踩过的坑都展开讲讲,适合正在维护传统 SSM 项目、SpringMVC 老工程,或者想给上传功能补进度条的开发者参考。

1. 整体设计与思路拆解:为什么“在拦截器里读上传流”是个坑

先说一个很多教程不会告诉你的知识点:SpringMVC 的 HandlerInterceptor 其实拿不到原始上传请求流。这不怪拦截器,是 DispatcherServlet 的执行顺序决定的。

1.1 DispatcherServlet 的执行顺序:multipart 解析在拦截器之前

SpringMVC 的核心入口是 DispatcherServlet,它处理请求的 doDispatch 方法里,有这么几行关键逻辑:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { HttpServletRequest processedRequest = request; HandlerExecutionChain mappedHandler = null; // 1. 先检查是不是 multipart 请求,如果是就提前解析 processedRequest = checkMultipart(request); // 2. 再根据请求找对应的 Handler 和拦截器链 mappedHandler = getHandler(processedRequest); // 3. 最后才执行拦截器的 preHandle if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } // ... 调用 Controller }

也就是说,当你的拦截器 preHandle 方法执行的时候,CommonsMultipartResolver 已经把请求体里所有的文件流读完、拆好、封装成 MultipartFile 了。此时你去 request.getInputStream() 读数据,读到的只会是空流或者残留数据。这就是为什么网上有人说“我用拦截器怎么都拿不到进度”的原因——不是代码写得不对,是整个方案在架构上就选错了地方。

那么正确的做法是什么?来捋一下完整的数据链路:

  • 浏览器提交 multipart/form-data 请求
  • DispatcherServlet 的 checkMultipart 调用 MultipartResolver
  • 自定义的 MultipartResolver 在解析过程中,通过 FileUpload 的 ProgressListener 实时收到已读字节数
  • ProgressListener 把进度写入内存缓存(按会话或 requestId 标识)
  • HandlerInterceptor 在 preHandle 阶段初始化进度记录,在 afterCompletion 阶段清理记录
  • 浏览器侧通过进度查询接口拿到当前百分比,实时渲染进度条

所以真正的“读字节”活是在解析器层面干的,拦截器只是在旁边管进度数据的生命周期。但整个方案里拦截器确实扮演了重要角色:没有它做统一清理,进度缓存会在内存里越积越多。

1.2 三种可选方案的对比

我实际调研和试做时,把市面上的方案整理成了三类,各有利弊。

方案一:自定义 MultipartResolver + ProgressListener(本教程主方案)

  • 优点:能在服务端精确统计每个请求的进度,不依赖前端是否支持 upload 事件;前后端分离、手机端、第三方客户端都能复用同一个进度查询接口。
  • 缺点:实现代码稍多,需要理解 SpringMVC 的 multipart 解析机制;对并发场景需要设计好进度存储结构。

方案二:Filter 层面包装 request 流统计字节

  • 优点:能获取最原始的请求流,可控性最强。
  • 缺点:需要自己处理 multipart 格式切分,工作量比较大;包装后的流要保证能被后方的 MultipartResolver 正确解析,稍有不慎会把整个上传流程搞崩。

方案三:前端拦截器 / XHR 拦截进度事件

  • 优点:实现成本极低,浏览器原生支持;axios 里配一个 onUploadProgress 回调就能拿到上传进度。
  • 缺点:只有浏览器端能用;如果是 App 端、桌面端、或者你需要把进度存在服务端供审计、断点续传使用,这个方案就不够了。

从投入产出比看,大多数需要做“大附件上传实时监控”的项目,真正值得采用的是方案一加方案三结合:后端负责准确统计并暴露进度接口,前端负责渲染。

2. 核心细节解析:自定义 MultipartResolver 与进度监听器实现

这一章要动手了。我用的是 Spring 4/5 常用的配置方式,基于 CommonsMultipartResolver(底层是 Apache Commons FileUpload)。

2.1 依赖与基础配置

无论你是老的 web.xml 配置方式还是 JavaConfig 配置方式,都需要保证项目里有 commons-fileupload 依赖。Maven 里这么加:

<dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.4</version> </dependency>

然后在 SpringMVC 配置文件里注册一个自定义的 multipartResolver。这里有个细节:bean 的名称必须叫multipartResolver,大小写都别改,DispatcherServlet 是按这个固定名称去查找的。

<bean id="multipartResolver" class="com.example.upload.progress.ProgressMultipartResolver"> <property name="maxUploadSize" value="1073741824" /> <property name="maxInMemorySize" value="10485760" /> <property name="defaultEncoding" value="UTF-8" /> </bean>

如果你是纯 JavaConfig 的项目,这么写:

@Configuration public class UploadConfig { @Bean(name = "multipartResolver") public CommonsMultipartResolver multipartResolver() { ProgressMultipartResolver resolver = new ProgressMultipartResolver(); resolver.setMaxUploadSize(1024 * 1024 * 1024L); // 1GB resolver.setMaxInMemorySize(10 * 1024 * 1024); // 10MB resolver.setDefaultEncoding("UTF-8"); return resolver; } }

maxUploadSize 是单次请求总大小上限,maxInMemorySize 表示小于该阈值的文件先放内存,超过就落临时盘。大附件场景建议把 maxInMemorySize 设小一点,别让几百兆文件都堆内存里。

2.2 核心:ProgressMultipartResolver 如何实时上报进度

CommonsMultipartResolver 继承自 FileUpload 的解析逻辑,它支持通过setProgressListener挂一个监听器。关键在于:这个监听器必须在每次解析请求的时候重新设置一次,因为 ProgressListener 是跟着每个解析实例走的。

直接上代码:

package com.example.upload.progress; import org.apache.commons.fileupload.ProgressListener; import org.springframework.web.multipart.commons.CommonsMultipartResolver; import javax.servlet.http.HttpServletRequest; import java.util.Date; public class ProgressMultipartResolver extends CommonsMultipartResolver { private UploadProgressCache progressCache; public void setProgressCache(UploadProgressCache progressCache) { this.progressCache = progressCache; } @Override public MultipartParsingResult parseRequest(HttpServletRequest request) { String uploadId = resolveUploadId(request); final UploadProgress progress = progressCache.newProgress(uploadId); // 这里就是文件上传进度实时回调的来源 this.getFileUpload().setProgressListener(new ProgressListener() { @Override public void update(long pBytesRead, long pContentLength, int pItems) { progress.setBytesRead(pBytesRead); progress.setContentLength(pContentLength); progress.setItems(pItems); progress.setLastUpdateTime(new Date()); } }); return super.parseRequest(request); } private String resolveUploadId(HttpServletRequest request) { // 优先取请求参数里的 uploadId,没有就用 sessionId 兜底 String uploadId = request.getParameter("uploadId"); if (uploadId == null || uploadId.isEmpty()) { uploadId = request.getSession().getId(); } return uploadId; } }

需要注意:setProgressListener在 Spring 5 的 CommonsMultipartResolver 上依然可用,因为它内部持有的FileUpload对象就是 commons-fileupload 的原生对象。如果你用的 javax.servlet 版本较新,CommonsMultipartResolver 可能会抛兼容性警告,但通常不影响使用。

2.3 进度存储对象:怎么设计才能抗住并发和内存泄漏

进度数据不能放在普通成员变量里,因为每次上传请求都会触发 ProgressListener 的 update 回调,这是多线程并发的。我用了一个简单的缓存类:

package com.example.upload.progress; import java.util.concurrent.ConcurrentHashMap; public class UploadProgressCache { // uploadId -> progress private final ConcurrentHashMap<String, UploadProgress> progressMap = new ConcurrentHashMap<>(); public UploadProgress newProgress(String uploadId) { UploadProgress progress = new UploadProgress(uploadId); progressMap.put(uploadId, progress); return progress; } public UploadProgress get(String uploadId) { return progressMap.get(uploadId); } public void remove(String uploadId) { progressMap.remove(uploadId); } }

UploadProgress 本身的属性包括:

package com.example.upload.progress; public class UploadProgress { private String uploadId; private volatile long bytesRead = 0; private volatile long contentLength = -1; private volatile int items = 0; private volatile java.util.Date lastUpdateTime; // 构造器、getter、setter 省略 public int getPercent() { if (contentLength <= 0) { return 0; } return (int) (bytesRead * 100 / contentLength); } }

为什么 bytesRead 和 contentLength 要用 volatile?因为 ProgressListener 回调线程和 Controller 请求线程不是一个线程,进度接口读取时需要保证可见性。虽然我们可以通过 ConcurrentHashMap 保证集合线程安全,但对象内部字段的值不加 volatile,读取端可能长时间看不到更新。

2.4 HandlerInterceptor 的定位:进度上下文管理和清理

既然拦截器不是用来读流的,那它在整个方案里到底干什么?答案是:统一管理进度记录的生命周期。

我写了一个 UploadProgressInterceptor 继承 HandlerInterceptorAdapter:

package com.example.upload.progress; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class UploadProgressInterceptor extends HandlerInterceptorAdapter { private UploadProgressCache progressCache; public void setProgressCache(UploadProgressCache progressCache) { this.progressCache = progressCache; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 对上传请求,提前建立一条进度记录,避免前端轮询时查不到 if (isMultipartRequest(request)) { String uploadId = resolveUploadId(request); if (progressCache.get(uploadId) == null) { progressCache.newProgress(uploadId); } } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 接口处理完,移除进度记录,防止内存泄漏 if (isMultipartRequest(request)) { String uploadId = resolveUploadId(request); progressCache.remove(uploadId); } } private boolean isMultipartRequest(HttpServletRequest request) { return request.getContentType() != null && request.getContentType().toLowerCase().startsWith("multipart/"); } private String resolveUploadId(HttpServletRequest request) { String uploadId = request.getParameter("uploadId"); if (uploadId == null || uploadId.isEmpty()) { uploadId = request.getSession().getId(); } return uploadId; } }

这里有一个很重要的取舍:afterCompletion 里直接删除进度记录,意味着前端必须在上传请求完全结束前拿到最终进度。如果上传完成后前端还要查一次“100%”,就会查不到。我的实践方案是:前端在上传完成后不要立即去查,而是直接把进度条置为 100%,或者进度接口在记录不存在时返回 100 也行。你也可以把清理工作交给一个定时任务延迟执行,那样更稳但代码会复杂一些。

拦截器还有一个隐藏好处:在做这个功能的同时,你可以在 preHandle 里顺手做登录校验、上传频率控制、文件类型拦截。很多人会忽略,大附件上传接口如果没有鉴权,很容易被拿来做流量攻击,进度接口被人轮询也会导致缓存膨胀。

推荐在 SpringMVC 配置里这样注册拦截器:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/upload/**" /> <bean class="com.example.upload.progress.UploadProgressInterceptor"> <property name="progressCache" ref="uploadProgressCache" /> </bean> </mvc:interceptor> </mvc:interceptors>

JavaConfig 方式也一样,重写 addInterceptors 方法即可。

3. 实操过程与核心环节实现:完整的可运行示例

思路理顺了,代码就好写。我直接提供一个最小可用的完整前后端示例,你可以照着改。

3.1 后端:上传接口与进度查询接口

Controller 需要两个方法,一个处理真正的大附件上传,一个返回进度。

package com.example.upload.controller; import com.example.upload.progress.UploadProgress; import com.example.upload.progress.UploadProgressCache; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartHttpServletRequest; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.UUID; @Controller @RequestMapping("/upload") public class UploadController { @Resource private UploadProgressCache progressCache; @PostMapping("/file") @ResponseBody public Map<String, Object> uploadFile(MultipartHttpServletRequest request) throws IOException { String uploadId = resolveUploadId(request); MultipartFile file = request.getFile("file"); if (file == null || file.isEmpty()) { throw new IllegalArgumentException("file is empty"); } // 实际项目中这里可以分片存储 / 转存 OSS / 做病毒扫描等 File dest = new File("/data/upload", UUID.randomUUID().toString() + "_" + file.getOriginalFilename()); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); Map<String, Object> result = new HashMap<>(); result.put("success", true); result.put("filePath", dest.getAbsolutePath()); result.put("size", file.getSize()); result.put("uploadId", uploadId); return result; } @GetMapping("/progress") @ResponseBody public Map<String, Object> progress(HttpServletRequest request) { String uploadId = resolveUploadId(request); UploadProgress progress = progressCache.get(uploadId); Map<String, Object> result = new HashMap<>(); if (progress == null) { // 没有记录时返回 100,避免前端一直空转 result.put("percent", 100); result.put("bytesRead", -1); result.put("contentLength", -1); result.put("finished", true); return result; } result.put("percent", progress.getPercent()); result.put("bytesRead", progress.getBytesRead()); result.put("contentLength", progress.getContentLength()); result.put("finished", progress.getBytesRead() >= progress.getContentLength()); return result; } private String resolveUploadId(HttpServletRequest request) { String uploadId = request.getParameter("uploadId"); if (uploadId == null || uploadId.isEmpty()) { uploadId = request.getSession().getId(); } return uploadId; } }

有两个点需要说明:

  1. 进度接口的解析规则必须和拦截器、MultipartResolver 完全一致,最好抽成同一个工具方法,避免前端传一个 uploadId,三个地方三种取值逻辑。
  2. 如果项目用了 Shiro、Spring Security,记得给/upload/progress放行或者走权限接口,否则前端拿不到进度。

3.2 前端:原生 XMLHttpRequest 的 onUploadProgress 方案

现在前端有很多成熟的上传组件,但为了讲清楚原理,我先用原生方式写一个。核心是 XMLHttpRequest 自带的上传事件。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>大附件上传进度</title> </head> <body> <input type="file" id="fileInput" /> <button id="uploadBtn">上传</button> <div> <span>进度:</span> <span id="progressText">0%</span> <div style="width: 300px; height: 20px; background: #eee; border-radius: 10px; margin-top: 10px;"> <div id="progressBar" style="width: 0%; height: 100%; background: #4CAF50; border-radius: 10px;"></div> </div> </div> <script> const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const progressText = document.getElementById('progressText'); const progressBar = document.getElementById('progressBar'); function getUploadId() { // 简单生成一个随机上传ID,实际可以用 UUID return 'up_' + Date.now() + '_' + Math.floor(Math.random() * 10000); } uploadBtn.addEventListener('click', function () { const file = fileInput.files[0]; if (!file) { alert('请选择文件'); return; } const uploadId = getUploadId(); const formData = new FormData(); formData.append('file', file); formData.append('uploadId', uploadId); const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload/file'); let finishedShown = false; // 浏览器底层上报的上传字节数事件,非常精准 xhr.upload.onprogress = function (event) { if (event.lengthComputable) { const percent = Math.round(event.loaded * 100 / event.total); progressText.textContent = percent + '%'; progressBar.style.width = percent + '%'; } }; xhr.onload = function () { if (xhr.status === 200) { // 后端读到的进度可能因为网络缓冲存在差异,最终直接置为 100 progressText.textContent = '100%'; progressBar.style.width = '100%'; console.log('上传成功', xhr.responseText); } else { alert('上传失败:' + xhr.status); } }; xhr.onerror = function () { alert('网络错误,上传中断'); }; xhr.send(formData); }); </script> </body> </html>

这也是我开头说的“方案三”:如果你想快速上线,不做服务端进度查询,这一段就完全够用了。但如果你还需要服务端留痕、上传进度的跨端统一展示,那就必须走进度接口。建议的做法是:页面显示进度的主数据源用后端进度接口,同时用 upload.onprogress 作为兜底,哪个数据先到位就用哪个。因为 upload.onprogress 是网络层的数据,而服务端的 ProgressListener 是后端真正读到的字节数,两边在极少数场景下会略有差异。

3.3 前端:定时轮询后端进度接口

当你需要把进度上报给多个端(比如用户上传中,管理员后台也看得到进度),就采用轮询后端进度接口的方案:

let pollTimer = null; function startPolling(uploadId) { pollTimer = setInterval(function () { fetch('/upload/progress?uploadId=' + uploadId) .then(res => res.json()) .then(data => { const percent = data.percent || 0; progressText.textContent = percent + '%'; progressBar.style.width = percent + '%'; if (data.finished || percent >= 100) { clearInterval(pollTimer); pollTimer = null; } }) .catch(err => { // 轮询失败不阻塞上传,打日志即可 console.warn('进度查询失败:', err); }); }, 500); } // 点击上传时,同时启动轮询 uploadBtn.addEventListener('click', function () { // ... 前面构造 formData 的同名代码 startPolling(uploadId); xhr.send(formData); });

轮询间隔建议 500ms 到 1s。设置得太短(比如 100ms)会徒增服务器压力;设置得太长(比如 3s)会显得进度条一跳一跳的,不连贯。

3.4 联调验证的完整过程

为了让读者快速验证这个方案,我把联调流程写清楚:

  1. 启动项目,准备一个 200MB 以上的测试文件。
  2. 打开浏览器控制台 Network 面板,勾选 “All”,找到上传请求。
  3. 点击上传按钮,观察 Network 里请求的进度状态,浏览器会显示发送字节数。
  4. 同时打开另一个标签页,手动访问/upload/progress?uploadId=up_xxx,观察 JSON 返回的 percent 字段是否递增。
  5. 查看后端控制台日志,确认 ProgressListener 的 update 方法被频繁调用。

如果一切正常,你会看到进度值不是从 0 跳到 100 的,而是稳定递增。数据量越大,递增过程越明显。

4. 常见问题与排查技巧实录

这个方案我在几个项目里落地过,也帮同事排查过不少问题,挑几个有代表性的写下来。

4.1 上传一半直接报 413 或者请求被中断

大附件上传最常见的问题是“文件明明在配置里设置过了,怎么还是传不上去”。这里有一个容易被忽略的链条:

  • Spring 层的 maxUploadSize 只是第一道关卡
  • Tomcat 的 maxPostSize 有第二道限制
  • 如果前面挂了 Nginx,client_max_body_size 又是第三道限制

排查顺序建议是:

检查点默认值排查命令/位置
Spring maxUploadSize通常不配置则默认 2MBSpringMVC 配置文件里的 multipartResolver
Tomcat maxPostSize2MB(老版本)/ 新版无限制server.xml 中<Connector maxPostSize="...">
Tomcat maxSwallowSize2MB如果“吞”剩余 body 失败会抛 IOException
Nginx client_max_body_size1MBnginx.conf 中client_max_body_size 2048m;

我遇到过一个场景:本地开发上传 500MB 没问题,一到测试环境就报错,最后发现是 Nginx 配置没更新。所以排查时先看请求是到没到后端,再决定查哪一层。

4.2 拦截器里读 request.getInputStream() 报错或者读不到数据

这个问题我前面已经说了,就是因为 DispatcherServlet 在拦截器之前完成了 multipart 解析。这里再补充一种情况:如果你在拦截器里尝试读流,有可能会得到 -1,也就是流已经关闭。这不是代码 bug,是架构选型错了。

有读者问:那我能不能在拦截器里把request.getAttribute("file")拿出来,再统计大小?可以,但此时文件已经读完了,压根不是“实时进度”,只能算“事后确认文件大小”,没有意义。

4.3 进度一直卡在 99% 不结束

这个现象通常不是因为传输卡住,而是 ProgressListener 收到的contentLength和实际传输量之间有一个微小的差距。比如浏览器发送的 multipart body 里除了文件二进制以外,还有一段 form-data 的字段头和结尾的 boundary 标记,这些字节也会被读入。某些情况下 listener 读到的 bytesRead 会大于或略小于原始的 contentLength 字段。

可以参考 FileUpload 源码里的逻辑:在update回调里,当pItems已经等于总数且 bytesRead >= contentLength 时,就视为完成。如果进度接口只看 bytesRead / contentLength,就可能出现在 99% 上不再动弹。稳妥的处理是加一个阈值:当 percent >= 99 且距离上一次 update 超过 3 秒,就返回 100% 或者触发一次强制完成标记。

4.4 多用户并发上传,进度互相覆盖

如果你直接用 sessionId 作为进度的 key,而又恰好同浏览器没开新 session,那多个上传任务会互相覆盖。解决办法就是引入uploadId,由前端每次上传前生成一个唯一 ID,后端把它当成进度 key。

还有一个容易踩的坑:上传接口和进度接口如果在前后两次请求里从 request.getParameter 拿 uploadId,会出现 uploadId 在 GET 请求里能拿到、在 POST 上传请求里也能拿到,但如果你不小心在解析完 multipart 之后再去拿,部分 Servlet 容器可能已经无法读取参数。所以我都建议在 MultipartResolver 的 parseRequest 里、拦截器的 preHandle/afterCompletion 里、Controller 三个地方用同一个工具方法解析,保证逻辑一致。

4.5 Spring Boot 项目里配置了 CommonsMultipartResolver 但一直不生效

如果你在 Spring Boot 2.x / 3.x 里直接引入CommonsMultipartResolver,要小心一个事:Spring Boot 的MultipartAutoConfiguration默认注册了StandardServletMultipartResolver,且 DispatcherServlet 只认名为multipartResolver的 bean。当你自己定义同名 bean 时,Boot 默认的 StandardServletMultipartResolver 会被覆盖。理论上可行,但 commons-fileupload 1.4 在较新的 Servlet 容器上会报ServletFileUpload.isMultipartContent的兼容性警告。如果遇到这种问题,建议:

@Bean(name = "multipartResolver") public CommonsMultipartResolver multipartResolver() { ProgressMultipartResolver resolver = new ProgressMultipartResolver(); resolver.setMaxUploadSize(...); return resolver; }

同时手动排除 Boot 的 MultipartAutoConfiguration,或者干脆用 Servlet 3.0 的 Part API 配合写监听,后者更贴近新版本容器。

5. 额外补充:拦截器还能顺手做的几件事

既然已经上了拦截器,就不要浪费这个位置。在大附件上传场景下,preHandle 里可以做几件性价比很高的事:

第一:登录与上传权限校验。上传接口是最容易被恶意刷流量的接口,大附件更是如此。在 preHandle 里校验登录态和用户上传配额,符合预期才放行。

第二:上传频率限制。同一个用户 30 秒内只允许创建一个上传任务,可以避免客户端脚本疯狂开任务拖垮带宽。我通常会用一个简单的 ConcurrentHashMap 记录上次发起上传的时间戳。

第三:统一初始化进度记录的同时,写入一个业务上下文。有些项目还有“上传完成后触发回调任务”的需求,可以在 afterCompletion 里触发,而不是在 Controller 里做,这样业务逻辑更内聚。

不过要注意,拦截器本身不太适合做耗时操作。如果你在 preHandle 里做数据库查询、远程鉴权等重操作,会拖慢整个上传请求的进入时机。进度监控的工具类里尽量都用内存操作。

6. 最后分享一点个人体会

我个人在实际项目里的建议是:如果产品只要求浏览器端看起来有进度条,前端 onUploadProgress 就够了,不要再额外加后端进度接口,尽量避免为了一个简单需求引入过多复杂度。但如果你已经遇到了“需要把上传进度同步给其他端”“需要留痕上传过程”“要做断点续传的进度计算基础”,那今天这套方案就是绕不开的基础设施。

这套方案跑起来之后,你会发现一个额外的惊喜:因为 ProgressListener 里的 bytesRead 本身就是一步步增长的,你可以在它基础上顺手做上传速率统计——拿两次回调之间的字节差除以时间差,就能得到实时的 Mbps 数值。我在做文件传输工具时加了这个功能,运维很满意,排查网络问题的时候能一眼看出带宽是不是被占满了。

做这类功能有一个底线原则:进度监控本身不能影响上传性能。它的代码要足够轻,不能做磁盘 IO、不能做远程调用、尽量不打印大段日志。因为每一次 ProgressListener 回调都发生在解析线程里,如果你在里面写日志,几百兆文件上传时日志文件先爆掉是小事,拖慢上传速度才是大问题。我用过一段 debug 日志做验证,最后发现上传耗时增加了近一倍,果断全部去掉。好的进度组件应该是“你看不见它,但它一直在安静地工作”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询