Spring Boot :彻底解决 HttpServletRequest 输入流只能读取一次的问题

1. 背景与痛点

在开发企业级 Web 应用(尤其是构建基础框架)时,我们经常面临一个矛盾的需求场景:

  1. 全链路日志记录 :我们需要在 Filter 层记录请求的详细信息,包括 Request Body(通常是 JSON 参数),以便于排查问题。
  2. 业务参数绑定 :Spring MVC 的 Controller 层需要通过 @RequestBody 注解将 Request Body 解析为 Java DTO 对象。

技术冲突

标准的 HttpServletRequest 底层基于 ServletInputStream。这是一个单向、不可回退的 IO 流。

  • 如果 日志 Filter 先读取了流,流内部的指针会指向末尾(EOF)。
  • 当请求流转到 Spring MVC 时,框架再次尝试读取流,发现数据为空,从而抛出 HttpMessageNotReadableException: Required request body is missing 异常,导致业务中断。

本文将介绍如何通过 装饰器模式(Decorator Pattern)Request Wrapper 机制,优雅地解决这一问题。

2. 核心困难:ServletInputStream 的不可重复读性

要理解这个问题,必须从底层 IO 机制说起。

当 Tomcat 接收到 HTTP 请求时,Request Body 的数据存在于操作系统的 TCP 接收缓冲区或 Tomcat 的内部缓冲区中。HttpServletRequest 提供的 getInputStream() 是读取这些数据的唯一入口。

该流具有以下特性:

  1. 流式读取 :数据读取是线性的。调用 read() 时,指针向后移动,读过的字节无法再次获取。
  2. 无状态缓冲:标准的 Servlet 实现为了节省内存,不会默认在 JVM 堆内存中缓存所有 Body 数据。
  3. 不可重置 :标准的 ServletInputStream 不支持 reset() 操作。一旦读完,流就"废"了。

因此,在一次请求的生命周期中,Request Body 只能被消费一次

3. 解决方案:CacheRequestBodyWrapper

解决思路是 "以空间换时间" 。我们需要拦截原始请求,将流中的数据全部读取出来暂存在 内存(byte 数组) 中,然后伪造一个新的 Request 对象,当后续组件索要流时,始终返回一个新的、基于内存数组的流。

3.1 核心代码实现

我们继承 HttpServletRequestWrapper 类,实现一个自定义的 Request 包装器。

java 复制代码
public class CacheRequestBodyWrapper extends HttpServletRequestWrapper {

    // 用于在内存中存储 Body 数据
    private final byte[] body;

    public CacheRequestBodyWrapper(HttpServletRequest request) {
        super(request);
        // 【关键步骤 1】在构造时,立即读取原始流的所有内容
        // 将数据从 IO Buffer 转移到 JVM Heap 的 byte[] 数组中
        this.body = ServletUtils.getBodyBytes(request);
    }

    @Override
    public ServletInputStream getInputStream() {
        // 【关键步骤 2】每次调用,都创建一个新的 ByteArrayInputStream
        // 利用内存中的 byte[] 生成一个新的流实例
        final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);

        // 返回 ServletInputStream 的实现,底层委托给 ByteArrayInputStream
        return new ServletInputStream() {
            @Override
            public int read() {
                return inputStream.read();
            }
            // 省略 isFinished, isReady, setReadListener 等标准实现
            @Override
            public boolean isFinished() { return inputStream.available() == 0; }
            @Override
            public boolean isReady() { return true; }
            @Override
            public void setReadListener(ReadListener readListener) {}
            @Override
            public int available() { return body.length; }
        };
    }
    
    // 同时重写 getReader,适配使用 Reader 读取的情况
    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

3.2 过滤器实现:偷梁换柱

接下来,我们需要一个 Filter,将原始的 Request 替换为我们的 Wrapper。

