一次把 Spring MVC 文件上传参数“查没了”的排查:multipart、Filter 与 request body 的连环坑

📅 2026/7/11 5:00:13 👤 编程新知 🏷️ 技术资讯
一次把 Spring MVC 文件上传参数“查没了”的排查:multipart、Filter 与 request body 的连环坑 1. 背景最近排查了一个老项目里的文件上传问题现象非常反直觉。接口使用multipart/form-data同时上传普通表单字段和文件。看起来只是 token 放置位置不同后端接收到的结果却完全不一样。方式一token 放在 URL 参数中curl--location--requestPUThttp://127.0.0.1:18000/test/upload?token12345678910\--formid123\--formname张三\--formfiles/Users/shepherdmy/Downloads/已生成图像 1 (3).png\--formfiles/Users/shepherdmy/Downloads/已生成图像 1 (2).png这种方式下后端可以正常解析普通参数和文件。方式二token 放在请求头中curl--location--requestPUThttp://127.0.0.1:18000/test/upload\--headertoken: 12345678910\--formid123\--formname张三\--formfiles/Users/shepherdmy/Downloads/已生成图像 1 (3).png\--formfiles/Users/shepherdmy/Downloads/已生成图像 1 (2).png这种方式下后端却接收不到参数和文件。第一眼看上去两种请求都是Content-Type: multipart/form-data都带普通字段也都带文件。唯一差异只是 token 一个放在 query param 里一个放在 header 里。按理说token 放在哪里不应该影响 multipart 参数绑定但实际结果就是不一致。后端接口大致如下PutMapping(/upload)publicvoidtestUploadFile(UserParamparam){log.info(开始上传文件了);log.info(参数{},param);log.info(文件数{},paramnull?0:param.getFiles().size());}参数对象中既有普通字段也有文件字段DatapublicclassUserParam{privateLongid;privateStringuserNo;privateStringname;privateListMultipartFilefiles;}排查过程中还发现同样的 Spring Boot 版本、同样的接口写法有的项目可以正常接收到id/name/files有的项目却完全接收不到。最后定位下来问题不在 Controller 参数写法而在请求进入 Controller 之前multipart/form-data有没有被 Servlet 容器正确解析。这次排查基本属于一层一层往下钻最后把 Spring MVC、Servlet multipart 解析、Filter 执行顺序、request.getParameter(...)的副作用都串起来了。2. Spring MVC 如何解析 multipart/form-dataSpring MVC 处理 multipart 请求的核心链路大致如下DispatcherServlet - checkMultipart(request) - MultipartResolver.resolveMultipart(request) - StandardMultipartHttpServletRequest - request.getParts()关键点是request.getParts()如果项目使用 Servlet 标准 multipart 解析Spring 最终会进入org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest核心代码是CollectionPartpartsrequest.getParts();也就是说Spring MVC 并不是自己手动从输入流中拆 multipart body而是调用 Servlet 容器提供的 multipart 解析能力。以 Tomcat 为例后续调用链路大致是org.apache.catalina.connector.RequestFacade#getParts org.apache.catalina.connector.Request#getParts org.apache.catalina.connector.Request#parseParts如果request.getParts()返回为空后面的 Spring MVC 参数绑定自然也拿不到普通表单字段和文件字段。3. 根因过滤器提前读取了原始 request body项目中有一个过滤器会对原始HttpServletRequest做包装用来缓存请求体。它的目的也很常见解决原始HttpServletRequest#getInputStream()只能读取一次的问题。原始请求流只能读取一次本质上是因为流是有读取位置的。第一次读取会不断推进当前位置第二次再读时位置已经在末尾自然读不到内容。虽然有些流支持reset()但 Servlet 原始输入流并不适合依赖这种方式重复读取所以很多项目会自定义一个 request wrapper把 body 先读到 byte 数组里再从缓存中反复创建输入流。关于这个知识点在之前分享的文章中总结过Spring Boot如何优雅提高接口数据安全性 感兴趣的可以自行跳转查看示例代码如下GetterpublicclassRequestBodyWrapperextendsHttpServletRequestWrapper{privatefinalbyte[]body;publicRequestBodyWrapper(HttpServletRequestrequest)throwsIOException{super(request);bodyStreamUtils.copyToByteArray(request.getInputStream());}OverridepublicServletInputStreamgetInputStream()throwsIOException{finalByteArrayInputStreambyteArrayInputStreamnewByteArrayInputStream(body);returnnewServletInputStream(){OverridepublicbooleanisFinished(){returnfalse;}OverridepublicbooleanisReady(){returnfalse;}OverridepublicvoidsetReadListener(ReadListenerreadListener){}Overridepublicintread()throwsIOException{returnbyteArrayInputStream.read();}};}OverridepublicBufferedReadergetReader()throwsIOException{returnnewBufferedReader(newInputStreamReader(getInputStream()));}}过滤器中无条件包装请求ComponentSlf4jpublicclassBodyTransferFilterimplementsFilter{OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throwsIOException,ServletException{RequestBodyWrapperrequestBodyWrappernull;try{requestBodyWrappernewRequestBodyWrapper((HttpServletRequest)request);}catch(Exceptione){log.warn(requestBodyWrapper Error:,e);}chain.doFilter(Objects.isNull(requestBodyWrapper)?request:requestBodyWrapper,response);}}这类写法对普通 JSON 请求通常能工作但对multipart/form-data非常危险。问题出在 wrapper 构造方法里bodyStreamUtils.copyToByteArray(request.getInputStream());这行代码已经把原始 Tomcat request 的 body 输入流消费掉了。虽然 wrapper 后续重写了getInputStream()getReader()但 multipart 解析时Spring 调用的关键入口是request.getParts()而不是简单调用request.getInputStream()getParts()是 Servlet 容器的 multipart 解析入口。容器解析 multipart 时依赖的是原始 request 的内部状态、connector 输入流、Content-Type、Content-Length、MultipartConfigElement等信息。这个自定义 wrapper 只是提供了一个新的getInputStream()并没有完整接管getParts()getPart(Stringname)getParameter(...)getParameterMap()getParameterValues(...)所以后续 Spring MVC 调用request.getParts()时最终仍然会委托到底层原始 request。此时原始 request 的输入流已经被过滤器提前读完Tomcat 再解析 multipart 时就拿不到内容了。完整失败链路如下请求进入 Filter - new RequestBodyWrapper(originalRequest) - 构造方法读取 originalRequest.getInputStream() - 原始 body 被消费 - 进入 DispatcherServlet - checkMultipart(request) - StandardMultipartHttpServletRequest#parseRequest - request.getParts() - 委托到底层 originalRequest.getParts() - Tomcat 发现底层输入流已被消费 - parts 为空或解析失败 - Controller 绑定不到 id/name/files流程图如下所示这里最容易误判的一点是重写 getInputStream() 只对后续直接调用 wrapper.getInputStream() 的代码有效 Servlet 容器的 getParts() multipart 解析不会自动使用你缓存后的 ByteArrayInputStream。所以结论是正常情况下无论接口是POST还是PUT都可以通过multipart/form-data传递参数和文件但如果在 multipart 解析前有类似BodyTransferFilter的过滤器提前读取了原始 request inputStream就可能导致后续参数和文件无法解析。4. 为什么 token 放 header 里失败放 query param 里反而成功到这里已经能解释“multipart 参数为什么会丢”但还没有解释另一个更迷惑的现象为什么 token 放在 header 中会失败放在 URL 参数中反而成功真正原因不是 header 影响了 multipart而是两种 token 获取方式触发了不同代码路径。项目中有一个登录校验过滤器类似如下ComponentpublicclassAuthFilterimplementsFilter{OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throwsIOException,ServletException{HttpServletRequesthttpServletRequest(HttpServletRequest)request;StringtokenhttpServletRequest.getHeader(token);if(StrUtil.isBlank(token)){tokenhttpServletRequest.getParameter(token);}if(StrUtil.isBlank(token)){thrownewRuntimeException(token不能为空);}chain.doFilter(request,response);}}这个过滤器在当前项目中早于BodyTransferFilter执行。当 token 放在 header 中时request.getHeader(token) 有值 - 直接拿到 token - 不会调用 request.getParameter(token) - multipart body 没有被提前解析 - 后续 BodyTransferFilter 包装 request - 原始 request.getInputStream() 被提前读完 - DispatcherServlet 再执行 request.getParts() - parts 为空或解析失败 - Controller 绑定不到 id/name/files当 token 放在 URL 参数中时request.getHeader(token) 无值 - 执行 request.getParameter(token) - 对 multipart/form-data 来说这可能触发 Servlet 容器提前解析请求参数 - multipart parts 和 parameter 被缓存到原始 request 内部 - 后续即使 BodyTransferFilter 读取 body - DispatcherServlet/参数绑定仍可能拿到已经解析好的参数 - Controller 可以接收到 id/name/files所以两种调用方式的差异本质不是header token 不支持 multipart而是query param token 触发了 request.getParameter(token) header token 没有触发 request.getParameter(token)。request.getParameter(...)对 multipart 请求来说是一个很敏感的动作。它可能促使 Servlet 容器提前解析 body。提前解析成功后参数和文件信息已经缓存在 request 内部如果没有提前解析后续原始 body 又被自定义 wrapper 读走就会导致 Spring MVC 再解析 multipart 时拿不到内容。因此token 放 query param 能正常接收参数只是因为它碰巧触发了 multipart 提前解析。这不是设计上的正确行为而是多个过滤器执行顺序和 Servlet 容器解析时机叠在一起产生的副作用。5. 为什么 PUT 改成 POST 之后token 放哪里都能解析有问题的项目里还出现了另一个更离谱的现象把接口从PUT改成POST后无论 token 放 header 还是 query param后端都能正常解析参数。如果只看 multipart 本身这个现象很容易把人带偏。因为对 Spring MVC 来说POST multipart/form-data和PUT multipart/form-data并没有本质差异前面分析的DispatcherServlet - checkMultipart - request.getParts()链路也能说明这一点。真正差异来自另一个过滤器HiddenHttpMethodFilter。它的核心代码如下protectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainfilterChain)throwsServletException,IOException{HttpServletRequestrequestToUserequest;if(POST.equals(request.getMethod())request.getAttribute(javax.servlet.error.exception)null){StringparamValuerequest.getParameter(this.methodParam);if(StringUtils.hasLength(paramValue)){StringmethodparamValue.toUpperCase(Locale.ENGLISH);if(ALLOWED_METHODS.contains(method)){requestToUsenewHttpMethodRequestWrapper(request,method);}}}filterChain.doFilter(requestToUse,response);}关键点非常明显StringparamValuerequest.getParameter(this.methodParam);只要是POST请求HiddenHttpMethodFilter就会尝试读取_method参数。这个request.getParameter(...)又可能触发 Servlet 容器提前解析 multipart 请求。这就解释了为什么把接口从PUT改成POST后token 放哪里都能正常解析不是POST天然更适合 multipart而是POST命中了HiddenHttpMethodFilter的逻辑提前触发了参数解析。HiddenHttpMethodFilter的本意并不是处理文件上传而是让传统 HTML 表单通过隐藏字段模拟PUT、DELETE、PATCH等 HTTP 方法。例如formmethodpostaction/user/1inputtypehiddenname_methodvaluePUT/form因为 HTML form 原生只支持GET和POST所以 Spring 用_method参数实现 method override。可以通过下面配置开启该过滤器spring.mvc.hiddenmethod.filter.enabledtrue在前后端分离项目里如果没有传统 HTML form method override 的需求这个过滤器通常没有必要开启。更重要的是不能依赖它“顺手”触发 multipart 提前解析来掩盖问题。6. 正确修改方向这类问题的核心原则非常简单不要在 multipart 解析前读取原始 request body。如果项目里确实需要缓存请求体也应该跳过 multipart 请求ComponentSlf4jpublicclassBodyTransferFilterimplementsFilter{OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throwsIOException,ServletException{StringcontentTyperequest.getContentType();if(contentType!nullcontentType.toLowerCase(Locale.ROOT).startsWith(multipart/)){chain.doFilter(request,response);return;}RequestBodyWrapperrequestBodyWrappernull;try{requestBodyWrappernewRequestBodyWrapper((HttpServletRequest)request);}catch(Exceptione){log.warn(requestBodyWrapper Error:,e);}chain.doFilter(Objects.isNull(requestBodyWrapper)?request:requestBodyWrapper,response);}}同时建议文件上传接口显式声明consumesPostMapping(value/upload,consumesMediaType.MULTIPART_FORM_DATA_VALUE)publicvoidtestUploadFile(ModelAttributeUserParamparam){log.info(开始上传文件了);log.info(参数{},param);log.info(文件数{},paramnull?0:param.getFiles().size());}不写ModelAttribute时复杂对象默认也会按模型属性绑定处理但在上传接口里显式写出来代码意图会更清晰也能减少后续维护者的误判。另外不建议依赖早于BodyTransferFilter执行的过滤器中存在request.getParameter(...)让它“碰巧”触发 Servlet 容器提前解析 body。这种行为太隐蔽也太依赖执行顺序一旦过滤器顺序、框架版本或容器行为有变化问题就可能重新出现。7. 总结这类问题表面看是 Controller 收不到参数实际根因通常发生在请求进入 Controller 之前。核心结论如下Spring MVC 标准 multipart 解析最终依赖request.getParts()。自定义RequestBodyWrapper如果提前读取原始request.getInputStream()会破坏 Servlet 容器后续 multipart 解析。重写 wrapper 的getInputStream()不等于完整接管了 Servlet 容器的getParts()。token 放在 query param 中能正常解析是因为鉴权逻辑调用了request.getParameter(token)它可能提前触发 multipart 解析。token 放在 header 中没有触发request.getParameter(...)所以更容易暴露 body 被提前读取的问题。HiddenHttpMethodFilter调用request.getParameter(_method)也可能提前触发 multipart 解析但这是副作用不应依赖。前后端分离项目中如果没有 HTML form method override 需求可以关闭HiddenHttpMethodFilter。正确做法是过滤器跳过 multipart 请求不要在 multipart 解析前读取上传请求体并确保 Spring Boot multipart 配置正常生效。最后再强调一句这次问题最迷惑人的地方不是某个单点知识有多复杂而是多个“看起来合理”的组件行为叠在一起后形成了一个非常绕的表象。只有把请求从 Filter 到 DispatcherServlet再到 Servlet 容器 multipart 解析的完整链路串起来才能真正做到知其然也知其所以然。