JAVA Web 请求体重复读取?使用 ContentCachingRequestWrapper 实现缓存读取

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接口的类。它通过以下步骤来缓存请求体内容:

  1. 构造器注入:ContentCachingRequestWrapper的构造函数中,传入原始的HttpServletRequest对象。
  2. 缓存请求体: 当第一次调用getInputStream()getReader()方法时,ContentCachingRequestWrapper会读取原始请求体的内容,并将其存储在内部的ByteArrayOutputStream中。
  3. 提供缓存数据: 后续的getInputStream()getReader()调用将不再访问原始的ServletInputStream,而是直接从ByteArrayOutputStream中读取缓存的数据。
  4. 保证请求参数: 原始请求的参数(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 的关键步骤如下:

  1. 创建 Filter 创建一个Filter,用于拦截所有请求。
  2. 包装请求:FilterdoFilter()方法中,将原始的HttpServletRequest对象包装成ContentCachingRequestWrapper对象。
  3. 传递请求: 将包装后的HttpServletRequest对象传递给过滤器链中的下一个组件。
  4. 读取缓存数据: 在需要读取请求体的地方,从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 方法中,首先将 ServletRequestServletResponse 对象转换为 HttpServletRequestHttpServletResponse 对象。然后,使用 ContentCachingRequestWrapper 包装原始的 HttpServletRequest 对象。最后,调用 chain.doFilter 方法将包装后的请求和原始的响应传递给过滤器链中的下一个组件。
  • 配置 Filter:web.xml 文件或者使用 @WebFilter 注解配置该 Filter,确保它拦截所有需要重复读取请求体的请求。
  • 在 Servlet 中使用: 在需要读取请求体的 Servlet 中,首先将 HttpServletRequest 对象转换为 ContentCachingRequestWrapper 对象。然后,调用 getContentAsByteArray() 方法获取缓存的请求体内容,并将其转换为字符串。现在,您可以多次使用该字符串,而无需担心请求体被耗尽。

线程安全性

ContentCachingRequestWrapper 本身不是线程安全的。每个请求都应该创建一个新的ContentCachingRequestWrapper实例。在上面的例子中,我们在FilterdoFilter()方法中为每个请求创建了一个新的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开发需求。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注