写点什么

SpringBoot 项目中 HTTP 请求体只能读一次?试试这方案

  • 2024-08-07
    山东
  • 本文字数:3324 字

    阅读完需:约 11 分钟

SpringBoot项目中HTTP请求体只能读一次?试试这方案

问题描述

在基于 Spring 开发 Java 项目时,可能需要重复读取 HTTP 请求体中的数据,例如使用拦截器打印入参信息等,但当我们重复调用 getInputStream()或者 getReader()时,通常会遇到类似以下的错误信息:


大体的意思是当前 request 的 getInputStream()已经被调用过了。那为什么会出现这个问题呢?

原因分析

主要原因有两个,一是 Java 自身的设计中,InputStream 作为数据管道本身只支持读取一次,如果要支持重复读取的话就需要重新初始化;二是 Servlet 容器中 Request 的实现问题,我们以默认的 Tomcat 为例,可以发现在 Request 有两个 boolean 类型的属性,分别是 usingReader 和 usingInputStream,当调用 getInputStream()或 getReader()时会分别检查两个属性的值,并在执行后将对应的属性设置为 true,如果在检查时变量的值已经为 true 了,那么就会报出以上错误信息。


解决方案

不太可行的方案:简单粗暴的反射机制

涉及到变量的修改,我们首先想到的就是有没有提供方法进行修改,不过可惜的是 usingReader 和 usingInputStream 并未提供,所以想要在使用过程中修改这两个属性估计只能靠反射了,在使用过程中每次调用后通过反射将 usingReader 和 usingInputStream 设置为 false,每次根据读取出的内容把数据流初始化回去,理论上就可以再次读取了。


首先说反射机制本身就是通过破坏类的封装来实现动态修改的,有点过于粗暴了,其次也是主要原因,我们只能针对我们自己实现的代码进行处理,框架本身如果调用 getInputStream()和 getReader()的话,我们就没法通过这个办法干预了,所以这个方案在给予 Spring 的 Web 项目中并不可行。

理论上可行的方案:HttpServletRequest 接口

HttpServletRequest 是一个接口,理论上我们只需要创建一个实现类就可以自定义 getInputStream()和 getReader()的行为,自然也就能解决 RequestBody 不能重复读取的问题,但这个方案的问题在于 HttpServletRequest 有 70 个方法,而我们只需要修改其中两个而已,通过这种方式去解决有点得不偿失。

部分场景可行的方案:ContentCachingRequestWrapper

Spring 本身提供了一个 Request 包装类来处理重复读取的问题,即 ContentCachingRequestWrapper,其实现思路就是在读取 RequestBody 时将内存缓存到它内部的一个字节流中,后续读取可以通过调用 getContentAsString()或 getContentAsByteArray()获取到缓存下来的内容。


之所以说这个方案是部分场景可行主要是两个方面,一是 ContentCachingRequestWrapper 没有重写 getInputStream()和 getReader()方法,所以框架中使用这两个方法的地方依然获取不到缓存下来的内容,仅支持自定义的业务逻辑;第二点和第一点有所关联,因为其没有修改 getInputStream()和 getReader()方法,所以我们在使用时只能在使用 RequestBody 注解后使用 ContentCachingRequestWrapper,否则就会出现 RequestBody 注解修饰的参数无法正常读取请求体的问题,也就限定了它的使用范围如下图所示:



如果仅需要在业务代码后再次读取请求体内容,那么使用 ContentCachingRequestWrapper 也足以满足需求,具体使用方法请参考下一节的说明。

目前的最佳实践:继承 HttpServletRequestWrapper

之前我们提到实现 HttpServletRequest 需要实现 70 个方法,所以不太可能自行实现,这个方案算是进阶版本,继承 HttpServletRequest 的实现类,之后再自定义我们需要修改的两个方法。


