社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
来自公众号:新世界杂货铺
这是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 压缩格式压缩请求和响应标头元数据,这种格式采用下面两种技术压缩:
本篇不对哈夫曼编码做过多的阐述,主要对双端共同维护的索引列表进行分析。
HPACK 压缩上下文包含一个静态表和一个动态表:静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。
认识静/动态表需要先认识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,并将其分别存入byName
和byNameValue
中。最后,将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的步骤如下:
byName
和byNameValue
的映射。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.byName
和t.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
,相比原先的基础功能增加了表的大小限制,其他功能保持不变。
前面介绍了动/静态表中内部的Index和内部的唯一Id,而在一次连接中HPACK索引列表是由静态表和动态表一起构成,那此时在连接中的HPACK索引是怎么样的呢?
带着这样的疑问我们看看下面的结构:
上图中蓝色部分表示静态表,黄色部分表示动态表。
H1...Hn
和H1...Hm
分别表示存储在静态表和动态表中的Header元素。
在HPACK索引中静态表部分的索引和静态表的内部索引保持一致,动态表部分的索引为动态表内部索引加上静态表索引的最大值。在一次连接中Client和Server通过HPACK索引标识唯一的Header元素。
众所周知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: %vn", 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
,:path
,accept-encoding
和user-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.SetMaxDynamicTableSizeLimit
和e.SetMaxDynamicTableSize
,而笔者在(*http2Transport).newClientConn
(此方法相关逻辑参见前篇)相关的源码中发现了这样的注释:
// TODO: SetMaxDynamicTableSize, SetMaxDynamicTableSizeLimit on
// henc in response to SETTINGS frames?
笔者看到这里的时候内心激动不已呀,产生了一种强烈的想贡献代码的欲望,奈何自己能力有限只能看着机会却抓不住呀,只好含恨埋头苦学(开个玩笑~,毕竟某位智者说过,写的越少BUG越少
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!