写点什么

Go 发起 HTTP2.0 请求流程分析 (后篇)——标头压缩

用户头像
Gopher指北
关注
发布于: 2020 年 10 月 26 日
Go发起HTTP2.0请求流程分析(后篇)——标头压缩

来自公众号:新世界杂货铺

阅读建议

这是 HTTP2.0 系列的最后一篇,笔者推荐阅读顺序如下:


  1. Go中的HTTP请求之——HTTP1.1请求流程分析

  2. Go发起HTTP2.0请求流程分析(前篇)

  3. Go发起HTTP2.0请求流程分析(中篇)——数据帧&流控制

回顾


在前篇(*http2ClientConn).roundTrip方法中提到了写入请求 header,而在写入请求 header 之前需要先编码(源码见https://github.com/golang/go/blob/master/src/net/http/h2_bundle.go#L7947)。


在中篇(*http2ClientConn).readLoop方法中提到了ReadFrame()方法,该方法会读取数据帧,如果是http2FrameHeaders数据帧,会调用(*http2Framer).readMetaFrame对读取到的数据帧解码(源码见https://github.com/golang/go/blob/master/src/net/http/h2_bundle.go#L2725)。


因为标头压缩具有较高的独立性,所以笔者基于上面提到的编/解码部分的源码自己实现了一个可以独立运行的小例子。本篇将基于自己实现的例子进行标头压缩分析(完整例子见https://github.com/Isites/go-coder/blob/master/http2/hpack-example/main.go)。

开门见山

HTTP2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用下面两种技术压缩:


  1. 通过静态哈夫曼代码对传输的标头字段进行编码,从而减小数据传输的大小。

  2. 单个连接中,client 和 server 共同维护一个相同的标头字段索引列表(笔者称为 HPACK 索引列表),此列表在之后的传输中用作编解码的参考。

本篇不对哈夫曼编码做过多的阐述,主要对双端共同维护的索引列表进行分析。

HPACK 压缩上下文包含一个静态表和一个动态表:静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。

HPACK 索引列表

认识静/动态表需要先认识headerFieldTable结构体,动态表和静态表都是基于它实现的。


type headerFieldTable struct {	// As in hpack, unique ids  are 1-based. The unique id for ents[k] is k + evictCount + 1.	ents       []HeaderField	evictCount uint64
// byName maps a HeaderField name to the unique id of the newest entry with the same name. byName map[string]uint64
// byNameValue maps a HeaderField name/value pair to the unique id of the newest byNameValue map[pairNameValue]uint64}
复制代码

下面将对上述的字段分别进行描述:


ents:entries 的缩写,代表着当前已经索引的 Header 数据。在 headerFieldTable 中,每一个 Header 都有一个唯一的 Id,以ents[k]为例,该唯一 id 的计算方式是k + evictCount + 1


evictCount:已经从 ents 中删除的条目数。


byName:存储具有相同 Name 的 Header 的唯一 Id,最新 Header 的 Name 会覆盖老的唯一 Id。


byNameValue:以 Header 的 Name 和 Value 为 key 存储对应的唯一 Id。


对字段的含义有所了解后,接下来对 headerFieldTable 几个比较重要的行为进行描述。


(*headerFieldTable).addEntry:添加 Header 实体到表中


func (t *headerFieldTable) addEntry(f HeaderField) {	id := uint64(t.len()) + t.evictCount + 1	t.byName[f.Name] = id	t.byNameValue[pairNameValue{f.Name, f.Value}] = id	t.ents = append(t.ents, f)}
复制代码

首先,计算出 Header 在 headerFieldTable 中的唯一 Id,并将其分别存入byNamebyNameValue中。最后,将 Header 存入ents


因为使用了 append 函数,这意味着ents[0]存储的是存活最久的 Header。

(*headerFieldTable).evictOldest:从表中删除指定个数的 Header 实体


func (t *headerFieldTable) evictOldest(n int) {	if n > t.len() {		panic(fmt.Sprintf("evictOldest(%v) on table with %v entries", n, t.len()))	}	for k := 0; k < n; k++ {		f := t.ents[k]		id := t.evictCount + uint64(k) + 1		if t.byName[f.Name] == id {			delete(t.byName, f.Name)		}		if p := (pairNameValue{f.Name, f.Value}); t.byNameValue[p] == id {			delete(t.byNameValue, p)		}	}	copy(t.ents, t.ents[n:])	for k := t.len() - n; k < t.len(); k++ {		t.ents[k] = HeaderField{} // so strings can be garbage collected	}	t.ents = t.ents[:t.len()-n]	if t.evictCount+uint64(n) < t.evictCount {		panic("evictCount overflow")	}	t.evictCount += uint64(n)}
复制代码


第一个 for 循环的下标是从 0 开始的,也就是说删除 Header 时遵循先进先出的原则。删除 Header 的步骤如下:


  1. 删除byNamebyNameValue的映射。

  2. 将第 n 位及其之后的 Header 前移。

  3. 将倒数的 n 个 Header 置空,以方便垃圾回收。

  4. 改变 ents 的长度。

  5. 增加evictCount的数量。


(*headerFieldTable).search:从当前表中搜索指定 Header 并返回在当前表中的 Index(此处的Index和切片中的下标含义是不一样的)


func (t *headerFieldTable) search(f HeaderField) (i uint64, nameValueMatch bool) {	if !f.Sensitive {		if id := t.byNameValue[pairNameValue{f.Name, f.Value}]; id != 0 {			return t.idToIndex(id), true		}	}	if id := t.byName[f.Name]; id != 0 {		return t.idToIndex(id), false	}	return 0, false}
复制代码

如果 Header 的 Name 和 Value 均匹配,则返回当前表中的 Index 且nameValueMatch为 true。


如果仅有 Header 的 Name 匹配,则返回当前表中的 Index 且nameValueMatch为 false。


如果 Header 的 Name 和 Value 均不匹配,则返回 0 且nameValueMatch为 false。


(*headerFieldTable).idToIndex:通过当前表中的唯一 Id 计算出当前表对应的 Index


func (t *headerFieldTable) idToIndex(id uint64) uint64 {	if id <= t.evictCount {		panic(fmt.Sprintf("id (%v) <= evictCount (%v)", id, t.evictCount))	}	k := id - t.evictCount - 1 // convert id to an index t.ents[k]	if t != staticTable {		return uint64(t.len()) - k // dynamic table	}	return k + 1}
复制代码


静态表:Index从 1 开始,且 Index 为 1 时对应的元素为t.ents[0]


动态表: Index也从 1 开始,但是 Index 为 1 时对应的元素为t.ents[t.len()-1]

静态表

静态表中包含了一些每个连接都可能使用到的 Header。其实现如下:


var staticTable = newStaticTable()func newStaticTable() *headerFieldTable {	t := &headerFieldTable{}	t.init()	for _, e := range staticTableEntries[:] {		t.addEntry(e)	}	return t}var staticTableEntries = [...]HeaderField{	{Name: ":authority"},	{Name: ":method", Value: "GET"},	{Name: ":method", Value: "POST"},  // 此处省略代码	{Name: "www-authenticate"},}
复制代码


上面的t.init函数仅做初始化t.byNamet.byNameValue用。笔者在这里仅展示了部分预定义的 Header,完整预定义 Header 参见https://github.com/golang/go/blob/master/src/vendor/golang.org/x/net/http2/hpack/tables.go#L130

动态表

动态表结构体如下:


type dynamicTable struct {	// http://http2.github.io/http2-spec/compression.html#rfc.section.2.3.2	table          headerFieldTable	size           uint32 // in bytes	maxSize        uint32 // current maxSize	allowedMaxSize uint32 // maxSize may go up to this, inclusive}
复制代码


动态表的实现是基于headerFieldTable,相比原先的基础功能增加了表的大小限制,其他功能保持不变。

静态表和动态表构成完整的 HPACK 索引列表


前面介绍了动/静态表中内部的 Index 和内部的唯一 Id,而在一次连接中 HPACK 索引列表是由静态表和动态表一起构成,那此时在连接中的 HPACK 索引是怎么样的呢?

带着这样的疑问我们看看下面的结构:



上图中蓝色部分表示静态表,黄色部分表示动态表。


H1...HnH1...Hm分别表示存储在静态表和动态表中的 Header 元素。


在 HPACK 索引中静态表部分的索引和静态表的内部索引保持一致,动态表部分的索引为动态表内部索引加上静态表索引的最大值。在一次连接中 Client 和 Server 通过 HPACK 索引标识唯一的 Header 元素。

HPACK 编码

众所周知 HTTP2 的标头压缩能够减少很多数据的传输,接下来我们通过下面的例子,对比一下编码前后的数据大小:


var (  buf     bytes.Buffer  oriSize int)henc := hpack.NewEncoder(&buf)headers := []hpack.HeaderField{  {Name: ":authority", Value: "dss0.bdstatic.com"},  {Name: ":method", Value: "GET"},  {Name: ":path", Value: "/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png"},  {Name: ":scheme", Value: "https"},  {Name: "accept-encoding", Value: "gzip"},  {Name: "user-agent", Value: "Go-http-client/2.0"},  {Name: "custom-header", Value: "custom-value"},}for _, header := range headers {  oriSize += len(header.Name) + len(header.Value)  henc.WriteField(header)}fmt.Printf("ori size: %v, encoded size: %v\n", oriSize, buf.Len())//输出为:ori size: 197, encoded size: 111
复制代码


注:在 HTTP2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异:所有标头字段名称均为小写,请求行现在拆分成各个 :method:scheme:authority:path 伪标头字段。


在上面的例子中,我们看到原来为 197 字节的标头数据现在只有 111 字节,减少了近一半的数据量!


带着一种 “卧槽,牛逼!”的心情开始对henc.WriteField方法调试。


func (e *Encoder) WriteField(f HeaderField) error {	e.buf = e.buf[:0]
if e.tableSizeUpdate { e.tableSizeUpdate = false if e.minSize < e.dynTab.maxSize { e.buf = appendTableSize(e.buf, e.minSize) } e.minSize = uint32Max e.buf = appendTableSize(e.buf, e.dynTab.maxSize) }
idx, nameValueMatch := e.searchTable(f) if nameValueMatch { e.buf = appendIndexed(e.buf, idx) } else { indexing := e.shouldIndex(f) if indexing { e.dynTab.add(f) // 加入动态表中 }
if idx == 0 { e.buf = appendNewName(e.buf, f, indexing) } else { e.buf = appendIndexedName(e.buf, f, idx, indexing) } } n, err := e.w.Write(e.buf) if err == nil && n != len(e.buf) { err = io.ErrShortWrite } return err}
复制代码


经调试发现,本例中:authority:pathaccept-encodinguser-agent走了appendIndexedName分支;:method:scheme走了appendIndexed分支;custom-header走了appendNewName分支。这三种分支总共代表了两种不同的编码方法。


由于本例中f.Sensitive默认值为 false 且 Encoder 给动态表的默认大小为 4096,按照e.shouldIndex的逻辑本例中indexing一直为 true(在笔者所使用的 go1.14.2 源码中,client 端尚未发现有使f.Sensitive为 true 的代码)。


笔者对上面e.tableSizeUpdate相关的逻辑不提的原因是控制e.tableSizeUpdate的方法为e.SetMaxDynamicTableSizeLimite.SetMaxDynamicTableSize,而笔者在(*http2Transport).newClientConn(此方法相关逻辑参见前篇)相关的源码中发现了这样的注释:


// TODO: SetMaxDynamicTableSize, SetMaxDynamicTableSizeLimit on// henc in response to SETTINGS frames?
复制代码


笔者看到这里的时候内心激动不已呀,产生了一种强烈的想贡献代码的欲望,奈何自己能力有限只能看着机会却抓不住呀,只好含恨埋头苦学(开个玩笑~,毕竟某位智者说过,写的越少 BUG 越少😄)。


(*Encoder).searchTable:从 HPACK 索引列表中搜索 Header,并返回对应的索引。


func (e *Encoder) searchTable(f HeaderField) (i uint64, nameValueMatch bool) {	i, nameValueMatch = staticTable.search(f)	if nameValueMatch {		return i, true	}
j, nameValueMatch := e.dynTab.table.search(f) if nameValueMatch || (i == 0 && j != 0) { return j + uint64(staticTable.len()), nameValueMatch }
return i, false}
复制代码


搜索顺序为,先搜索静态表,如果静态表不匹配,则搜索动态表,最后返回。

索引 Header 表示法

此表示法对应的函数为 appendIndexed,且该 Header 已经在索引列表中。


该函数将 Header 在 HPACK 索引列表中的索引编码,原先的 Header 最后仅用少量的几个字节就可以表示。


func appendIndexed(dst []byte, i uint64) []byte {	first := len(dst)	dst = appendVarInt(dst, 7, i)	dst[first] |= 0x80	return dst}func appendVarInt(dst []byte, n byte, i uint64) []byte {	k := uint64((1 << n) - 1)	if i < k {		return append(dst, byte(i))	}	dst = append(dst, byte(k))	i -= k	for ; i >= 128; i >>= 7 {		dst = append(dst, byte(0x80|(i&0x7f)))	}	return append(dst, byte(i))}
复制代码


appendIndexed知,用索引头字段表示法时,第一个字节的格式必须是0b1xxxxxxx,即第 0 位必须为1,低 7 位用来表示值。


如果索引大于uint64((1 << n) - 1)时,需要使用多个字节来存储索引的值,步骤如下:


  1. 第一个字节的最低 n 位全为 1。

  2. 索引 i 减去 uint64((1 << n) - 1)后,每次取低 7 位或上0b10000000, 然后 i 右移 7 位并和 128 进行比较,判断是否进入下一次循环。

  3. 循环结束后将剩下的 i 值直接放入 buf 中。


用这种方法表示 Header 时,仅需要少量字节就可以表示一个完整的 Header 头字段,最好的情况是一个字节就可以表示一个 Header 字段。

增加动态表 Header 表示法

此种表示法对应两种情况:一,Header 的 Name 有匹配索引;二,Header 的 Name 和 Value 均无匹配索引。这两种情况分别对应的处理函数为appendIndexedNameappendNewName。这两种情况均会将 Header 添加到动态表中。


appendIndexedName: 编码有 Name 匹配的 Header 字段。


func appendIndexedName(dst []byte, f HeaderField, i uint64, indexing bool) []byte {	first := len(dst)	var n byte	if indexing {		n = 6	} else {		n = 4	}	dst = appendVarInt(dst, n, i)	dst[first] |= encodeTypeByte(indexing, f.Sensitive)	return appendHpackString(dst, f.Value)}
复制代码


在这里我们先看看encodeTypeByte函数:


func encodeTypeByte(indexing, sensitive bool) byte {	if sensitive {		return 0x10	}	if indexing {		return 0x40	}	return 0}
复制代码


前面提到本例中 indexing 一直为 true,sensitive 为 false,所以 encodeTypeByte 的返回值一直为0x40


此时回到 appendIndexedName 函数,我们知道增加动态表 Header 表示法的第一个字节格式必须是0xb01xxxxxx,即最高两位必须是01,低 6 位用于表示 Header 中 Name 的索引。


通过appendVarInt对索引编码后,下面我们看看appendHpackString函数如何对 Header 的 Value 进行编码:


func appendHpackString(dst []byte, s string) []byte {	huffmanLength := HuffmanEncodeLength(s)	if huffmanLength < uint64(len(s)) {		first := len(dst)		dst = appendVarInt(dst, 7, huffmanLength)		dst = AppendHuffmanString(dst, s)		dst[first] |= 0x80	} else {		dst = appendVarInt(dst, 7, uint64(len(s)))		dst = append(dst, s...)	}	return dst}
复制代码


appendHpackString编码时分为两种情况:


哈夫曼编码后的长度小于原 Value 的长度时,先用appendVarInt将哈夫曼编码后的最终长度存入 buf,然后再将真实的哈夫曼编码存入 buf。


哈夫曼编码后的长度大于等于原 Value 的长度时,先用appendVarInt将原 Value 的长度存入 buf,然后再将原 Value 存入 buf。


在这里需要注意的是存储 Value 长度时仅用了字节的低 7 位,最高位为 1 表示存储的内容为哈夫曼编码,最高位为 0 表示存储的内容为原 Value。


appendNewName: 编码 Name 和 Value 均无匹配的 Header 字段。


func appendNewName(dst []byte, f HeaderField, indexing bool) []byte {	dst = append(dst, encodeTypeByte(indexing, f.Sensitive))	dst = appendHpackString(dst, f.Name)	return appendHpackString(dst, f.Value)}
复制代码


前面提到encodeTypeByte的返回值为0x40,所以我们此时编码的第一个字节为0b01000000


第一个字节编码结束后通过appendHpackString先后对 Header 的 Name 和 Value 进行编码。

HPACK 解码

前面理了一遍 HPACK 的编码过程,下面我们通过一个解码的例子来理一遍解码的过程。


// 此处省略HPACK编码中的编码例子var (  invalid    error  sawRegular bool  // 16 << 20 from fr.maxHeaderListSize() from  remainSize uint32 = 16 << 20)hdec := hpack.NewDecoder(4096, nil)// 16 << 20 from fr.maxHeaderStringLen() from fr.maxHeaderListSize()hdec.SetMaxStringLength(int(remainSize))hdec.SetEmitFunc(func(hf hpack.HeaderField) {  if !httpguts.ValidHeaderFieldValue(hf.Value) {    invalid = fmt.Errorf("invalid header field value %q", hf.Value)  }  isPseudo := strings.HasPrefix(hf.Name, ":")  if isPseudo {    if sawRegular {      invalid = errors.New("pseudo header field after regular")    }  } else {    sawRegular = true    // if !http2validWireHeaderFieldName(hf.Name) {    // 	invliad = fmt.Sprintf("invalid header field name %q", hf.Name)    // }  }  if invalid != nil {    fmt.Println(invalid)    hdec.SetEmitEnabled(false)    return  }  size := hf.Size()  if size > remainSize {    hdec.SetEmitEnabled(false)    // mh.Truncated = true    return  }  remainSize -= size  fmt.Printf("%+v\n", hf)  // mh.Fields = append(mh.Fields, hf)})defer hdec.SetEmitFunc(func(hf hpack.HeaderField) {})fmt.Println(hdec.Write(buf.Bytes()))// 输出如下:// ori size: 197, encoded size: 111// header field ":authority" = "dss0.bdstatic.com"// header field ":method" = "GET"// header field ":path" = "/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png"// header field ":scheme" = "https"// header field "accept-encoding" = "gzip"// header field "user-agent" = "Go-http-client/2.0"// header field "custom-header" = "custom-value"// 111 <nil>
复制代码

通过最后一行的输出可以知道确确实实从 111 个字节中解码出了 197 个字节的原 Header 数据。

而这解码的过程笔者将从hdec.Write方法开始分析,逐步揭开它的神秘面纱。


 func (d *Decoder) Write(p []byte) (n int, err error) {   // 此处省略代码	if d.saveBuf.Len() == 0 {		d.buf = p	} else {		d.saveBuf.Write(p)		d.buf = d.saveBuf.Bytes()		d.saveBuf.Reset()	}
for len(d.buf) > 0 { err = d.parseHeaderFieldRepr() if err == errNeedMore { // 此处省略代码 d.saveBuf.Write(d.buf) return len(p), nil } // 此处省略代码 } return len(p), err}
复制代码


在笔者 debug 的过程中发现解码的核心逻辑主要在d.parseHeaderFieldRepr方法里。


func (d *Decoder) parseHeaderFieldRepr() error {	b := d.buf[0]	switch {	case b&128 != 0:		return d.parseFieldIndexed()	case b&192 == 64:		return d.parseFieldLiteral(6, indexedTrue)    // 此处省略代码	}	return DecodingError{errors.New("invalid encoding")}}
复制代码


第一个字节与上 128 不为 0 只有一种情况,那就是 b 为0b1xxxxxxx格式的数据,综合前面的编码逻辑可以知道索引 Header 表示法对应的解码方法为d.parseFieldIndexed


第一个字节与上 192 为 64 也只有一种情况,那就是 b 为0b01xxxxxx格式的数据,综合前面的编码逻辑可以知道增加动态表 Header 表示法对应的解码方法为d.parseFieldLiteral

索引 Header 表示法

通过(*Decoder).parseFieldIndexed解码时,真实的 Header 数据已经在静态表或者动态表中了,只要通过 HPACK 索引找到对应的 Header 就解码成功了。


func (d *Decoder) parseFieldIndexed() error {	buf := d.buf	idx, buf, err := readVarInt(7, buf)	if err != nil {		return err	}	hf, ok := d.at(idx)	if !ok {		return DecodingError{InvalidIndexError(idx)}	}	d.buf = buf	return d.callEmit(HeaderField{Name: hf.Name, Value: hf.Value})}
复制代码


上述方法主要有三个步骤:


  1. 通过readVarInt函数读取 HPACK 索引。

  2. 通过d.at方法找到索引列表中真实的 Header 数据。

  3. 将 Header 传递给最上层。d.CallEmit最终会调用hdec.SetEmitFunc设置的闭包,从而将 Header 传递给最上层。


readVarInt:读取 HPACK 索引


func readVarInt(n byte, p []byte) (i uint64, remain []byte, err error) {	if n < 1 || n > 8 {		panic("bad n")	}	if len(p) == 0 {		return 0, p, errNeedMore	}	i = uint64(p[0])	if n < 8 {		i &= (1 << uint64(n)) - 1	}	if i < (1<<uint64(n))-1 {		return i, p[1:], nil	}
origP := p p = p[1:] var m uint64 for len(p) > 0 { b := p[0] p = p[1:] i += uint64(b&127) << m if b&128 == 0 { return i, p, nil } m += 7 if m >= 63 { // TODO: proper overflow check. making this up. return 0, origP, errVarintOverflow } } return 0, origP, errNeedMore}
复制代码


由上述的 readVarInt 函数知,当第一个字节的低 n 为不全为 1 时,则低 n 为代表真实的 HPACK 索引,可以直接返回。


当第一个字节的低 n 为全为 1 时,需要读取更多的字节数来计算真正的 HPACK 索引。


  1. 第一次循环时 m 为 0,b 的低 7 位加上(1<<uint64(n))-1并赋值给 i


  1. 后续循环时 m 按 7 递增,b 的低 7 位会逐步填充到 i 的高位上。


  1. 当 b 小于 128 时结速循环,此时已经读取完整的 HPACK 索引。


readVarInt函数逻辑和前面appendVarInt函数逻辑相对应。


(*Decoder).at:根据 HPACK 的索引获取真实的 Header 数据。


func (d *Decoder) at(i uint64) (hf HeaderField, ok bool) {	if i == 0 {		return	}	if i <= uint64(staticTable.len()) {		return staticTable.ents[i-1], true	}	if i > uint64(d.maxTableIndex()) {		return	}	dt := d.dynTab.table	return dt.ents[dt.len()-(int(i)-staticTable.len())], true}
复制代码


索引小于静态表长度时,直接从静态表中获取 Header 数据。


索引长度大于静态表时,根据前面介绍的 HPACK 索引列表,可以通过dt.len()-(int(i)-staticTable.len())计算出 i 在动态表ents的真实下标,从而获取 Header 数据。

增加动态表 Header 表示法

通过(*Decoder).parseFieldLiteral解码时,需要考虑两种情况。一、Header 的 Name 有索引。二、Header 的 Name 和 Value 均无索引。这两种情况下,该 Header 都不存在于动态表中。


下面分步骤分析(*Decoder).parseFieldLiteral方法。


1、读取 buf 中的 HPACK 索引。


nameIdx, buf, err := readVarInt(n, buf)
复制代码


2、 如果索引不为 0,则从 HPACK 索引列表中获取 Header 的 Name。


ihf, ok := d.at(nameIdx)if !ok {  return DecodingError{InvalidIndexError(nameIdx)}}hf.Name = ihf.Name
复制代码


3、如果索引为 0,则从 buf 中读取 Header 的 Name。


hf.Name, buf, err = d.readString(buf, wantStr)
复制代码


4、从 buf 中读取 Header 的 Value,并将完整的 Header 添加到动态表中。


hf.Value, buf, err = d.readString(buf, wantStr)if err != nil {  return err}d.buf = bufif it.indexed() {  d.dynTab.add(hf)}
复制代码


(*Decoder).readString: 从编码的字节数据中读取真实的 Header 数据。


func (d *Decoder) readString(p []byte, wantStr bool) (s string, remain []byte, err error) {	if len(p) == 0 {		return "", p, errNeedMore	}	isHuff := p[0]&128 != 0	strLen, p, err := readVarInt(7, p)	// 省略校验逻辑	if !isHuff {		if wantStr {			s = string(p[:strLen])		}		return s, p[strLen:], nil	}
if wantStr { buf := bufPool.Get().(*bytes.Buffer) buf.Reset() // don't trust others defer bufPool.Put(buf) if err := huffmanDecode(buf, d.maxStrLen, p[:strLen]); err != nil { buf.Reset() return "", nil, err } s = buf.String() buf.Reset() // be nice to GC } return s, p[strLen:], nil}
复制代码


首先判断字节数据是否是哈夫曼编码(和前面的appendHpackString函数对应),然后通过readVarInt读取数据的长度并赋值给strLen


如果不是哈夫曼编码,则直接返回strLen长度的数据。如果是哈夫曼编码,读取strLen长度的数据,并用哈夫曼算法解码后再返回。

验证 &总结

在前面我们已经了解了 HPACK 索引列表,以及基于 HPACK 索引列表的编/解码流程。


下面笔者最后验证一下已经编解码过后的 Header,再次编解码时的大小。


// 此处省略前面HAPACK编码和HPACK解码的demo// try againfmt.Println("try again: ")buf.Reset()henc.WriteField(hpack.HeaderField{Name: "custom-header", Value: "custom-value"}) // 编码已经编码过后的Headerfmt.Println(hdec.Write(buf.Bytes())) // 解码// 输出:// ori size: 197, encoded size: 111// header field ":authority" = "dss0.bdstatic.com"// header field ":method" = "GET"// header field ":path" = "/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png"// header field ":scheme" = "https"// header field "accept-encoding" = "gzip"// header field "user-agent" = "Go-http-client/2.0"// header field "custom-header" = "custom-value"// 111 <nil>// try again:// header field "custom-header" = "custom-value"// 1 <nil>
复制代码


由上面最后一行的输出可知,解码仅用了一个字节,即本例中编码一个已经编码过的 Header 也仅需一个字节。


综上:在一个连接上,client 和 server 维护一个相同的 HPACK 索引列表,多个请求在发送和接收 Header 数据时可以分为两种情况。


  1. Header 在 HPACK 索引列表里面,可以不用传输真实的 Header 数据仅需传输 HPACK 索引从而达到标头压缩的目的。

  2. Header 不在 HPACK 索引列表里面,对大多数 Header 而言也仅需传输 Header 的 Value 以及 Name 的 HPACK 索引,从而减少 Header 数据的传输。同时,在发送和接收这样的 Header 数据时会更新各自的 HPACK 索引列表,以保证下一个请求传输的 Header 数据尽可能的少。


最后,由衷的感谢将 HTTP2.0 系列读完的读者,真诚的希望各位读者能够有所收获。


如果大家有什么疑问可以在评论区和谐地讨论,笔者看到了也会及时回复,愿大家一起进步。


1. 写本文时, 笔者所用 go 版本为: go1.14.2

2. 索引 Header 表示法和增加动态表 Header 表示法均为笔者自主命名,主要便于读者理解。


参考:

https://developers.google.com/web/fundamentals/performance/http2?hl=zh-cn


发布于: 2020 年 10 月 26 日阅读数: 373
用户头像

Gopher指北

关注

还未添加个人签名 2020.09.15 加入

欢迎关注公众号:Gopher指北

评论

发布
暂无评论
Go发起HTTP2.0请求流程分析(后篇)——标头压缩