问题
在使用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
基于上述解决方案,我们来实现一个名为 CachedBodyHttpServletRequestWrapper 的 HttpServletRequest 装饰器类,代码如下:
版本一
@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-Type 是 multipart/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
好了,打完收工!!!!
|