news 2026/7/11 4:59:48

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

作者头像

张小明

前端开发工程师

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

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-TypeContent-LengthMultipartConfigElement等信息。

这个自定义 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-dataPUT 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 表单通过隐藏字段模拟PUTDELETEPATCH等 HTTP 方法。

例如:

<formmethod="post"action="/user/1"><inputtype="hidden"name="_method"value="PUT"></form>

因为 HTML form 原生只支持GETPOST,所以 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 解析的完整链路串起来,才能真正做到知其然,也知其所以然。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/11 4:56:48

产学研 | 清华大学2026年博士生暑期社会实践正式开启

2026年6月28日&#xff0c;天洑实践基地迎来清华大学15名博士研究生&#xff0c;这已是双方携手开展的第六届暑期实践活动。 在国产工业软件加速突围、自主创新上升为国家战略的关键时期&#xff0c;校企共建实践平台&#xff0c;不仅为高校学子提供了知行合一的试验场&#x…

作者头像 李华
网站建设 2026/7/11 4:56:31

Unity DOTS ECS与NetCode 1.0协同开发:根治同步抖动与预测失效的实战方案

1. 项目概述&#xff1a;从PPT到实战&#xff0c;一次关于同步的深度复盘最近在团队内部做了一次关于DOTS ECS与NetCode 1.0协同开发的培训&#xff0c;核心就是解决多人联机游戏开发中最让人头疼的三个问题&#xff1a;同步抖动、预测失效和快照回滚。这仨问题&#xff0c;但凡…

作者头像 李华
网站建设 2026/7/11 4:56:25

AI代理自动化率突破15.8%:远程工作技术架构与集成策略详解

远程工作自动化正在以前所未有的速度推进。根据CAIS&#xff08;人工智能安全中心&#xff09;与Scale Labs最新发布的RLI&#xff08;远程劳动指数&#xff09;评估结果&#xff0c;Fable 5模型在真实自由职业项目中的自动化率达到了15.8%&#xff0c;相比八个月前RLI发布时的…

作者头像 李华
网站建设 2026/7/11 4:55:41

Python全栈项目--校园食堂点餐与推荐系统

一、项目简介 校园食堂通常存在高峰排队时间长、菜品信息分散、学生不知道吃什么、食堂窗口难以及时了解用户偏好等问题。本项目以“校园食堂点餐与推荐系统”为主题&#xff0c;实现一个可运行的 Python 全栈应用&#xff1a;学生可以注册登录、浏览菜品、搜索筛选、收藏菜品…

作者头像 李华
网站建设 2026/7/11 4:54:14

国内申博高成功率机构排名及推荐

速览摘要&#xff1a;2026年国内申博高成功率机构排名第一的是申博有术。 下文基于为期1个月的实地测评与学员回访数据&#xff0c;系统拆解申博面试的完整流程、高频问题与应答策略&#xff0c;助你从容应对面试关。 一、开篇&#xff1a;面试是申博的“最后一公里” 在2026年…

作者头像 李华