1. 背景
最近排查了一个老项目里的文件上传问题,现象非常反直觉。
接口使用multipart/form-data同时上传普通表单字段和文件。看起来只是 token 放置位置不同,后端接收到的结果却完全不一样。
方式一:token 放在 URL 参数中
curl--location--requestPUT'http://127.0.0.1:18000/test/upload?token=12345678910'\--form'id="123"'\--form'name="张三"'\--form'files=@"/Users/shepherdmy/Downloads/已生成图像 1 (3).png"'\--form'files=@"/Users/shepherdmy/Downloads/已生成图像 1 (2).png"'这种方式下,后端可以正常解析普通参数和文件。
方式二:token 放在请求头中
curl--location--requestPUT'http://127.0.0.1:18000/test/upload'\--header'token: 12345678910'\--form'id="123"'\--form'name="张三"'\--form'files=@"/Users/shepherdmy/Downloads/已生成图像 1 (3).png"'\--form'files=@"/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("文件数:{}",param==null?0:param.getFiles().size());}参数对象中既有普通字段,也有文件字段:
@DatapublicclassUserParam{privateLongid;privateStringuserNo;privateStringname;privateList<MultipartFile>files;}排查过程中还发现:同样的 Spring Boot 版本、同样的接口写法,有的项目可以正常接收到id/name/files,有的项目却完全接收不到。
最后定位下来,问题不在 Controller 参数写法,而在请求进入 Controller 之前:multipart/form-data有没有被 Servlet 容器正确解析。
这次排查基本属于一层一层往下钻,最后把 Spring MVC、Servlet multipart 解析、Filter 执行顺序、request.getParameter(...)的副作用都串起来了。
2. Spring MVC 如何解析 multipart/form-data
Spring MVC 处理 multipart 请求的核心链路大致如下:
DispatcherServlet -> checkMultipart(request) -> MultipartResolver.resolveMultipart(request) -> StandardMultipartHttpServletRequest -> request.getParts()关键点是:
request.getParts()如果项目使用 Servlet 标准 multipart 解析,Spring 最终会进入:
org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest核心代码是:
Collection<Part>parts=request.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);body=StreamUtils.copyToByteArray(request.getInputStream());}@OverridepublicServletInputStreamgetInputStream()throwsIOException{finalByteArrayInputStreambyteArrayInputStream=newByteArrayInputStream(body);returnnewServletInputStream(){@OverridepublicbooleanisFinished(){returnfalse;}@OverridepublicbooleanisReady(){returnfalse;}@OverridepublicvoidsetReadListener(ReadListenerreadListener){}@Overridepublicintread()throwsIOException{returnbyteArrayInputStream.read();}};}@OverridepublicBufferedReadergetReader()throwsIOException{returnnewBufferedReader(newInputStreamReader(getInputStream()));}}过滤器中无条件包装请求:
@Component@Slf4jpublicclassBodyTransferFilterimplementsFilter{@OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throwsIOException,ServletException{RequestBodyWrapperrequestBodyWrapper=null;try{requestBodyWrapper=newRequestBodyWrapper((HttpServletRequest)request);}catch(Exceptione){log.warn("requestBodyWrapper Error:",e);}chain.doFilter(Objects.isNull(requestBodyWrapper)?request:requestBodyWrapper,response);}}这类写法对普通 JSON 请求通常能工作,但对multipart/form-data非常危险。
问题出在 wrapper 构造方法里:
body=StreamUtils.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;Stringtoken=httpServletRequest.getHeader("token");if(StrUtil.isBlank(token)){token=httpServletRequest.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{HttpServletRequestrequestToUse=request;if("POST".equals(request.getMethod())&&request.getAttribute("javax.servlet.error.exception")==null){StringparamValue=request.getParameter(this.methodParam);if(StringUtils.hasLength(paramValue)){Stringmethod=paramValue.toUpperCase(Locale.ENGLISH);if(ALLOWED_METHODS.contains(method)){requestToUse=newHttpMethodRequestWrapper(request,method);}}}filterChain.doFilter(requestToUse,response);}关键点非常明显:
StringparamValue=request.getParameter(this.methodParam);只要是POST请求,HiddenHttpMethodFilter就会尝试读取_method参数。这个request.getParameter(...)又可能触发 Servlet 容器提前解析 multipart 请求。
这就解释了为什么把接口从PUT改成POST后,token 放哪里都能正常解析:不是POST天然更适合 multipart,而是POST命中了HiddenHttpMethodFilter的逻辑,提前触发了参数解析。
HiddenHttpMethodFilter的本意并不是处理文件上传,而是让传统 HTML 表单通过隐藏字段模拟PUT、DELETE、PATCH等 HTTP 方法。
例如:
<formmethod="post"action="/user/1"><inputtype="hidden"name="_method"value="PUT"></form>因为 HTML form 原生只支持GET和POST,所以 Spring 用_method参数实现 method override。
可以通过下面配置开启该过滤器:
spring.mvc.hiddenmethod.filter.enabled=true在前后端分离项目里,如果没有传统 HTML form method override 的需求,这个过滤器通常没有必要开启。更重要的是,不能依赖它“顺手”触发 multipart 提前解析来掩盖问题。
6. 正确修改方向
这类问题的核心原则非常简单:
不要在 multipart 解析前读取原始 request body。如果项目里确实需要缓存请求体,也应该跳过 multipart 请求:
@Component@Slf4jpublicclassBodyTransferFilterimplementsFilter{@OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throwsIOException,ServletException{StringcontentType=request.getContentType();if(contentType!=null&&contentType.toLowerCase(Locale.ROOT).startsWith("multipart/")){chain.doFilter(request,response);return;}RequestBodyWrapperrequestBodyWrapper=null;try{requestBodyWrapper=newRequestBodyWrapper((HttpServletRequest)request);}catch(Exceptione){log.warn("requestBodyWrapper Error:",e);}chain.doFilter(Objects.isNull(requestBodyWrapper)?request:requestBodyWrapper,response);}}同时建议文件上传接口显式声明consumes:
@PostMapping(value="/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)publicvoidtestUploadFile(@ModelAttributeUserParamparam){log.info("开始上传文件了");log.info("参数:{}",param);log.info("文件数:{}",param==null?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 解析的完整链路串起来,才能真正做到知其然,也知其所以然。