专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

SpringBoot反复读取Body失效?Wrapper缓存方案一次根治!

ins518 2025-10-19 05:52:39 技术文章 2 ℃ 0 评论

SpringBoot反复读取Body失效?Wrapper缓存方案一次根治!

声明

本文中的所有案例代码、配置仅供参考,如需使用请严格做好相关测试及评估,对于因参照本文内容进行操作而导致的任何直接或间接损失,作者概不负责。本文旨在通过生动易懂的方式分享实用技术知识,欢迎读者就技术观点进行交流与指正。


1. 引言:Body“一次性”的痛点

在 SpringBoot2.x 中,过滤器、拦截器、AOP、全局日志……处处都想读 Request Body,结果:

o第一次 getInputStream() 成功,第二次抛 IllegalStateException

o日志切面读完,Controller 拿到空 Body,JSON 解析直接 400

o线上偶发“参数丢失”,排查复现困难,心态炸裂

痛点本质:HTTP 协议基于流,默认只能读一次。
解决方向:把“一次性流”变成“可重放缓存”。
答案钥匙:HttpServletRequestWrapper + 本地缓存 + Spring 注入优先级。


2. 背景知识:从 Servlet 到 SpringBoot

2.1 Servlet 请求体系

Servlet 容器把请求拆成两行数据:

oHeader → 随机访问(getHeader 任意次)

oBody → 输入流(getInputStream / getReader 只能读一次)

2.2 为什么只能读一次?

底层 socket 数据一旦消费,内核缓冲区清空;重复读需要额外缓存。

2.3 SpringBoot 的介入点

SpringBoot 自动注册 Filter 顺序在 DispatcherServlet 之前,天然适合“提前缓存”。


3. 问题分析:重复读取的三座大山

挑战

现象

根源

① 流已关闭

第二次读抛异常

容器实现 ServletInputStream 状态机

② 编码不一致

打印中文乱码

多次 getReader 可能用不同 Charset

③ 大报文 OOM

并发上传 10MB 文件直接打挂

内存缓存无限制

核心本质:需要“可重复 + 可控制 + 可释放”的缓存策略。


4. 解决方案详解:Wrapper + 缓存 + 注入

4.1 总体架构

图解释

o请求进入容器后,第一时间被最高优先级 Filter 拦截

o用自定义 Wrapper 把原始 Request 替换为“缓存版”

o后续全链路(Filter、Interceptor、AOP、Controller)拿到的是同一缓存副本,可无限次读

4.2 核心组件说明

组件

职责

CachedHttpServletRequest

重写 getInputStream()、getReader(),返回缓存流

CacheRequestFilter

优先级最高,负责包装并向下传递

CachedServletInputStream

自定义输入流,基于 ByteArrayInputStream

SpringBootAutoConfig

自动注册 Filter,零配置集成

4.3 关键实现细节

1.仅缓存 Body,Header 仍委托原始请求,保证性能

2.使用 Content-Length 或 maxInMemorySize 限制内存占用,超大报文可落盘

3.线程安全:一次请求一个 Wrapper 实例,无共享状态

4.重复读取性能:内存数组 ByteArrayInputStream,重置游标即可,常数级开销


5. 实践案例:

5.1 项目结构

springboot-cache-request
├── src
│   └── main
│       ├── java
│       │   └── 「包名称,请自行替换」
│       │       ├── SpringbootCacheRequestApplication.java
│       │       ├── config
│       │       │   └── CacheRequestAutoConfig.java
│       │       ├── filter
│       │       │   └── CacheRequestFilter.java
│       │       ├── wrapper
│       │       │   └── CachedHttpServletRequest.java
│       │       └── controller
│       │           └── DemoController.java
│       └── resources
│           └── application.yml
└── pom.xml

5.2 Maven 依赖(SpringBoot 2.7.18)

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>「包名称,请自行替换」</groupId>
  <artifactId>springboot-cache-request</artifactId>
  <version>1.0.0</version>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
  </parent>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 如需落盘扩展可引入 commons-io -->
  </dependencies>
