Spring MVC 如何彻底解决request body重复读取?

前言

常规堆代码的CRUD阶段,很少会在Controller内再次读取request body,因为大部分所需要的参数Spring MVC 框架已经帮我们注入到了方法形参之中,大家只需要取用便可,所以大多数人不会遇到这个问题,如果你有特定需求,例如,在实际业务处理之前,过滤请求的参数,这就不得不提前读取request body,从而引发request body重复读的问题。

其实request body 不可重复读跟什么框架没有关系,主要是HTTP协议传输过程属于网络流,不可重复读是正常的,想要重复读,唯有缓存它。

应对策略

Spring提供了2个类(ContentCachingRequestWrapper和ContentCachingResponseWrapper)来解决请求和相应重复读取和写入的问题,这里我们关注ContentCachingRequestWrapper,他通过包装一个HttpServletRequest实现request 的重复读取。但是要让他生效,需要一番波折,因为这跟请求的完整流程有关

//ContentCachingRequestWrapper.java
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {

    //缓存流
	private final ByteArrayOutputStream cachedContent;

    //需要包装的流
	@Nullable
	private ServletInputStream inputStream;



    //构造函数包装流
	public ContentCachingRequestWrapper(HttpServletRequest request) {
		super(request);
		int contentLength = request.getContentLength();
		this.cachedContent = new ByteArrayOutputStream(contentLength >= 0 ? contentLength : 1024);
		this.contentCacheLimit = null;
	}

    //构造函数包装流
	public ContentCachingRequestWrapper(HttpServletRequest request, int contentCacheLimit){
		super(request);
		this.cachedContent = new ByteArrayOutputStream(contentCacheLimit);
		this.contentCacheLimit = contentCacheLimit;
	}
}

ContentCachingRequestWrapper的坑

不要以为使用了ContentCachingRequestWrapper类就可以完美解决问题,其实这个类有致命的坑,稍有不慎就会全军覆没。

第一个坑:

        ContentCachingRequestWrapper何时缓存Stream?

        其实是第一次读取时才缓存,缓存好之后,再次读取并不是缓存,第一大坑。

        

//ContentCachingRequestWrapper.java
public ServletInputStream getInputStream() throws IOException {
        //第一次读取的时候,其实是从内部类ContentCachingInputStream 中读取
		if (this.inputStream == null) {
             //将外部流传入内部类,内部类第一次读取时,会写入缓存流。
            //所以第一次都是可以读取的到的。
			this.inputStream = new ContentCachingInputStream(getRequest().getInputStream());
		}

        //第二次读取时,直接返回内部类,但是内部类持有的外部流已经读取完毕,所以其实是无法
        //重复读取的,它没有利用内部中的缓存流
		return this.inputStream;
}

//这就是那个内部类
private class ContentCachingInputStream extends ServletInputStream {

		private final ServletInputStream is;

		private boolean overflow = false;

		public ContentCachingInputStream(ServletInputStream is) {
            //持有外部流
			this.is = is;
		}

		@Override
		public int read() throws IOException {
            //读取流,其实读取的是外部流,顺便缓存一份到缓存流cachedContent
			int ch = this.is.read();
			if (ch != -1 && !this.overflow) {
				if (contentCacheLimit != null && cachedContent.size() == contentCacheLimit) {
					this.overflow = true;
					handleContentOverflow(contentCacheLimit);
				}
				else {
                    //读取的时候写入了缓存流。
					cachedContent.write(ch);
				}
			}
			return ch;
		}
}

第二个坑:

        ContentCachingRequestWrapper要怎么样读取到缓存流

//ContentCachingRequestWrapper.java
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {

    //在这里读取缓存流了。但是外部的API 都是调用ContentCachingRequestWrapper#getInputStream()
    //所以享受不到缓存的好处。只能想办法改写它
    public byte[] getContentAsByteArray() {
		return this.cachedContent.toByteArray();
	}
}

如何破解它的坑?只有自己辛苦一下了,本着用最小的代码干最大的事的原则,通过源码分析,其实只需要切换一下流的读取顺序就好了。所以我们重写ContentCachingRequestWrapper类

