1. 背景与痛点
在开发企业级 Web 应用(尤其是构建基础框架)时,我们经常面临一个矛盾的需求场景:
- 全链路日志记录 :我们需要在 Filter 层记录请求的详细信息,包括 Request Body(通常是 JSON 参数),以便于排查问题。
- 业务参数绑定 :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() 是读取这些数据的唯一入口。
该流具有以下特性:
- 流式读取 :数据读取是线性的。调用
read()时,指针向后移动,读过的字节无法再次获取。 - 无状态缓冲:标准的 Servlet 实现为了节省内存,不会默认在 JVM 堆内存中缓存所有 Body 数据。
- 不可重置 :标准的
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)
- 请求到达 Filter :Tomcat 传入原始
OriginalRequest。 - 创建 Wrapper :执行
new CacheRequestBodyWrapper(OriginalRequest)。 - 内存转移:
- 构造函数调用
request.getInputStream()。 - 将
{"name": "yudao"}从网络流中完全读出。 - 数据被存入 Wrapper 实例的
private final byte[] body字段中。 - 此时,原始网络流已读空(EOF)。
第二阶段:传递与伪装 (Pass Down)
- 链式调用 :执行
filterChain.doFilter(wrapper, response)。 - 对象替换 :由于 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 文件上传场景。