</project>

5.3 自动配置类

package 「包名称,请自行替换」.config;


import 「包名称,请自行替换」.filter.CacheRequestFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;


@Configuration
public class CacheRequestAutoConfig {
    /**
     * 注册最高优先级过滤器,保证最先包装 Request
     */
    @Bean
    public FilterRegistrationBean<CacheRequestFilter> cacheRequestFilter() {
        FilterRegistrationBean<CacheRequestFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new CacheRequestFilter());
        bean.addUrlPatterns("/*");
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE); // 关键
        return bean;
    }
}

5.4 自定义 Wrapper

package 「包名称,请自行替换」.wrapper;


import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;


public class CachedHttpServletRequest extends HttpServletRequestWrapper {
    /** 缓存字节数组,包级可见便于测试 */
    private byte[] bodyCache;


    public CachedHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        // 仅缓存一次,后续任意次读取
        try (InputStream is = request.getInputStream()) {
            // 安全提示:可自行限制最大长度,防止大报文 OOM
            bodyCache = is.readAllBytes(); // JDK9+,低版本可用 commons-io IOUtils
        }
    }


    @Override
    public ServletInputStream getInputStream() {
        return new CachedServletInputStream(bodyCache);
    }


    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream(), getCharacterEncoding()));
    }


    /* ================= 自定义输入流 ================= */
    private static class CachedServletInputStream extends ServletInputStream {
        private final ByteArrayInputStream buffer;


        public CachedServletInputStream(byte[] data) {
            this.buffer = new ByteArrayInputStream(data);
        }


        @Override
        public boolean isFinished() {
            return buffer.available() == 0;
        }
        @Override
        public boolean isReady() {
            return true;
        }
        @Override
        public void setReadListener(ReadListener listener) {
            throw new UnsupportedOperationException("异步 IO 未实现");
        }
        @Override
        public int read() {
            return buffer.read();
        }
    }
}

5.5 过滤器

package 「包名称,请自行替换」.filter;


import 「包名称,请自行替换」.wrapper.CachedHttpServletRequest;


import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;


public class CacheRequestFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // 只处理 HTTP
        if (request instanceof HttpServletRequest) {
            request = new CachedHttpServletRequest((HttpServletRequest) request);
        }
        chain.doFilter(request, response);
    }
}

5.6 测试控制器(读两次)

package 「包名称,请自行替换」.controller;


import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;


@RestController
public class DemoController {
    /**
     * 控制台打印两次 Body,验证可重复读
     */
    @PostMapping("/demo")
    public String twice(@RequestBody String body, HttpServletRequest req) throws IOException {
        System.out.println("=== 1. @RequestBody 注入 ===");
        System.out.println(body);
        System.out.println("=== 2. 手动再读一次 ===");
        String again = new String(req.getInputStream().readAllBytes());
        System.out.println(again);
        return "ok";
    }
}

5.7 运行步骤

1.环境要求:JDK11+(readAllBytes 方法)

2.启动后执行:

curl -X POST http://IP:端口/demo \
     -H "Content-Type:application/json" \
     -d '{"name":"kimi"}'

3.控制台输出两次相同 JSON,证明重复读取成功


6. 进阶优化:大文件、线程池、内存保护

场景

策略

实现提示

大文件上传

阈值落盘

当 Content-Length > maxInMemorySize 时,写入临时文件,返回 FileInputStream

异步读取

零拷贝

使用 PipedInputStream + 线程池,边读边写

内存保护

对象池

对 byte[] 使用 ThreadLocal 池化,降低 GC 压力

多租户

隔离

在 Wrapper 内绑定 TenantContext,落盘文件按租户分目录


7. 总结与展望

o核心回顾

HttpServletRequestWrapper 通过“提前缓存 + 包装替换”把一次性流变成可重放流,解决 SpringBoot 全链路重复读取难题

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表