HttpServletRequest 作为一个接口,肯定会有其实现去支撑它的业务功能,因为 Servlet 容器的选择较多,我们也不能使用某一方提供的实现,所以选择的范围也就被限制到了 Java EE(现在叫 Jakarta EE)标准范围内,通过查看 HttpServletRequest 的实现,可以发现在标准内提供了一个包装类:HttpServletRequestWrapper,我们的方案也是围绕它展开。

思路简述

  1. 自定义子类,继承 HttpServletRequestWrapper,在子类的构造方法中将 RequestBody 缓存到自定义的属性中。

  2. 自定义 getInputStream()和 getReader()的业务逻辑,不再校验 usingReader 和 usingInputStream,且在调用时读取缓存下来的内容。

  3. 自定义 Filter,将默认的 HttpServletRequest 替换为自定义的包装类。

代码展示

  1. 继承 HttpServletRequestWrapper,实现子类 CustomRequestWrapper,并自定义 getInputStream()和 getReader()的业务逻辑


// 1.继承HttpServletRequestWrapperpublic class CustomRequestWrapper extends HttpServletRequestWrapper {
// 2.定义final属性,用于缓存请求体内容 private final byte[] content;
public CustomRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 3.构造方法中将请求体内容缓存到内部属性中 this.content = StreamUtils.copyToByteArray(request.getInputStream()); }
// 4.重新getInputStream() @Override public ServletInputStream getInputStream() { // 5.将缓存下来的内容转换为字节流 final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(content); return new ServletInputStream() { @Override public boolean isFinished() { return false; }
@Override public boolean isReady() { return false; }
@Override public void setReadListener(ReadListener listener) {
}
@Override public int read() { // 6.读取时读取第5步初始化的字节流 return byteArrayInputStream.read(); } }; }
// 7.重写getReader()方法,这里复用getInputStream()的逻辑 @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream())); }}
复制代码


  1. 自定义 Filter 将默认的 HttpServletRequest 替换为自定义的 CustomRequestWrapper


// 1.实现Filter接口,此处也可以选择继承HttpFilterpublic class RequestWrapperFilter implements Filter {    // 2. 重写或实现doFilter方法    @Override    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {        // 3.此处判断是为了缩小影响范围,本身CustomRequestWrapper只是针对HttpServletRequest,不进行判断可能会影响其他类型的请求        if (request instanceof HttpServletRequest) {            // 4.将默认的HttpServletRequest转换为自定义的CustomRequestWrapper            CustomRequestWrapper requestWrapper = new CustomRequestWrapper((HttpServletRequest) request);            // 5.将转换后的request传递至调用链中            chain.doFilter(requestWrapper, response);        } else {            chain.doFilter(request, response);        }    }}
复制代码


  1. 将 Filter 注册到 Spring 容器,这一步可以通过多种方式执行,这里采用比较传统但比较灵活的 Bean 方式注册,如果图方便可以通过 ServletComponentScan 注解+ WebFilter 注解的方式。


/** * 过滤器配置,支持第三方过滤器 */@Configurationpublic class FilterConfigure {    /**     * 请求体封装     * @return     */    @Bean    public FilterRegistrationBean<RequestWrapperFilter> filterRegistrationBean(){        FilterRegistrationBean<RequestWrapperFilter> bean = new FilterRegistrationBean<>();        bean.setFilter(new RequestWrapperFilter());        bean.addUrlPatterns("/*");        return bean;    }}
复制代码


至此我们就可以在项目中重复读取请求体了,如果选择使用 Spring 提供的 ContentCachingRequestWrapper,那么在 Filter 中将 CustomRequestWrapper 替换为 ContentCachingRequestWrapper 即可,不过需要注意在上一节提到的可用范围较小的问题。


文章内的代码可以参考 https://gitee.com/itartisans/itartisans-framework,这是我开源的一个 SpringBoot 项目脚手架,我会不定期加入一些通用功能,欢迎关注。



发布于: 刚刚阅读数: 3
用户头像

还未添加个人签名 2020-12-14 加入

还未添加个人简介

评论

发布
暂无评论
SpringBoot项目中HTTP请求体只能读一次?试试这方案_springboot_小明同学的学长_InfoQ写作社区