← 全部文章

在 Spring Web 中实现可重复读取的 HttpServletRequest

解决HttpServletRequest的InputStream只能读取一次的问题

本文目录 5 节
在 Spring Web 中实现可重复读取的 HttpServletRequest封面

为什么要关心「请求体只能读一次」

如果我们想在过滤器或者拦截器层面就读取请求体的参数用于某种操作,那么当请求到达Controller层的时候,@RequestBody将无法正常解析传入的DTO对象。

为什么要在过滤器层面做

在很多真实业务场景中,我们必须尽可能早地拿到请求体,例如:

  1. 接口签名 / 防篡改校验 需要对请求体做 hash / HMAC 校验,而这一步通常放在过滤器中最合适

  2. 统一审计 / 请求日志 希望无论 Controller 是否成功处理,都能记录原始请求数据

  3. 安全风控 / 黑白名单 / 参数校验 某些规则希望在请求进入业务逻辑前就被拦截

  4. 灰度 / 网关式逻辑 在进入 Spring MVC 之前决定请求是否放行或路由

而这些能力,用 Filter 实现通常比 Interceptor 更靠前、也更通用。

问题的根源:InputStream 只能消费一次

HttpServletRequest 的请求体本质上来自底层 Servlet 容器的流:

request.getInputStream()
request.getReader()

一旦在过滤器中读取了:

String body = IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8);

那么到 Controller 层:

@PostMapping
public void test(@RequestBody SomeDto dto) { ... }

Spring MVC 再去读请求体时,流已经被消费完了,结果就是:

  • DTO 解析失败

  • 报错:Required request body is missing

  • 或字段全是 null

解决思路:缓存请求体,实现“可重复读取”

包装后的HttpServletRequest

import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StreamUtils;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

/**
 * 缓存请求Body的HttpServletRequest包装类
 * <p>
 * 解决HttpServletRequest的Body只能读取一次的问题
 * </p>
 */
@Slf4j
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {

    /**
     * 缓存的请求体内容
     */
    private final byte[] cachedBody;

    /**
     * 构造方法,读取并缓存请求体
     *
     * @param request 原始请求
     * @throws IOException 读取请求体失败
     */
    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        // 读取原始请求体并缓存
        this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    /**
     * 获取缓存的请求体内容
     *
     * @return 请求体字节数组
     */
    public byte[] getCachedBody() {
        return cachedBody;
    }

    /**
     * 获取缓存的请求体字符串
     *
     * @return 请求体字符串
     */
    public String getCachedBodyString() {
        return new String(cachedBody, StandardCharsets.UTF_8);
    }

    /**
     * 重写getInputStream方法,返回可重复读取的输入流
     */
    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new CachedBodyServletInputStream(cachedBody);
    }

    /**
     * 重写getReader方法,返回可重复读取的BufferedReader
     */
    @Override
    public BufferedReader getReader() throws IOException {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody);
        return new BufferedReader(new InputStreamReader(byteArrayInputStream, StandardCharsets.UTF_8));
    }

    /**
     * 可缓存的ServletInputStream实现
     */
    private static class CachedBodyServletInputStream extends ServletInputStream {

        private final ByteArrayInputStream cachedBodyInputStream;

        public CachedBodyServletInputStream(byte[] cachedBody) {
            this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);
        }

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

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

        @Override
        public void setReadListener(ReadListener readListener) {
            throw new UnsupportedOperationException("ReadListener is not supported");
        }

        @Override
        public int read() throws IOException {
            return cachedBodyInputStream.read();
        }
    }
}

在过滤器中使用它

import cn.edu.tyut.backendtyutoj.common.wrapper.CachedBodyHttpServletRequest;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * 请求Body缓存过滤器
 * <p>
 * 对于POST、PUT、PATCH请求,将HttpServletRequest包装为CachedBodyHttpServletRequest,
 * 以支持请求体的多次读取
 * </p>
 */
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CachedBodyFilter implements Filter {

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

        if (request instanceof HttpServletRequest httpServletRequest) {
            // 判断是否需要包装请求
            if (shouldWrapRequest(httpServletRequest)) {
                try {
                    // 包装请求以支持多次读取Body
                    CachedBodyHttpServletRequest cachedBodyRequest =
                            new CachedBodyHttpServletRequest(httpServletRequest);
                    chain.doFilter(cachedBodyRequest, response);
                    return;
                } catch (IOException e) {
                    log.warn("包装请求失败,使用原始请求: {}", e.getMessage());
                }
            }
        }

        // 对于其他类型的请求或包装失败的情况,直接放行
        chain.doFilter(request, response);
    }

    /**
     * 判断是否需要包装请求
     * <p>
     * 仅对以下请求进行包装:
     * 1. POST、PUT、PATCH方法
     * 2. Content-Type包含application/json、application/xml或multipart/form-data
     * </p>
     */
    private boolean shouldWrapRequest(HttpServletRequest request) {
        String method = request.getMethod();
        String contentType = request.getContentType();

        // 只处理POST、PUT、PATCH请求
        if (!HttpMethod.POST.matches(method) &&
            !HttpMethod.PUT.matches(method) &&
            !HttpMethod.PATCH.matches(method)) {
            return false;
        }

        // Content-Type为空,不处理
        if (contentType == null) {
            return false;
        }

        // 处理JSON、XML和表单数据
        return contentType.contains(MediaType.APPLICATION_JSON_VALUE) ||
               contentType.contains(MediaType.APPLICATION_XML_VALUE) ||
               contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
    }
}

评论