//继承ContentCachingRequestWrapper 
public class ContentCachingRequestWrapperNew extends ContentCachingRequestWrapper {

    //原子变量,用来区分首次读取还是非首次
    private AtomicBoolean isFirst = new AtomicBoolean(true);

    public ContentCachingRequestWrapperNew(HttpServletRequest request) {
        super(request);
    }

    public ContentCachingRequestWrapperNew(HttpServletRequest request, int contentCacheLimit) {
        super(request, contentCacheLimit);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {

        if(isFirst.get()){
            //首次读取直接调父类的方法,这一次执行完之后 缓存流中有数据了
            //后续读取就读缓存流里的。
            isFirst.set(false);
            return super.getInputStream();
        }

        //用缓存流构建一个新的输入流
        return new ServletInputStreamNew(super.getContentAsByteArray());
    }

    //参考自 DelegatingServletInputStream
    class ServletInputStreamNew extends ServletInputStream{

        private InputStream sourceStream;

        private boolean finished = false;



        public ServletInputStreamNew(byte [] bytes) {
            //构建一个普通的输入流
            this.sourceStream = new ByteArrayInputStream(bytes);
        }


        @Override
        public int read() throws IOException {
            int data = this.sourceStream.read();
            if (data == -1) {
                this.finished = true;
            }
            return data;
        }

        @Override
        public int available() throws IOException {
            return this.sourceStream.available();
        }

        @Override
        public void close() throws IOException {
            super.close();
            this.sourceStream.close();
        }

        @Override
        public boolean isFinished() {
            return this.finished;
        }

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

        @Override
        public void setReadListener(ReadListener readListener) {
            throw new UnsupportedOperationException();
        }
    }

}

应用

有了加强版的ContentCachingRequestWrapper,此时只需要将它应用到请求的完整流程中去就好了。根据Servlet特性,执行靠前的是Filter。所以,想办法从Filter中,将新的包装对象传递下去。

开始传递ContentCachingRequestWrapperNew

//ContentCachingRequestWrapperFilter.java
public class ContentCachingRequestWrapperFilter  implements OrderedFilter {

            @Override
            public int getOrder() {
                //顺序控制要看你自己的代码
                //尽量小,比如说我这里是OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER-106
                //REQUEST_WRAPPER_FILTER_MAX_ORDER变量是spring 官方推荐的顺序
                //但是直接使用可能也会有坑,你可以自己查一下。
                //因为有一个spring boot 默认扩展的过滤OrderedRequestContextFilter
                //它使用的是REQUEST_WRAPPER_FILTER_MAX_ORDER - 105
                //所以为了尽可能早一点,你自己根据你的情况调整顺序
                return OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER-106;
            }

            @Override
            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
                //传递包装类下去。这样后面的servlet等可以拿到这个包装后的request
                chain.doFilter(new ContentCachingRequestWrapperNew((HttpServletRequest)request),response);
            }

}

通过这一顿操作,后续读取的API统一到ServletRequest#getInputStream上了,这样可以兼容更多的代码(面向抽象编程)。关于@RequestBody不可重复读的问题也迎刃而解了(如果没解决,请查看你的过滤器顺序,只要尽量靠前就没问题)

结束

可能你还有疑虑!!全局获取request的地方会有问题吗?

RequestContextHolder.getRequestAttributes()获取的请求上下文对象好像还没解决,它好像还不是包装之后的请求对象。

大可不必担心。RequestContextHolder中的线程变量在多个地方多有初始化

除了最常见的RequestContextListener监听器中有初始化,它在众多过滤器中都有初始化

例如OrderedRequestContextFilter有初始化(spring boot 自带这个过滤器)

例如在FrameworkServlet中也有初始化。所以不要担心请求上下文获取不到你的包装request。

只要你的过滤器执行能尽可能靠前,那么后续这些获取的request就一定是你包装后的request,

然后他们将这个request设置到RequestContextHolder.setRequestAttributes中,这样你全局获取的request也是包装后的,也是可重复读的。

至于RequestContextListener还要不要注册,我觉得已经无所谓了。。。。

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值