JAVA Web 请求体重复读取:ContentCachingRequestWrapper 实战解析
大家好,今天我们来深入探讨一个在Java Web开发中经常遇到的问题:如何重复读取HTTP请求体(Request Body)。在很多场景下,我们需要多次访问请求体的内容,例如:
- 日志记录: 在请求到达时记录请求体内容,方便问题排查。
- 权限校验: 根据请求体中的数据进行权限判断。
- 数据转换: 对请求体数据进行预处理,转换成其他格式。
- 审计追踪: 记录所有请求的完整内容,用于审计目的。
默认情况下,Servlet规范规定请求体只能被读取一次。这是因为ServletInputStream只能读取一次,读取后就不能再重置(reset)。如果尝试多次读取,后续的读取操作将得到空数据或者抛出异常。
为了解决这个问题,Spring框架提供了ContentCachingRequestWrapper类,它可以缓存整个请求体的内容,从而实现重复读取。今天我们将深入了解ContentCachingRequestWrapper的工作原理,并通过实际代码示例演示如何使用它。
为什么不能直接重复读取ServletInputStream?
在深入ContentCachingRequestWrapper之前,我们需要了解为什么ServletInputStream只能被读取一次。ServletInputStream本质上是一个输入流,它从客户端读取数据。一旦数据被读取,流的指针就会移动到读取位置的末尾。如果再次调用read()方法,则会从当前指针位置开始读取,如果已经到达流的末尾,则会返回-1,表示没有更多数据可读。
此外,ServletInputStream 通常没有提供reset()方法来将流的指针重置到起始位置。即使某些ServletInputStream提供了reset()方法,也可能由于底层实现的原因而无法正常工作。
示例代码:
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class ReadBodyServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
ServletInputStream inputStream = req.getInputStream();
byte[] buffer = new byte[1024];
int length = inputStream.read(buffer);
String body1 = new String(buffer, 0, length, StandardCharsets.UTF_8);
System.out.println("第一次读取 Body: " + body1);
length = inputStream.read(buffer); // 尝试再次读取
String body2 = new String(buffer, 0, length, StandardCharsets.UTF_8);
System.out.println("第二次读取 Body: " + body2); // 很有可能是空字符串
}
}
在这个例子中,第一次读取请求体内容后,第二次读取很可能得到空字符串,因为流的指针已经到达末尾。
ContentCachingRequestWrapper 原理
ContentCachingRequestWrapper是Spring框架提供的一个实现了HttpServletRequestWrapper接口的类。它通过以下步骤来缓存请求体内容:
- 构造器注入: 在
ContentCachingRequestWrapper的构造函数中,传入原始的HttpServletRequest对象。 - 缓存请求体: 当第一次调用
getInputStream()或getReader()方法时,ContentCachingRequestWrapper会读取原始请求体的内容,并将其存储在内部的ByteArrayOutputStream中。 - 提供缓存数据: 后续的
getInputStream()或getReader()调用将不再访问原始的ServletInputStream,而是直接从ByteArrayOutputStream中读取缓存的数据。 - 保证请求参数: 原始请求的参数(Query Parameter)不会受到任何影响。
核心代码片段:
// 来自 org.springframework.web.util.ContentCachingRequestWrapper
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {
private final ByteArrayOutputStream cachedContent;
private ServletInputStream inputStream;
private BufferedReader reader;
private boolean inputStreamUsed;
private boolean readerUsed;
public ContentCachingRequestWrapper(HttpServletRequest request) {
super(request);
this.cachedContent = new ByteArrayOutputStream();
}
@Override
public ServletInputStream getInputStream() throws IOException {
if (this.inputStreamUsed) {
return super.getInputStream();
}
if (this.readerUsed) {
throw new IllegalStateException("Cannot call getInputStream() after getReader() has already been called for this request");
}
this.inputStreamUsed = true;
this.inputStream = new ContentCachingInputStream(getRequest().getInputStream());
return this.inputStream;
}
@Override
public BufferedReader getReader() throws IOException {
if (this.readerUsed) {
return super.getReader();
}
if (this.inputStreamUsed) {
throw new IllegalStateException("Cannot call getReader() after getInputStream() has already been called for this request");
}
this.readerUsed = true;
Charset encoding = getCharacterEncoding();
this.reader = new BufferedReader(new InputStreamReader(new ContentCachingInputStream(getRequest().getInputStream()), encoding));
return this.reader;
}
// 内部类:ContentCachingInputStream
private class ContentCachingInputStream extends ServletInputStream {
private final ServletInputStream inputStream;
public ContentCachingInputStream(ServletInputStream inputStream) {
this.inputStream = inputStream;
}
@Override
public int read() throws IOException {
int ch = this.inputStream.read();
if (ch != -1) {
cachedContent.write(ch); // 关键:将读取到的数据写入缓存
}
return ch;
}
// 其他 read() 方法类似,都将数据写入 cachedContent
}
public byte[] getContentAsByteArray() {
return this.cachedContent.toByteArray();
}
}
如何使用 ContentCachingRequestWrapper
使用 ContentCachingRequestWrapper 的关键步骤如下:
- 创建
Filter: 创建一个Filter,用于拦截所有请求。 - 包装请求: 在
Filter的doFilter()方法中,将原始的HttpServletRequest对象包装成ContentCachingRequestWrapper对象。 - 传递请求: 将包装后的
HttpServletRequest对象传递给过滤器链中的下一个组件。 - 读取缓存数据: 在需要读取请求体的地方,从
ContentCachingRequestWrapper对象中获取缓存的请求体数据。
示例代码:
1. 创建 Filter:
import org.springframework.web.util.ContentCachingRequestWrapper;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class RequestCachingFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 初始化操作,可以留空
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
chain.doFilter(requestWrapper, httpResponse);
}
@Override
public void destroy() {
// 销毁操作,可以留空
}
}
2. 配置 Filter (web.xml 或 @WebFilter):
web.xml:
<filter>
<filter-name>requestCachingFilter</filter-name>
<filter-class>com.example.RequestCachingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>requestCachingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
或者使用 @WebFilter 注解:
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebFilter("/*") // 拦截所有请求
public class RequestCachingFilter implements Filter {
// ... (Filter implementation as above) ...
}
3. 在 Servlet 中读取缓存数据:
import org.springframework.web.util.ContentCachingRequestWrapper;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class MyServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) req; // 需要强制转换
byte[] body = requestWrapper.getContentAsByteArray();
String bodyContent = new String(body, StandardCharsets.UTF_8);
System.out.println("请求体内容: " + bodyContent);
// 现在可以多次使用 bodyContent
System.out.println("再次使用请求体内容: " + bodyContent);
}
}
详细步骤:
- 创建 Filter 类: 创建一个实现了
javax.servlet.Filter接口的类,例如RequestCachingFilter。 - doFilter 方法: 在
doFilter方法中,首先将ServletRequest和ServletResponse对象转换为HttpServletRequest和HttpServletResponse对象。然后,使用ContentCachingRequestWrapper包装原始的HttpServletRequest对象。最后,调用chain.doFilter方法将包装后的请求和原始的响应传递给过滤器链中的下一个组件。 - 配置 Filter: 在
web.xml文件或者使用@WebFilter注解配置该 Filter,确保它拦截所有需要重复读取请求体的请求。 - 在 Servlet 中使用: 在需要读取请求体的 Servlet 中,首先将
HttpServletRequest对象转换为ContentCachingRequestWrapper对象。然后,调用getContentAsByteArray()方法获取缓存的请求体内容,并将其转换为字符串。现在,您可以多次使用该字符串,而无需担心请求体被耗尽。
线程安全性
ContentCachingRequestWrapper 本身不是线程安全的。每个请求都应该创建一个新的ContentCachingRequestWrapper实例。在上面的例子中,我们在Filter的doFilter()方法中为每个请求创建了一个新的ContentCachingRequestWrapper实例,这保证了线程安全性。
性能考虑
虽然 ContentCachingRequestWrapper 解决了重复读取请求体的问题,但也引入了额外的性能开销。因为它需要将整个请求体的内容缓存到内存中。
- 内存占用: 如果请求体很大,例如上传大型文件,缓存整个请求体可能会消耗大量内存。
- CPU 消耗: 缓存请求体需要额外的CPU时间。
因此,在使用 ContentCachingRequestWrapper 时需要谨慎,只在必要时使用,并注意控制请求体的大小。
优化策略:
- 限制请求体大小: 可以通过配置Web服务器或应用服务器来限制允许的最大请求体大小。
- 选择性缓存: 只对特定类型的请求或特定的URL进行缓存。
- 使用其他方案: 如果请求体非常大,并且只需要读取部分内容,可以考虑使用其他方案,例如流式处理或将请求体写入临时文件。
替代方案
除了 ContentCachingRequestWrapper,还有一些其他的方案可以实现重复读取请求体,例如:
- TeeInputStream: Apache Commons IO 库提供了一个
TeeInputStream类,它可以将一个输入流的数据同时写入两个输出流。可以使用TeeInputStream将原始的ServletInputStream的数据同时写入一个缓存流和一个目标流。 - 自定义缓存: 可以自己实现一个缓存机制,将请求体的内容缓存到内存或磁盘中。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| ContentCachingRequestWrapper | 简单易用,Spring 官方提供 | 性能开销,内存占用 | 请求体大小适中,需要多次完整读取 |
| TeeInputStream | 可以同时写入多个输出流 | 需要手动管理缓存,较为复杂 | 需要同时处理请求体和缓存,例如记录日志和继续处理 |
| 自定义缓存 | 可以灵活控制缓存策略 | 需要自行实现缓存逻辑,较为复杂 | 需要定制化的缓存策略,例如只缓存部分内容或使用磁盘缓存 |
选择哪种方案取决于具体的应用场景和需求。
常见问题
java.lang.IllegalStateException: getInputStream() has already been called for this request: 这个异常表示在调用getInputStream()之前已经调用了getReader()方法,或者反之。ContentCachingRequestWrapper内部会记录哪个方法被调用过,以避免冲突。确保在同一个请求中只调用getInputStream()或getReader()中的一个。- 中文乱码: 使用
getReader()方法读取请求体时,需要指定正确的字符编码。可以使用request.getCharacterEncoding()方法获取请求的字符编码,或者手动指定编码,例如StandardCharsets.UTF_8。 ContentCachingResponseWrapper: 除了ContentCachingRequestWrapper,Spring 还提供了ContentCachingResponseWrapper类,用于缓存响应体的内容。使用方法类似,可以用于记录响应内容或进行后处理。
写在最后
ContentCachingRequestWrapper 是一个非常有用的工具,可以帮助我们解决Java Web开发中重复读取请求体的问题。但是,在使用它时需要注意性能开销和线程安全性。根据具体的应用场景选择合适的方案,才能更好地解决问题。掌握了这种方法,能帮助你更好地应对各种复杂的Web开发需求。