Contents

HTTP2 下的 Transfer-Encoding: chunked

Warning
本文最后更新于 February 28, 2022,文中内容可能已过时,请谨慎使用。

HTTP 中传输数据有一个 chunked 的方式, 又称“分块传输”。在响应报文里用头字段Transfer-Encoding: chunked 来表示。意思是报文里的 body 部分不是一次性发过来的,而是分成了许多的块(chunk)逐个发送。而 HTTP2.0 协议作为 HTTP协议的升级,自然是对chunked模式做支持?不然!

HTTP2 是没有 chunked 的!

分块传输也可以用于“流式数据”,例如由数据库动态生成的表单页面,这种情况下 body 数据的长度是未知的,无法在头字段“Content-Length”里给出确切的长度,所以也只能用 chunked 方式分块发送。

chunked 的编码规则

  • 每个分块包含两个部分,长度头和数据块;
  • 长度头是以 CRLF(回车换行,即\r\n)结尾的一行明文,用 16 进制数字表示长度;
  • 数据块紧跟在长度头后,最后也用 CRLF 结尾,但数据不包含 CRLF;
  • 最后用一个长度为 0 的块表示结束,即“0\r\n\r\n”

HTTP2 下的分块传输

先说结论,HTTP2 是不支持 HTTP1 语义下的 chunked 模式的。因为H2的 Data 帧是纯天然的Chunked模式。

这也是最容易出bug的地方,一些实现不完全的HTTP2开源库经常在这里出问题。因为我们线上都是客户端请求基本都是 chunked 模式,升级到 HTTP2 之后,经常访问一些三方链接访问卡死,最终 debug 后的原因发现出在服务端对 HTTP2 chunked  的支持上。

最常见的反向代理实现 Nginx 就是最容易有这种 bug的,很多企业维护的Nginx经常不更新,而低版本的NginxHTTP2上的这个bug就被我们遇到过。

具体现象是,客户端使用chunked模式上传,但是服务端开启了 HTTP2,自然客户端也就升级到 HTTP2 。但是请求总是卡主,服务端无响应。最终定位到如果 DATA帧不携带内容,只携带一个 End Stream 标志,服务端无法识别流式传输结束。我拿 www.bing.com 做了对比,其他网站都是正常的。

其实 RFC 7540 早有规定,HTTP2 的传输是不支持 Transfer-Encoding: chunked 的。

一般的网络库(如 CronetOKHTTP )底层都给我们做了兼容,如果上层使用 chunked 模式传输,而实际使用的是 HTTP2 ,网络库会帮我们自动隐藏掉 Transfer-Encoding: chunked 这个 Header

使用 HTTP2 发送 chunked

这里我们以 Golang 的 HTTP2 官方的客户端库做一个测试.

 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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
func main() {
	rd, wr := io.Pipe()
	u, _ := url.Parse("https://httpbin.org/post")


	req := &http.Request{
		Method:           "POST",
		ProtoMajor:       1,
		ProtoMinor:       1,
		URL:              u,
		TransferEncoding: []string{"chunked"},
		Body:             rd,
		Header:           make(map[string][]string),
	}
	req.Header.Set("Content-Type", "application/x-protobuf")
	var client *http.Client
	var transport *http2.Transport

	sslkeylogfile, err := os.OpenFile("/tmp/sslkey.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
	if err != nil {
		panic(err)
	}
	defer sslkeylogfile.Close()

	transport = &http2.Transport{}
	var config *tls.Config = &tls.Config{
		InsecureSkipVerify: true,
		KeyLogWriter:       sslkeylogfile,
	}
	transport.TLSClientConfig = config
	client = &http.Client{Transport: transport}

	go func() {
		buf := make([]byte, 8000)
		str := ""
		for i := 0; i < 100000; i++ {
			str += "0"
		}

		f := strings.NewReader(str)
		for {
			n, _ := f.Read(buf)
			if 0 == n {
				break
			}
			wr.Write(buf)
		}
		wr.Close()

	}()

	resp, err := client.Do(req)
	if nil != err {
		fmt.Println("error =>", err.Error())
		return
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if nil != err {
		fmt.Println("error =>", err.Error())
	} else {
		fmt.Println(string(body))
	}
}

通过导入 SSLKEYLOGWireshark 抓包可以看到,每个 Data Frame 大小为 8000 。