在这里插入图片描述

有些问题的难点,不在代码,而在你是否理解它的运行机制。
RequestBody 只能读取一次,就是典型代表。


一、问题出现的真实背景

在简单 CRUD 系统里,请求链路很短:

Controller → Service → DAO

RequestBody 只被读取一次,自然不会有问题。

但在企业级系统中,请求进入 Controller 之前,往往已经经历多层处理:

  • 接口幂等校验(防重复提交)
  • 请求签名 / 防重放攻击
  • 全量请求日志落库
  • 安全审计 / 风控策略
  • 参数脱敏 / 敏感词过滤

这些能力有一个共同点:

都需要读取请求体。

于是问题出现了——

当某个前置组件读取过 RequestBody 后,
Controller 的 @RequestBody 绑定直接失效。


二、很多人忽略的关键事实

先给一个结论:

RequestBody 不是“参数对象”,而是“数据流”。

在进入 Spring MVC 之前,它的真实形态是:

HttpServletRequest InputStream

Spring 只是帮你做了这一步转换:

InputStream → HttpMessageConverter → Java Object

而一旦 InputStream 被提前消费:

  • Converter 无数据可读
  • 参数绑定失败
  • Validation 无法执行

这也是为什么问题看起来像“参数丢失”。


三、底层原理拆解:为什么只能读取一次?

从 Servlet 规范看,本质原因有 4 个:

1️⃣ HTTP Body 是流式传输

请求体不会一次性加载到内存,而是边传输边读取。


2️⃣ InputStream 只能顺序读取

ServletInputStream inputStream = request.getInputStream();
  • 没有 rewind
  • 没有 reset
  • 没有副本

读完即结束。


3️⃣ getReader() 与 getInputStream() 共用同一数据源

Reader → InputStream → Socket Buffer

所以两者本质是同一条流。


4️⃣ Servlet 规范不提供缓存机制

规范只定义读取接口,不负责数据回放。


四、Spring MVC 参数绑定发生在什么时候?

这是很多人忽略的关键。

@RequestBody 解析时机:

DispatcherServlet
   → HandlerAdapter
      → HttpMessageConverter
         → 读取 InputStream

也就是说:

参数绑定发生在 Controller 调用之前,但在 Filter / Interceptor 之后。

因此:

  • Filter 读取 → 必然影响绑定
  • Interceptor 读取 → 同样影响
  • AOP(Controller 前)→ 已经来不及

五、工程解决思路:构建“可回放请求体”

既然流不可重复读取,
那就必须人为构建“副本机制”。

核心策略:

第一次读取 → 缓存 → 后续基于缓存创建新流

实现目标:

  • 不改变业务代码
  • 不影响参数绑定
  • 不破坏 Security / Validation
  • 支持任意组件重复读取

六、核心实现方案

方案架构

Client Request
      ↓
Repeatable Filter(最前)
      ↓
RequestWrapper(缓存 Body)
      ↓
Security / Log / Idempotent
      ↓
Spring MVC 参数绑定
      ↓
Controller

七、关键代码实现讲解

1️⃣ RequestWrapper:缓存请求体

public class RepeatableRequestWrapper extends HttpServletRequestWrapper {

    private final byte[] body;

    public RepeatableRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.body = request.getInputStream().readAllBytes();
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream bis = new ByteArrayInputStream(body);

        return new ServletInputStream() {
            @Override
            public int read() {
                return bis.read();
            }

            @Override
            public boolean isFinished() {
                return bis.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener listener) {}
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
}

技术要点解析

① 为什么缓存为 byte[]?
  • InputStream 本质是字节流
  • byte[] 可重复构建 ByteArrayInputStream
  • 性能优于字符串转换

② 为什么每次 new InputStream?

因为流有“游标”:

读一次 → 游标到底 → 无数据

必须每次基于缓存创建新流。


③ 为什么同时重写 Reader?

部分组件用 Reader 读取字符流,
不重写会导致读取不一致。


2️⃣ Filter:入口替换 Request

public class RepeatableFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain)
            throws IOException, ServletException {

        ServletRequest wrapper = request;

        if (request instanceof HttpServletRequest httpRequest
                && httpRequest.getContentType() != null
                && httpRequest.getContentType().contains("application/json")) {

            wrapper = new RepeatableRequestWrapper(httpRequest);
        }

        chain.doFilter(wrapper, response);
    }
}

技术要点解析

① 为什么只处理 JSON?
  • JSON 最常用于 Body 绑定
  • 避免文件上传 / 大流量请求内存暴涨

② 为什么必须在最前?

因为谁先读流,谁就决定生死。


3️⃣ Filter 顺序配置

@Bean
public FilterRegistrationBean<RepeatableFilter> filterBean() {
    FilterRegistrationBean<RepeatableFilter> bean =
            new FilterRegistrationBean<>();

    bean.setFilter(new RepeatableFilter());
    bean.addUrlPatterns("/*");
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);

    return bean;
}

八、进阶工程思考

这部分才是真正体现技术深度的地方。


1️⃣ 大请求体的内存风险

缓存意味着:

Body Size × 并发数 = 内存占用

优化策略:

  • 限制缓存大小
  • 超限降级为不可重复读
  • 文件上传直接跳过

2️⃣ 与 Spring Security 的顺序关系

必须保证:

RepeatableFilter → SecurityFilterChain

否则签名校验读取后仍会失效。


3️⃣ 与日志系统的整合

推荐统一在 Wrapper 后读取:

  • 避免日志组件重复读流
  • 减少 IO 消耗

九、经验总结

  1. RequestBody 只能读一次,是 Servlet 规范决定
  2. Spring MVC 参数绑定依赖 InputStream
  3. 提前读取必然导致绑定失败
  4. 唯一工程解法:缓存 + 包装 + 前置替换
  5. Filter 顺序与缓存策略决定方案稳定性

十、结语

当系统开始引入:

  • 安全校验
  • 幂等控制
  • 全量日志
  • 风控策略

RequestBody 重复读取就不再是技巧,而是基础设施能力

优秀的工程师,不是解决一次问题,
而是构建一套可复用的底层机制。


🔹 留个讨论问题

在你的系统中,如果同时存在:

  • 幂等校验
  • 请求签名
  • 全量日志落库

你会如何设计 RequestBody 的读取与缓存策略?

是统一入口缓存,还是按组件分治?
又如何权衡内存占用与系统安全?

欢迎在评论区聊聊你的设计思路。


转载自————墨菲Code
墨菲 Code
一个专注技术深度,分享工程本质的公众号。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