优雅实现Spring多次读取InputStream

问题

在使用Spring的时候,很多人遇到过一个这样的问题,就是当我们想要在自己的业务代码中通过 HttpServletRequest 获取当前请求的流时,会报如下异常信息:

java.io.IOException: Stream closed

原因分析

HttpServletRequest 中的输入流只能读取一次,默认情况下在Spring帮我们处理了反序列化等操作之后,流已经关闭了,如果这个时候再想从 Request 中读取 body 等信息,就会报以上异常。

首先我们来看看为什么 HttpServletRequest 的输入流只能读取一次,当我们调用 getInputStream() 方法获取输入流时,得到的一个是一个 InputStream 对象,而实际类型是 ServletInputStream,它继承于 InputStream

InputStream 中的 read() 方法内部有一个 position,标志当前流被读取到的位置,每读取一次,该标志就会移动一次,如果读到最后,read()会返回 -1,表示已经读取完了。如果想要重新读取,则需要调用 reset() 方法,position就会移动到上次调用 mark() 方法的位置,mark 默认是0,所以就能从头再读了。调用 reset() 方法的前提条件是已经重写了 reset() 方法,当然能否 reset 也是有条件的,它取决于 markSupported() 方法是否返回 true

InputStream 默认是不实现 reset() 方法的,并且 markSupported() 方法返回 false,如下所示:
在这里插入图片描述
在这里插入图片描述
而Servlet提供的 ServletInputStream 中也是没有实现这两个方法的,因此可以判定 ServletInputStream 天生也不支持重复读取。

解决方案

既然明白了为什么在 Java Web 中无法重复读取 InputStream ,那么解决问题的办法的思路就很明显了—— 缓存流数据。即在请求达到时,我们只需要想办法把请求的流数据缓存起来,这样再次读取时直接读取我们缓存的即可。

既然有了思路,那么接下来就是如何来实现的问题了 ,既然 “罪魁祸首” ServletInputStream 无法支持重复读取,基于OOP以及设计模式的思想,要想扩展(增强)一个类,首先想到的就是 装饰器模式,而万幸的是Java 也想到了这个点,因此为我们提供了一个用于扩展 ServletInputStream 的包装器类,也是我们今天的主角 —— HttpServletRequestWrapper

使用 HttpServletRequestWrapper 我们可以轻而易举的自定义我们自己的 Request ,那么接下来就是思考如何缓存输入流数据了。

我们知道基于流的交互,数据主体格式都是二进制,而二进制可以使用 byte[] 来进行表示。因此我们的解决方法呼之欲出了——使用字节数组缓存输入流。

自定义HttpServletRequest

基于上述解决方案,我们来实现一个名为 CachedBodyHttpServletRequestWrapperHttpServletRequest 装饰器类,代码如下:

版本一

@Slf4j
public class CachedBodyHttpServletRequestWrapper extends HttpServletRequestWrapper {

    /**
     * 保存流便于重复读取
     */
    private final byte[] cachedBody;

    public CachedBodyHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

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

            @Override
            public void setReadListener(ReadListener listener) {

            }

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

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

好了,自定义的Request我们封装好了,接下来需要考虑如何使我们自定义的包装类生效呢?要解决这个问题,我们需要先了解对于Java Web应用来说,一个请求从客户端到达我们的控制器层中间需要经历哪些组件,如下图所示:
在这里插入图片描述
从上图中我们可以清楚的知道请求从 Web 容器转发到我们的程序中时,最先经过的就是过滤器层(Filter),因此我们如果想要使得我们自定义的 CachedBodyHttpServletRequestWrapper 生效,并且后续其他层读取流产生影响(可重复读),最好的方式就是在过滤器层对 Java 原生的 HttpServletRequest 进行 “偷梁换柱”

自定义Filter

在基于Spring开发的项目中,自定义Filter的方式有很多,这里我推荐使用继承 Spring 提供的 OncePerRequestFilter 抽象类来实现,该抽象类从字面意思上来看就知道通过这种方式定义的过滤器,每次请求只会调用一次(这是Spring为了兼容不同容器或不同版本的Servlet对过滤器的处理方式不同造成的差异)。

@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CachedBodyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("Invoke the CachedBodyFilter to wrapper the HttpServletRequest object.");
        filterChain.doFilter(new CachedBodyHttpServletRequestWrapper(request),response);
    }
}

为了保证我们自定义的过滤器优先执行,这里使用注解 @Order 设置了最高优先级。

接下来我们来测试一把,测试代码如下:

@Data
@ToString
public class TestModel {

    private Long id;

    private String name;
}

@PostMapping("/test4")
    public String test4(@RequestBody TestModel model, HttpServletRequest request) throws IOException {
        System.out.println(model);
        System.out.println("再次读取InputStream");
        TestModel model1 = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8,new TypeReference<TestModel>(){}.getType());
        System.out.println(model1);
        return "Ok";
    }

控制台输出结果如下:

TestModel(id=1, name=admin)
再次读取InputStream
TestModel(id=1, name=admin)

这样就结束了吗?没那么简单,在 Spring Boot 2.1.x版本的时候这样是可以的,但是在 Spring Boot 2.2.0以后如果请求的Content-Typemultipart/form-data 或者 application/x-www-form-urlencode 那么这种失效方式就无法获取数据了。因此我们要对这种情况做特殊处理,如下代码所示:

@Slf4j
public class CachedBodyHttpServletRequestWrapper extends HttpServletRequestWrapper {

    /**
     * 保存流便于重复读取
     */
    private final byte[] cachedBody;

    public CachedBodyHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        String contentType = request.getContentType();
        //判断当前请求数据类型是否为表单提交
        if (!StringUtils.isEmpty(contentType) && (contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE) || contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE))) {
            String bodyString = "";
            Map<String, String[]> parameterMap = request.getParameterMap();
            if (!CollectionUtils.isEmpty(parameterMap)) {
                bodyString = parameterMap.entrySet().stream().map(x -> {
                    String[] values = x.getValue();
                    return x.getKey() + "=" + (values != null ? (values.length == 1 ? values[0] : Arrays.toString(values)) : null);
                }).collect(Collectors.joining("&"));
            }
            this.cachedBody = bodyString.getBytes();
        } else {
            this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
        }
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

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

            @Override
            public void setReadListener(ReadListener listener) {

            }

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

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

再来测试一下:

@PostMapping("/test2")
    @PostMapping("/test2")
    public String test2(TestModel model,HttpServletRequest request) throws IOException {
        System.out.println(model);
        System.out.println("再次读取InputStream");
        String s = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        System.out.println(s);
        return "Ok";
    }

使用Postman来测试如下图:
在这里插入图片描述
控制台输出结果如下:

TestModel(id=1, name=admin)
再次读取InputStream
id=1&name=admin

好了,打完收工!!!!

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值