写在前面
最近遇到一个很尴尬的问题,前端传给后端的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流形式进行返回。