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

1. 背景

最近排查了一个老项目里的文件上传问题,现象非常反直觉。

接口使用 multipart/form-data 同时上传普通表单字段和文件。看起来只是 token 放置位置不同,后端接收到的结果却完全不一样。

方式一:token 放在 URL 参数中

css 复制代码
curl --location --request PUT '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 放在请求头中

css 复制代码
curl --location --request PUT '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 参数绑定,但实际结果就是不一致。

后端接口大致如下:

typescript 复制代码
@PutMapping("/upload")
public void testUploadFile(UserParam param) {
    log.info("开始上传文件了");
    log.info("参数:{}", param);
    log.info("文件数:{}", param == null ? 0 : param.getFiles().size());
}

参数对象中既有普通字段,也有文件字段:

kotlin 复制代码
@Data
public class UserParam {
    private Long id;
    private String userNo;
    private String name;
    private List<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 请求的核心链路大致如下:

rust 复制代码
DispatcherServlet
 -> checkMultipart(request)
 -> MultipartResolver.resolveMultipart(request)
 -> StandardMultipartHttpServletRequest
 -> request.getParts()

关键点是:

scss 复制代码
request.getParts()

如果项目使用 Servlet 标准 multipart 解析,Spring 最终会进入:

bash 复制代码
org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest

核心代码是:

ini 复制代码
Collection<Part> parts = request.getParts();

也就是说,Spring MVC 并不是自己手动从输入流中拆 multipart body,而是调用 Servlet 容器提供的 multipart 解析能力。

以 Tomcat 为例,后续调用链路大致是:

bash 复制代码
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如何优雅提高接口数据安全性 感兴趣的可以自行跳转查看

示例代码如下:

java 复制代码
@Getter
public class RequestBodyWrapper extends HttpServletRequestWrapper {
​
    private final byte[] body;
​
    public RequestBodyWrapper(HttpServletRequest request) throws IOException {
        super(request);
        body = StreamUtils.copyToByteArray(request.getInputStream());
    }
​
    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }
​
            @Override
            public boolean isReady() {
                return false;
            }
​
            @Override
            public void setReadListener(ReadListener readListener) {
            }
​
            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }
        };
    }
​
    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
}

过滤器中无条件包装请求:

java 复制代码
@Component
@Slf4j
public class BodyTransferFilter implements Filter {
​
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        RequestBodyWrapper requestBodyWrapper = null;
        try {
            requestBodyWrapper = new RequestBodyWrapper((HttpServletRequest) request);
        } catch (Exception e) {
            log.warn("requestBodyWrapper Error:", e);
        }
        chain.doFilter(Objects.isNull(requestBodyWrapper) ? request : requestBodyWrapper, response);
    }
}

这类写法对普通 JSON 请求通常能工作,但对 multipart/form-data 非常危险。

问题出在 wrapper 构造方法里:

ini 复制代码
body = StreamUtils.copyToByteArray(request.getInputStream());

这行代码已经把原始 Tomcat request 的 body 输入流消费掉了。

虽然 wrapper 后续重写了:

scss 复制代码
getInputStream()
getReader()

但 multipart 解析时,Spring 调用的关键入口是:

scss 复制代码
request.getParts()

而不是简单调用:

scss 复制代码
request.getInputStream()

getParts() 是 Servlet 容器的 multipart 解析入口。容器解析 multipart 时依赖的是原始 request 的内部状态、connector 输入流、Content-TypeContent-LengthMultipartConfigElement 等信息。

这个自定义 wrapper 只是提供了一个新的 getInputStream(),并没有完整接管:

scss 复制代码
getParts()
getPart(String name)
getParameter(...)
getParameterMap()
getParameterValues(...)

所以后续 Spring MVC 调用 request.getParts() 时,最终仍然会委托到底层原始 request。此时原始 request 的输入流已经被过滤器提前读完,Tomcat 再解析 multipart 时就拿不到内容了。

完整失败链路如下:

shell 复制代码
请求进入 Filter
 -> new RequestBodyWrapper(originalRequest)
 -> 构造方法读取 originalRequest.getInputStream()
 -> 原始 body 被消费
 -> 进入 DispatcherServlet
 -> checkMultipart(request)
 -> StandardMultipartHttpServletRequest#parseRequest
 -> request.getParts()
 -> 委托到底层 originalRequest.getParts()
 -> Tomcat 发现底层输入流已被消费
 -> parts 为空或解析失败
 -> Controller 绑定不到 id/name/files

流程图如下所示:

这里最容易误判的一点是:

scss 复制代码
重写 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 获取方式触发了不同代码路径。

项目中有一个登录校验过滤器,类似如下:

java 复制代码
@Component
public class AuthFilter implements Filter {
​
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("token");
        if (StrUtil.isBlank(token)) {
            token = httpServletRequest.getParameter("token");
        }
        if (StrUtil.isBlank(token)) {
            throw new RuntimeException("token不能为空");
        }
        chain.doFilter(request, response);
    }
}

这个过滤器在当前项目中早于 BodyTransferFilter 执行。

当 token 放在 header 中时:

shell 复制代码
request.getHeader("token") 有值
 -> 直接拿到 token
 -> 不会调用 request.getParameter("token")
 -> multipart body 没有被提前解析
 -> 后续 BodyTransferFilter 包装 request
 -> 原始 request.getInputStream() 被提前读完
 -> DispatcherServlet 再执行 request.getParts()
 -> parts 为空或解析失败
 -> Controller 绑定不到 id/name/files

当 token 放在 URL 参数中时:

shell 复制代码
request.getHeader("token") 无值
 -> 执行 request.getParameter("token")
 -> 对 multipart/form-data 来说,这可能触发 Servlet 容器提前解析请求参数
 -> multipart parts 和 parameter 被缓存到原始 request 内部
 -> 后续即使 BodyTransferFilter 读取 body
 -> DispatcherServlet/参数绑定仍可能拿到已经解析好的参数
 -> Controller 可以接收到 id/name/files

所以,两种调用方式的差异,本质不是:

css 复制代码
header token 不支持 multipart

而是:

vbscript 复制代码
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

它的核心代码如下:

vbscript 复制代码
protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
​
    HttpServletRequest requestToUse = request;
    if ("POST".equals(request.getMethod())
            && request.getAttribute("javax.servlet.error.exception") == null) {
        String paramValue = request.getParameter(this.methodParam);
        if (StringUtils.hasLength(paramValue)) {
            String method = paramValue.toUpperCase(Locale.ENGLISH);
            if (ALLOWED_METHODS.contains(method)) {
                requestToUse = new HttpMethodRequestWrapper(request, method);
            }
        }
    }
    filterChain.doFilter(requestToUse, response);
}

关键点非常明显:

ini 复制代码
String paramValue = request.getParameter(this.methodParam);

只要是 POST 请求,HiddenHttpMethodFilter 就会尝试读取 _method 参数。这个 request.getParameter(...) 又可能触发 Servlet 容器提前解析 multipart 请求。

这就解释了为什么把接口从 PUT 改成 POST 后,token 放哪里都能正常解析:不是 POST 天然更适合 multipart,而是 POST 命中了 HiddenHttpMethodFilter 的逻辑,提前触发了参数解析。

HiddenHttpMethodFilter 的本意并不是处理文件上传,而是让传统 HTML 表单通过隐藏字段模拟 PUTDELETEPATCH 等 HTTP 方法。

例如:

ini 复制代码
<form method="post" action="/user/1">
    <input type="hidden" name="_method" value="PUT">
</form>

因为 HTML form 原生只支持 GETPOST,所以 Spring 用 _method 参数实现 method override。

可以通过下面配置开启该过滤器:

ini 复制代码
spring.mvc.hiddenmethod.filter.enabled=true

在前后端分离项目里,如果没有传统 HTML form method override 的需求,这个过滤器通常没有必要开启。更重要的是,不能依赖它"顺手"触发 multipart 提前解析来掩盖问题。

6. 正确修改方向

这类问题的核心原则非常简单:

css 复制代码
不要在 multipart 解析前读取原始 request body。

如果项目里确实需要缓存请求体,也应该跳过 multipart 请求:

vbscript 复制代码
@Component
@Slf4j
public class BodyTransferFilter implements Filter {
​
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
​
        String contentType = request.getContentType();
        if (contentType != null
                && contentType.toLowerCase(Locale.ROOT).startsWith("multipart/")) {
            chain.doFilter(request, response);
            return;
        }
​
        RequestBodyWrapper requestBodyWrapper = null;
        try {
            requestBodyWrapper = new RequestBodyWrapper((HttpServletRequest) request);
        } catch (Exception e) {
            log.warn("requestBodyWrapper Error:", e);
        }
        chain.doFilter(Objects.isNull(requestBodyWrapper) ? request : requestBodyWrapper, response);
    }
}

同时建议文件上传接口显式声明 consumes

less 复制代码
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public void testUploadFile(@ModelAttribute UserParam param) {
    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 解析的完整链路串起来,才能真正做到知其然,也知其所以然。

相关推荐
lbb 小魔仙2 小时前
MuMu 模拟器 6.0 硬核测评:这次升级确实不一样
java·开发语言·模拟器·mumu·硬核测评
前端炒粉2 小时前
手写promise
java·前端·javascript
mONESY2 小时前
Node.js fs 文件系统模块 四种读写方案全解析
javascript·后端
H_HX1262 小时前
IDEA的下载和安装
java·intellij-idea·idea
AIGS0012 小时前
向量空间JBoltAI:本体语义如何跨越企业AI的语义鸿沟
java·大数据·人工智能·人工智能ai大模型应用
Larcher2 小时前
从回调地狱到优雅异步:Node.js 文件操作进化史
javascript·后端·架构
Java编程爱好者2 小时前
深入浅出Agent: Harness最全调研与原理解析(持续更新中)
后端
r_oo_ki_e_2 小时前
Java 线程池
java·开发语言
日月云棠2 小时前
Spring 源码深度剖析:DispatcherServlet 的自动化注册机制
java·后端