• 优雅实现Spring多次读取InputStream


    问题

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

    java.io.IOException: Stream closed
    
    • 1

    原因分析

    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()));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    好了,自定义的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);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    为了保证我们自定义的过滤器优先执行,这里使用注解 @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";
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    控制台输出结果如下:

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

    这样就结束了吗?没那么简单,在 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()));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58

    再来测试一下:

    @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";
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

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

    好了,打完收工!!!!

  • 相关阅读:
    【如何重燃程序人生】
    Android结构优化 - Java、Kotlin项目结构分包
    go的context.WithTimeout学习
    Ubuntu20运行SegNeXt代码提取道路水体(一)——从零开始运行代码过程摸索
    requirements.txt用法你真的清楚吗
    文章解读与仿真程序复现思路——电力自动化设备EI\CSCD\北大核心《多时间尺度下计及综合需求响应和碳捕集-电转气联合运行的综合能源系统优化调度》
    适用于初学者的 .NET MAUI
    央视纪录:全球首创 “佛脸识别技术”,探索文物虚拟修复
    到达终点数字
    Python中将列表拆分为大小为N的块
  • 原文地址:https://blog.csdn.net/lingxi0726/article/details/126974541