详解http-2头部压缩算法

7

准备工作

使用wireshark抓取http2.0请求

点击查看

正式内容

问题背景

  1. HTTP1.x的header中的字段很多时候都是重复的,例如method:getstatus:200等等,随着网页增长到需要数十到数百个请求,这些请求中的冗余标头字段不必要地消耗带宽,从而显著增加了延迟,因此,Hpack技术应时而生。

Hpack思想简介

首先介绍下压缩的概念(比较简单,熟悉的可以跳过):

  1. 通信的双方各拥有一本字典,记录着某些字符对应的文本内容,例如x代表危险,y代表撤退,z代表进攻等;
  2. 消息发送方根据字典生成消息文本比如'x,y'
  3. 接收方接收到消息后,根据字典还原内容:“危险,撤退”

这个例子已经简单介绍了压缩的好处:可以在传输的过程,简化消息内容,从而降低消息的大小

官方文档里的对Hpack的主要思想说明:

  1. 将header里的字段列表视为可包括重复对的name-value键值对的有序集合,分别使用8位字节表示name和value
  2. 当字段被编码/解码时,对应的字典会不断扩充
  3. 在编码形式中,header字段可以直接表示,也可以使用header field tables 中对应的引用。因此,可以使用引用和文字值的混合来header字段列表。
  4. 文字值要么直接编码,要么使用静态huffman代码
  5. 编码器负责决定在标题字段表中插入哪些标题字段作为新条目。解码器执行对编码器规定的报头字段表的修改,重建处理中的报头字段列表

以上摘自RFC 7541协议使用翻译工具直接翻译-_-,所以看起来有点艰涩,没关系,先往下看

引子-压缩效果比对

由于理论内容比较枯燥,所以先来几张图看一下效果,这里使用wireshark来抓取对同一个页面的两次请求,查看对比。

初次请求

  • 头部长度412,解压完 690 压缩完大概是原来的60%

图片描述

二次请求

  • 头部长度172,解压完 690 压缩完大概是原来的25%

图片描述

简单分析

cookie这个字段为例,在上述两次请求中:

  • 第一次请求时cookie所占的字符长度为36

图片描述

  • 第二次请求时cookie所占的字符长度为1

图片描述

所以通过简单观察,我们可以简单得出以下结论:

  1. 头部压缩可以减小请求的头部大小(显而易见)
  2. 二次压缩的压缩率会更高,(后面会解释为什么)

过程简述

简单描述一下Hpack算法的过程:

  • 消息发送端和消息接受端共同维护一份静态表和一份动态表(这两个合起来充当字典的角色),
  • 每次请求时,发送方根据字典的内容以及一些特定指定,编码压缩消息头部,
  • 接收方根据字典进行解码,并且根据指令来判断是否需要更新动态表

技术细节

基本概念

首先介绍一下前面在说明“压缩过程”时,提到的字典。在Hpack中,一共使用2个表来充当字典的角色:静态表和动态表。

静态表

静态表很简单,只包含已知的header字段。点此查看完整的静态表,分为两种:

  1. name和value都可以完全确定,比如:metho: GET:status: 200
  2. 只能够确定name:比如:authoritycookie
  • 第一种情况很好理解,已知键值对直接使用一个字符表示;
  • 第二种情况稍微说明下:首先将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种,下面逐一说明。

数字表示法

数字主要用来表示上文中索引空间的索引值,具体的规则如下:

  1. 先用限定位数的前缀表示,如果范围足够那就直接表示(限定位数是指下图中的扣除xxx剩余的长度,xxx的具体含义见下一节-动态表更新指令以及表示)
  2. 如果范围不够大,那么接下来每次增加8个字节来表示
  3. 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

  1. 1337>2^5-1,那么前面5位只能表示到31,剩余1337-31 = 1306
  2. 接下来:1306>2^7 =128(八位字节第一位是标志位,所以表示范围只有2^7-1)
  3. I % 128 == 26,26用7位2进制表示是0011010,由于I >128 还需要继续延续,所以标志位取1,得到第二行应该是10011010
  4. 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下的请求。
图片描述

  1. 在这里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协议


惯例:如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果对你有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

载入中...