共计 6335 个字符,预计需要花费 16 分钟才能阅读完成。
准备工作
使用 wireshark 抓取 http2.0 请求
点击查看
正式内容
问题背景
HTTP1.x 的 header 中的字段很多时候都是重复的,例如 method:get、status:200 等等,随着网页增长到需要数十到数百个请求,这些请求中的冗余标头字段不必要地消耗带宽,从而显著增加了延迟,因此,Hpack 技术应时而生。
Hpack 思想简介
首先介绍下压缩的概念(比较简单,熟悉的可以跳过):
通信的双方各拥有一本字典,记录着某些字符对应的文本内容,例如 x 代表危险,y 代表撤退,z 代表进攻等;
消息发送方根据字典生成消息文本比如 ’x,y’
接收方接收到消息后,根据字典还原内容:“危险,撤退”
这个例子已经简单介绍了压缩的好处:可以在传输的过程,简化消息内容,从而降低消息的大小
官方文档里的对 Hpack 的主要思想说明:
将 header 里的字段列表视为可包括重复对的 name-value 键值对的有序集合,分别使用 8 位字节表示 name 和 value
当字段被编码 / 解码时,对应的字典会不断扩充
在编码形式中,header 字段可以直接表示,也可以使用 header field tables 中对应的引用。因此,可以使用引用和文字值的混合来 header 字段列表。
文字值要么直接编码,要么使用静态 huffman 代码
编码器负责决定在标题字段表中插入哪些标题字段作为新条目。解码器执行对编码器规定的报头字段表的修改,重建处理中的报头字段列表
以上摘自 RFC 7541 协议使用翻译工具直接翻译 -_-,所以看起来有点艰涩,没关系,先往下看
引子 - 压缩效果比对
由于理论内容比较枯燥,所以先来几张图看一下效果,这里使用 wireshark 来抓取对同一个页面的两次请求,查看对比。
初次请求
头部长度 412, 解压完 690 压缩完大概是原来的 60%
二次请求
头部长度 172, 解压完 690 压缩完大概是原来的 25%
简单分析
以 cookie 这个字段为例,在上述两次请求中:
第一次请求时 cookie 所占的字符长度为 36:
第二次请求时 cookie 所占的字符长度为 1:
所以通过简单观察,我们可以简单得出以下结论:
头部压缩可以减小请求的头部大小(显而易见)
二次压缩的压缩率会更高,(后面会解释为什么)
过程简述
简单描述一下 Hpack 算法的过程:
消息发送端和消息接受端共同维护一份静态表和一份动态表(这两个合起来充当字典的角色),
每次请求时,发送方根据字典的内容以及一些特定指定,编码压缩消息头部,
接收方根据字典进行解码,并且根据指令来判断是否需要更新动态表
技术细节
首先介绍一下前面在说明“压缩过程”时,提到的字典。在 Hpack 中,一共使用 2 个表来充当字典的角色:静态表和动态表。
静态表
静态表很简单,只包含已知的 header 字段。点此查看完整的静态表,分为两种:
name 和 value 都可以完全确定,比如:metho: GET、:status: 200
只能够确定 name:比如:authority、cookie
第一种情况很好理解,已知键值对直接使用一个字符表示;
第二种情况稍微说明下:首先将 name 部分先用一个字符(比如 cookie)来表示,同时,根据情况判断是否告知服务端,将 cookie: xxxxxxx 添加到动态表中(我们这里默认假定是从客户端向服务端发送消息)
动态表
动态表最初是一个空表,当每次解压头部的时候,有可能会添加条目(比如前面提到的 cookie,当解压过一次 cookie 时,cookie: xxxxxxx 就有可能被添加到动态表了,至于是否添加要根据后面提到的指令判断)
动态表允许包含重复的条目,也就是可能出现完全相同的键值对
为了限制解码器的需求,动态表大小有严格限制的
索引地址空间
静态表和动态表一起组成一个索引地址空间。设静态表长度为 s, 动态表长度为 k,那么最终的索引空间如下:
<———- Index Address Space ———->
<– Static Table –> <– Dynamic Table –>
+—+———–+—+ +—+———–+—+
| 1 | … | s | |s+1| … |s+k|
+—+———–+—+ +—+———–+—+
^ |
| V
Insertion Point Dropping Point
其中:
索引 1 - s 是静态表,s- k 是动态表,
新的条目从在动态表的开头插入,从动态表末尾移除
有了这个索引空间以后,header 的字段一共有以下几种表示方法:
直接用索引值来表示(比如 2 表示 method:get)
字段的 name 使用索引值表示,字段的 value 直接使用原有字面的值的八位字节序列或者使用静态哈夫曼编码表示
字段表示法
header 字段的表示法一共分 2 种,下面逐一说明。
数字表示法
数字主要用来表示上文中索引空间的索引值,具体的规则如下:
先用限定位数的前缀表示,如果范围足够那就直接表示(限定位数是指下图中的扣除 xxx 剩余的长度,xxx 的具体含义见下一节 - 动态表更新指令以及表示)
如果范围不够大,那么接下来每次增加 8 个字节来表示
8 个字节的最高位都作为标志位,表示是否要继续向下延续(解码的时候要用到)
接下来看官方的一些例子帮助理解:
1. 用 5 位前缀表示 10
首先这里限制位数为 5,由于 10 小于 2^5-1, 可以直接表示为 01010,结果为:
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| X | X | X | 0 | 1 | 0 | 1 | 0 | 10 stored on 5 bits
+—+—+—+—+—+—+—+—
2. 用 5 位前缀表示 1337
1337>2^5-1, 那么前面 5 位只能表示到 31,剩余 1337-31 = 1306
接下来:1306>2^7 =128(八位字节第一位是标志位,所以表示范围只有 2^7-1)
I % 128 == 26,26 用 7 位 2 进制表示是 0011010,由于 I >128 还需要继续延续,所以标志位取 1,得到第二行应该是 10011010
I / 128 = 10,10 用 7 位 2 进制表示是 0001010, 标志位取 0 即可所以最终结果如下:
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| X | X | X | 1 | 1 | 1 | 1 | 1 | Prefix = 31, I = 1306
| 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 1306>=128, encode(154), I=1306/128
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 10<128, encode(10), done
+—+—+—+—+—+—+—+—+
3. 直接从边界开始表示 42
直接从边界开始, 也就是使用 8 位前缀,42 小于 2^8-1=255 所以直接表示:
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 42 stored on 8 bits
+—+—+—+—+—+—+—+—+
字符串表示法
header 的字段可以用字符串文本来表示,具体的规则如下:
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| H | String Length (7+) |
+—+—————————+
| String Data (Length octets) |
+——————————-+
H 是一个标志位,表示该字符串的八位字节是否被哈夫曼编码过
String Length:,表示用于编码的字节位数,具体的规则就是刚刚提到的 7 位前缀表示法
String Data:字符串编码过的数据,如果 h 为 0,则编码数据是字符串文字的原始八位字节;如果 H 是“1”,则编码数据是字符串文字的 huffman 编码。huffman 编码参见
动态表更新指令以及表示
情况 1:整个键值对都在现有的索引空间中
这种情况下,第一个字节固定为 1,然后用 7 位前缀法表示索引的值
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 1 | Index (7+) |
+—+—————————+
Figure 5: Indexed Header Field
An indexed header field starts with the ‘1
例如 10000010,表示索引值为 2,查找静态表可知,对应的 header 字段是 method:GET
注意我们前面说索引空间的时候提到,索引空间地址是从 1 开始的,0 的话会被视为错误,也就是 10000000 解码时会出错。
情况 2:name 在索引空间,但是 value 不在,且需要更新动态表
这种情况下,前两位固定为 01,后面 6 位表示索引值,取到对应的 name,例如 01010000 对应 32,查静态表可知 name 是 cookie,接下来使用字符串表示法表示对应的 value 字段,在解码之后,这个字段就被加到动态表中,下次编码的时候会直接使用情况 1,(这里也就说明了为什么后续请求压缩程度更大,因为动态表在不断扩充,扩充的界限请看官方文档这里暂时不说明)
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 1 | Index (6+) |
+—+—+———————–+
| H | Value Length (7+) |
+—+—————————+
| Value String (Length octets) |
+——————————-+
情况 3:name 和 value 都不在索引空间,且需要更新动态表
这种情况和上面的很相似,只要补上 name 部分的字符串表示, 并且把 index 值设置为 0 即可。
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 1 | 0 |
+—+—+———————–+
| H | Name Length (7+) |
+—+—————————+
| Name String (Length octets) |
+—+—————————+
| H | Value Length (7+) |
+—+—————————+
| Value String (Length octets) |
+——————————-+
观察情况 2 和情况 3 可知,如果需要更新动态表,前两位标志位都是 01
情况 4:name 在索引空间,但是 value 不在,且不需要更新动态表
这种情况,前四位固定为 0000,其他和情况 2 一致,
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 0 | 0 | Index (4+) |
+—+—+———————–+
| H | Value Length (7+) |
+—+—————————+
| Value String (Length octets) |
+——————————-+
情况 5 name 和 value 都不在索引空间,且不需要更新动态表
同理,前四位固定为 0000,其他和情况 3 一致,
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 0 | 0 | 0 |
+—+—+———————–+
| H | Name Length (7+) |
+—+—————————+
| Name String (Length octets) |
+—+—————————+
| H | Value Length (7+) |
+—+—————————+
| Value String (Length octets) |
+——————————-+
观察情况 2 和情况 3 可知,如果需要更新动态表,前两位标志位都是 0000
情况 6 name 在索引空间,但是 value 不在,且绝对不允许更新动态表
这种情况下和情况 4 基本一致,只是前四位固定为 0001,区别在于:
不需要更新表示,本次的发送过程不更新该字段到动态表;如果有多次转发,那么并不对转发做要求
绝对不允许更新表示,如果这个请求被多次转发才到目标,那么转发的所有中间对于该字段也必须采用相同的处理方案
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 0 | 0 | Index (4+) |
+—+—+———————–+
| H | Value Length (7+) |
+—+—————————+
| Value String (Length octets) |
+——————————-+
情况 7 name 和 value 都不在索引空间,且绝对不允许更新动态
0 1 2 3 4 5 6 7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 0 | 1 | 0 |
+—+—+———————–+
| H | Name Length (7+) |
+—+—————————+
| Name String (Length octets) |
+—+—————————+
| H | Value Length (7+) |
+—+—————————+
| Value String (Length octets) |
+——————————-+
和上面一种情况同理,就略过了。
实例分析
主要内容已经都说完了,接下来抓一些请求来看看具体的内容,比如直接抓取 segment 下的请求。
在这里 method 字段,就是前面提到的情况 1 – 直接在静态表查询就可以得到整个键值对,对应的索引值为 2。查看底下的编码 10000010,第一位 1 表示整个键值对在索引空间存在,后面的 0000010= 2 表示索引地址。
接下来看 authority 字段,我们抓第一次和第二次请求的进行对比:
第一次请求
可以看出,这个字段符合前面提到的情况 2:第一次编码是 01000001,表示 name 直接使用索引,索引值为 1,且 value 不在索引空间中,后面的部分表示具体的 value 值
第二次请求
第二次请求, 发现已经是直接使用索引空间的值(因为前一次请求已经要求更新到动态表),所以本次只要一个字符长度直接表示这个字段 110001101, 第一个 1 表示情况 1,后面 1001101=64+8+4+1 =77 也就是此时对应的索引值
小结
我们前面提到动态表会随请求增加不断更新,但是动态表其实是有大小限制的,因此动态表在增加条目时也可能会删除条目,具体的更新规则等限于篇幅(没错,不是因为懒)不在本文更新。还有就是相关的 huffman 编码等也不在此说明,本文主要还是针对 Hpack 算法的过程和编码规则做一些说明。主要参照 RFC 7541 协议
惯例:如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果对你有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址