写在前面

最近遇到一个很尴尬的问题,前端传给后端的JSON数据,如果开发者对此进行了拦截并进行了消费,那么后续在controller中就无法再次获取对应数据。原因在于服务端是通过IO流来解析JSON数据,而流是一种特殊的结构,只要读完就没有了,而在某些场景下往往希望可以多次读取。

举一个非常简单的例子,接口幂等性实现,即同一个接口在规定时间内多次接收到相同参数的请求,那么此时需要拒绝这些相同请求。我们在具体实现的时候,可能会先将请求中的参数提取出来,如果参数是JOSN数据,那么由于流已经读取了,因此后续在接口是无法再次获取JSON数据的。

问题再现

第一步,新建一个名为many-json的SpringBoot项目,并在其中新增Web依赖。

第二步,新建一个interceptor包,并在该包内新建一个RequestInterceptor类,这个类需要实现HandlerInterceptor接口并重写其中的preHandle方法:

1
2
3
4
5
6
7
8
9
public class RequestInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(RequestInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String line = request.getReader().readLine();
logger.info("读取的信息为:{}",line);
return true;
}
}

这里我们就简单一些,通过流将请求中的参数打印输出一下,这样流就读完了。

第三步,新建一个config包,并在该包内新建一个MyWebMvcConfig类,这个类需要实现WebMvcConfigurer接口并重写其中的addInterceptors方法:

1
2
3
4
5
6
7
@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RequestInterceptor()).addPathPatterns("/**");
}
}

这其实就是注册拦截器,并设置该拦截器对所有请求都进行拦截。

第四步,新建一个controller包,并在该包内新建一个KenBingsController类,然后提供一个名为test的接口,注意该接口中参数通过JSON格式来传递:

1
2
3
4
5
6
7
8
9
@RestController
public class KenBingsController {
private static final Logger logger = LoggerFactory.getLogger(KenBingsController.class);
@PostMapping("/test")
public String test(@RequestBody String message){
logger.info("用户输入的信息为:{}",message);
return message;
}
}

第五步,启动项目进行测试。可以看到当用户访问/test接口的时候,该请求被拦截器所拦截,因此preHandle方法将会执行,输入如下信息:

但是由于我们在test方法的参数中使用了@RequestBody注解,而该注解底层是通过解析IO流来解析JSON数据的,加上我们在拦截器中已经读取了流,因此后续接口中就得不到数据:

可是现在我们希望IO流可以被多次读取,此时该如何操作呢?可以利用装饰者模式对HttpServletRequest进行增强,即拦截HttpServletRequest请求且请求参数为JOSN格式调用新的HttpServletRequest包装类。

装饰者模式对HttpServletRequest进行增强

第一步,新建一个wrapper包,并在该包内新建一个MyRequestWrapper类,这个类需要继承HttpServletRequestWrapper类并重写其中的getInputStream和getReader方法,同时重载一下父类ServletRequestWrapper中有HttpServletRequest和HttpServletResponse对象的构造方法:

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
/**
* 自定义请求包装类
*/
public class MyRequestWrapper extends HttpServletRequestWrapper {
private final byte[] bytes;

public MyRequestWrapper(HttpServletRequest request,HttpServletResponse response) throws IOException {
super(request);
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
bytes = request.getReader().readLine().getBytes(StandardCharsets.UTF_8);
}

@Override
public ServletInputStream getInputStream() throws IOException {
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}

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

@Override
public void setReadListener(ReadListener readListener) {

}

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

@Override
public int available() throws IOException {
return bytes.length;
}
};
}

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

这其实就是自定义了一个新的HttpServletRequest类,并重载了一个包含HttpServletRequest和HttpServletResponse对象的构造方法,目的就是修改请求和响应的字符编码格式以及从IO流出读取数据,然后存入一个字节数组中,并通过重写getInputStream和getReader方法分别从字节数组中获取数据并构造IO流进行返回,这样就实现了IO流的多次读取。

第二步,新建一个filter包,并在该包内新建一个MyRequestFilter类,这个类需要实现Filter接口并重写其中的doFilter方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 请求拦截器,只有JSON数据才会使用自定义的RequestWrapper
*/
public class MyRequestFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
if(servletRequest instanceof HttpServletRequest){
HttpServletRequest request = (HttpServletRequest) servletRequest;
if(StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)){
MyRequestWrapper wrapper = new MyRequestWrapper(request,(HttpServletResponse) servletResponse);
filterChain.doFilter(wrapper,servletResponse);
return;
}
filterChain.doFilter(request,servletResponse);
}
filterChain.doFilter(servletRequest,servletResponse);
}
}

可以看到这里我们重写了doFilter方法,目的就是判断请求的类型,如果请求是HttpServletRequest且请求数据类型为JSON格式才会调用自定义的MyRequestWrapper,即将HttpServletRequest替换为MyRequestWrapper,走IO流可以多次读取的逻辑,之后让过滤器继续往下执行。

请注意,过滤器最好不要使用@Component注解交由Spring容器来管理,这样会导致每个接口都会被进行过滤,最好是开发者自己手动注册,并且配置过滤的接口。

第三步,在之前定义的MyWebMvcConfig类中将这个自定义的MyRequestFilter过滤器注册进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RequestInterceptor()).addPathPatterns("/**");
}

@Bean
FilterRegistrationBean<MyRequestFilter> myRequestFilterFilterRegistrationBean(){
FilterRegistrationBean<MyRequestFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new MyRequestFilter());
bean.addUrlPatterns("/*");
return bean;
}
}

第四步,启动项目进行测试。可以发现现在访问/test接口,Postman会返回正常数据:

查看一下控制台可以看到现在controller中也能获取到JSON数据了:

总结

通过装饰者模式对HttpServletRequest进行增强这一方式可以解决JSON重复读取问题,其本质上是对请求数据格式进行判断。如果是JOSN格式,则自定义HttpServletRequest对象,先将数据从IO流中读取,然后存入一个字节数组中,后续多次读取则是多次读取该字节数组并以IO流形式进行返回。