一、http中 chunked 与 content-length的区别

熟悉HTTP协议的朋友都知道,在HTTP的回复报文中一般使用content-length定义body的长度,当浏览器读取报文的时候会根据content-length来开启buffer接收数据,这个content-length必须如实准确反应HTTP body报文的长度,否则将会出现报文不完整或者pendding的情况。

现在使用content-length将会产生两个问题:

  • 1、不能边读取一边渲染,导致整个画面需要在数据加载后才能呈现,速度慢不说,加载突兀,如果在数据包传输不完整,将会导致整个画面无法展示。
  • 2、服务器必须计算完content-length的值才能开始传输。

为了使用web2.0的需求,在HTTP 1.1版本加入了Transfer-Encoding = chunked 的方式解决这个问题。Transfer-Encoding = chunked 告诉浏览器这是以 chunked 编码方式来传输的报文,使得浏览器边加载内容边渲染,完全不担心HTTP数据包的大小。由于边加载边渲染,在渲染上不用等待,也不需要校验数据是否完整,在体验效果上会优于content-length,而且加载和传输的速度更快,还有小避免pending(数据等待)的问题。

参考文章说明:HTTP 协议中的 Transfer-Encoding

二、spring boot 为何没有返回content-length ?

要回答这个问题就要搞清楚spring boot在回复报文的时候发生那些流程,下面我们来简单回顾下从return语句到HTTP报文发生了什么。本文并不打算打开源码研读,只是列举简单的步骤进行说明。

  • 1、首先API在return 一个Object交给框架,框架接收到后将会将Object序列化成Json String。
  • 2、Json String将会扔给Servlet框架,Servlet收到Json String后将会写到Response中,然后数据流到传输层传输到客户端中。

那么Servlet将Json String写到Response中,使用content-length还是Transfer-Encoding = chunked来定义Header头呢?经过测试发现,Spring Boot会根据具体的情况来决定使用content-length 或者 Transfer-Encoding = chunked,如果API 函数直接return String类型的数据,那么Spring Boot默认使用 content-length 来定义 Header头,如果return的是类型是一个Object,那么默认使用Transfer-Encoding = chunked 进行分片传输。

三、spring boot返回 content-length 两种方法

最近对接下位机的时候发现,下位机由于功能比较弱,不能开启大buffer来接收数据而且不支持分片接收。因此,只能通过定义content-length来告诉接收方这个包多大,需要开启多大的buffer。

那么,spring boot 如果返回携带 content-length 头部的Response?其实在上面的分析过程中已经给出其中的一种方式,那就是在API中直接返回String类型,spring boot框架检测到返回类型是String时,将自动使用 content-length 作为Response Http Header。

第二种方式,使用HttpResponseWrapper来重写回复包。相关的代码如下:

/**
 * (废弃原因:因为发现api返回类型换成string,默认也会在response的 header中添加 content-length 字段)
 * java servlet 方向的过滤器,区别于gateway过滤器。
 * 1、servlet类的操作可以执行一些比较底层的操作。
 * 2、spring boot 就是基于servlet
 */
//@Order(2)
//@WebFilter(urlPatterns = "/*", filterName = "responseFilter")
@Deprecated
public class HttpStreamFilter implements Filter {

    private static final Logger logger = LoggerFactory.getLogger(HttpStreamFilter.class);

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

        logger.info("HttpStreamFilter start");
        CustomizeResponseWrapper responseWrapper = new CustomizeResponseWrapper((HttpServletResponse) servletResponse);
        filterChain.doFilter(servletRequest, responseWrapper);
        byte[] bytes = responseWrapper.getBytes();
        servletResponse.setContentLength(bytes.length);
        servletResponse.setContentType("text/plain");
        ((HttpServletResponse) servletResponse).setStatus(200);
        servletResponse.getOutputStream().write(bytes);
        servletResponse.flushBuffer();
        servletResponse.getOutputStream().close();
        logger.info("HttpStreamFilter end");
    }

    public static class CustomizeResponseWrapper extends HttpServletResponseWrapper {

        private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        private final PrintWriter printWriter = new PrintWriter(outputStream);

        public CustomizeResponseWrapper(HttpServletResponse response) {
            super(response);
        }

        @Override
        public PrintWriter getWriter() throws IOException {
            return printWriter;
        }

        @Override
        public ServletOutputStream getOutputStream() throws IOException {
            return new ServletOutputStream() {
                @Override
                public boolean isReady() {
                    return false;
                }

                @Override
                public void setWriteListener(WriteListener listener) {

                }

                @Override
                public void write(int b) throws IOException {
                    //写入缓存
                    outputStream.write(b);
                }
            };
        }

        public void flush(){
            try {
                printWriter.flush();
                printWriter.close();
                outputStream.flush();
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        public ByteArrayOutputStream getByteArrayOutputStream(){
            return outputStream;
        }


        public byte[] getBytes() {
            flush();
            return outputStream.toByteArray();
        }
    }
}

代码解释:

  • 1、通过Wrapper的方式截获Response报文,将报文copy 到本地buffer中。
  • 2、计算buffer的长度a,重新打开response的通道写入 content-length = a,接着将buffer写入到Response的body当中。

注意:这里计算buffer的时候,不同的编码可能长度结果不一样,所以计算的时候要额外小心。

虽然这两个方法能够成功将content-length暴露给客户端,但是经过测试发现,讲过网关后的程序,content-length将会有可能被改写成 Transfer-Encoding = chunked 。为何发生这种情况呢?

其实很容易理解,因为系统是一个微服务架构,微服务是进程与进程之间的通信。spring boot api携带content-legnth返回网关的时候,此时可以把网关作为客户端看待,那么上一个spring boot已经完成自己的传输。结论:api其实携带有content-legnth有,只是网关没有完全copy上一个进程中的header头到本进程,所以实际客户端接收到的header可能有偏差。如图所示:

微信截图_20201010094648.png

四、其他不返回content-length 的情况

根据第三点我们知道,即使业务进程返回 content-length 信息,但是网关依然可能会改写,那么什么情况下将不会返回content-length呢?

回答:当前端的Request Header传入"Accept-Encoding" = "gzip,deflate"时,假如Http response的回复包比较小则使用content-length;假如回复包比较大,则网关会使用Transfer-Encoding = chunked 进行声明,进而强制分包分片进行传输。

如果前端不支持Transfer-Encoding = chunked 或者支持起来很麻烦,则要求前端在请求的时候无需传入"Accept-Encoding" = "gzip",网关就不会强制分包分片进行传输。