java 复制代码
public class CacheRequestBodyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
        
        // 【关键步骤 3】使用 Wrapper 包装原始 Request
        CacheRequestBodyWrapper requestWrapper = new CacheRequestBodyWrapper(request);
        
        // 【关键步骤 4】将包装后的对象传递给 Filter 链的下一个环节
        // 此后,所有组件(Interceptors, Controller)拿到的 request 都是 requestWrapper
        filterChain.doFilter(requestWrapper, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 【性能保护】只处理 JSON 请求
        // 绝对不能处理文件上传,否则会造成内存溢出
        return !ServletUtils.isJsonRequest(request); 
    }
}

4. 原理深度剖析与执行流程

让我们通过一个具体的请求示例:POST /api/user,Body 为 {"name": "yudao"},来追踪整个技术实现流程。

第一阶段:初始化与缓存 (In the Filter)

  1. 请求到达 Filter :Tomcat 传入原始 OriginalRequest
  2. 创建 Wrapper :执行 new CacheRequestBodyWrapper(OriginalRequest)
  3. 内存转移
  • 构造函数调用 request.getInputStream()
  • {"name": "yudao"} 从网络流中完全读出。
  • 数据被存入 Wrapper 实例的 private final byte[] body 字段中。
  • 此时,原始网络流已读空(EOF)。

第二阶段:传递与伪装 (Pass Down)

  1. 链式调用 :执行 filterChain.doFilter(wrapper, response)
  2. 对象替换 :由于 Java 的多态性,后续所有组件接收到的 ServletRequest 参数,实际上指向的都是 wrapper 实例。

第三阶段:多次消费 (Multiple Reads)

第一次读取:日志组件(假设)
  • 日志组件调用 request.getInputStream()
  • 实际调用的是 wrapper.getInputStream()
  • Wrapper 创建并返回 ByteArrayInputStream 实例 A (指向 body 数组,索引 0)。
  • 日志组件读取完毕,实例 A 指针到达末尾。
第二次读取:Spring MVC 参数绑定
  • DispatcherServlet 调用 request.getInputStream() 以处理 @RequestBody
  • 实际调用的是 wrapper.getInputStream()
  • Wrapper 创建并返回 ByteArrayInputStream 实例 B (依然指向同一个 body 数组,索引重置为 0)。
  • Jackson 解析器成功读取到完整的 JSON 数据。

5. 重要的技术限制与警示

既然这个方案这么好,为什么 Tomcat 不默认这么做?因为它涉及 内存开销

必须限制过滤范围 (shouldNotFilter)

  • JSON 请求 :通常只有几 KB 到几 MB,存入 JVM 堆内存(byte[])是安全的。
  • 文件上传请求 :如果用户上传一个 1GB 的视频文件。
  • 如果不拦截,Tomcat 使用流式处理,内存占用极低。
  • 如果使用本方案,Wrapper 会试图在堆内存中分配 1GB 的数组。这会直接导致 OOM (Out Of Memory) 错误,导致服务崩溃。

该方案仅适用于文本类(JSON/XML)请求体,严禁用于 multipart/form-data 文件上传场景。

相关推荐
3 小时前
java关于内部类
java·开发语言
好好沉淀3 小时前
Java 项目中的 .idea 与 target 文件夹
java·开发语言·intellij-idea
gusijin3 小时前
解决idea启动报错java: OutOfMemoryError: insufficient memory
java·ide·intellij-idea
To Be Clean Coder3 小时前
【Spring源码】createBean如何寻找构造器(二)——单参数构造器的场景
java·后端·spring
吨~吨~吨~3 小时前
解决 IntelliJ IDEA 运行时“命令行过长”问题:使用 JAR
java·ide·intellij-idea
你才是臭弟弟3 小时前
SpringBoot 集成MinIo(根据上传文件.后缀自动归类)
java·spring boot·后端
短剑重铸之日3 小时前
《设计模式》第二篇:单例模式
java·单例模式·设计模式·懒汉式·恶汉式
码农水水3 小时前
得物Java面试被问:消息队列的死信队列和重试机制
java·开发语言·jvm·数据结构·机器学习·面试·职场和发展
summer_du3 小时前
IDEA插件下载缓慢,如何解决?
java·ide·intellij-idea
C澒4 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流