HPACK总结

546 阅读10分钟

概述

在HTTP1中,头部字段是没有压缩的,随着请求的大量增长,头部字段浪费了大量的带宽,增加了延时。HPCK是一种压缩编码,用在了HTTP2协议中,目的就是为了更高效的表示HTTP头中的字段。它通过了将HTTP头中的字段映射到动态表或静态表的索引中,从而以更少的字节数表示了HTTP头中的字段,从而实现了压缩。

基本概念

  • 头字段(Header Field)

    就是HTTP头中的key/value键值对。

  • 头列表(Header List)

    多个头字段的有序集合,这些头字段经过联合编码,头列表中可以包含重复的头字段。

  • 头块(Header Block)

    头字段的表示形式的列表,解码时,这些会生成完整的头列表。

  • 头字段表示(Header Field Representation)

    将一个头字段表示成编码表中的一个索引或字面量。

  • 静态表(Dynamic Table)

    静态表是一个索引表,它是固定的,将常用的固定的头字段和索引关联,在编码和解码过程中就可以用索引找到头字段。

  • 动态表(Static Table)

    动态表也是一个索引表,建立了除开静态表中的头字段和索引的联系,它是动态的,可以新增头字段。

头字段的表示

先介绍整数和字符串这两种最基本的数据的表示形式,结合这两者就可以对头字段进行表示和压缩了。

整型数表示

名称索引、头字段索引、字符串长度都是整型数表示的。整数表示可以在8位字节内的任何位置开始。整数表示总是在8位字节的末尾结束。整型表示分为两个部分:1. 一个填充当前8位字节的前缀。2. 一个可选的8位字节列表,当整型值和前缀不匹配时用上。前缀的bit位数(称为N)是整型表示的一个参数。N的大小在1-8之间。

  • 整型数小于2^N-1

编码进Nbit位的前缀中,例如N为5的整型数编码如下:

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| ? | ? | ? |       Value       |
+---+---+---+-------------------+
  • 整型数大于2^N-1

将前缀的bit位都置为1,先表示整型数的一部分,然后整型数减去这一部分即2^N-1,后面跟着一个或多个字节,这些字节中如果最高位为1,代表后面还有字节,即最高位是一个连续的标志,他们一起来编码整型数减去2^N-1之后的值。例如N为5的大于2^N-1的数的编码如下:

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| ? | ? | ? | 1   1   1   1   1 |
+---+---+---+-------------------+
| 1 |    Value-(2^N-1) LSB      |
+---+---------------------------+
               ...
+---+---------------------------+
| 0 |    Value-(2^N-1) MSB      |
+---+---------------------------+

整型数编解码

  • 整型数编码

伪代码表示如下:

if I < 2^N - 1, encode I on N bits
else
  encode (2^N - 1) on N bits
  I = I - (2^N - 1)
  while I >= 128
       encode (I % 128 + 128) on 8 bits
       I = I / 128
  encode I on 8 bits 

编码10这个数如下图:

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| X | X | X | 0 | 1 | 0 | 1 | 0 |   10 stored on 5 bits
+---+---+---+---+---+---+---+---+ 

如果N为5,编码1337这个数如下图:

 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
+---+---+---+---+---+---+---+---+

因为1337大于2^5-1(31),所以前缀的位都被置为1。然后1337-31 = 1306,剩下的数取余128得26,将26+128=154编码到1个字节中即得到10011010,然后将1306/128 =10编码到1个字节中。

  • 整型数解码

整型数解码伪代码如下:

decode I from the next N bits
	if I < 2^N - 1, return I
	else
    M = 0
    repeat
        B = next octet
        I = I + (B & 127) * 2^M
        M = M + 7
    while B & 128 == 128
    return I

上述代码得到的结果需要加上2^N-1得到原始的数。

字符串表示

编码规则如下图:

 0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| H |    String Length (7+)     |
+---+---------------------------+
|  String Data (Length octets)  |
+-------------------------------+
  • H :1bit标志位,0代表数据为字符串原始8位字节数据,1代表编码的数据为字符串的Huffman编码数据。
  • String Length:用来编码字符串字面量的八位字节数量,编码成具有7bit位前缀的integer(具体见上面的整型数表示)。
  • String Data:字符串字面量编码的数据。类型根据H来确定(原始字节或Huffman编码)。
  • 由于Huffman编码的数据并不总是在八位字节边界处结束,因此会在其后插入填充,直到下一个字节边界。为了避免将此填充误解为字符串字面量的一部分,使用了与EOS(字符串结尾)符号对应的代码的最高有效位。
  • 解码时,将编码数据末尾的不完整代码视为填充并丢弃。大于7位的填充、与EOS符号的代码的最高有效位不对应的填充意味着解码错误。遇到包含EOS符号的Huffman编码的字符串也意味着解码错误了。

头字段表示

  • 已经索引的头字段的表示

已经索引的头字段,意味着已经通过索引映射到静态表或动态表中了。那么这个字段的表示如下:

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 |        Index (7+)         |
+---+---------------------------+

       可以看到它一定是1这个bit位开头,后面跟着的是索引,这个索引是7位前缀的,具体的表示参考上面的整型数表示。注意索引不会表示为0,因此如果遇到0了代表解码出错了。

  • 文字形式的头字段的表示

这种表示代表着头字段的值是文字,而头字段的键是一个静态表或动态表的索引或者是文字,这取决于这个字段是否已经被映射到索引表中。这种表示有三种类型:

  1. 增量索引

这一类以01这两个bit位打头,后面跟着的是6bit位前缀的索引。

  • 已经映射到索引表的头字段名称

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 1 |      Index (6+)       |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • 还没映射到索引表中的新的头字段名称

  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)  |
+-------------------------------+
  1. 不带索引

这一类以0000这4个bit位打头,如果之前已经映射到了索引表中,那么后面跟着的是4bit位前缀的索引表索引。之所以是不带索引的,指的是在解码时不把这个字段加入到动态表中,即不改变动态表。

  • 已经映射到索引表的头字段名称
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |  Index (4+)   |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • 还没映射到索引表中的新的头字段名称
  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)  |
+-------------------------------+
  1. 从不索引

这一类以0001这4个bit位打头,如果之前已经映射到了索引表中,那么后面跟着的是4bit位前缀的索引表索引。和不带索引的类型一样它解码也是不会更改动态表的。之所以叫从不索引,是指中间件转发的情况下,也必须使用相同的表示方式编码这个字段。这么做是为了保护头部字段的值。

  • 已经映射到索引表的头部字段名称
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 |  Index (4+)   |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • 还未映射到索引表的新头部字段名称
  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)  |
+-------------------------------+

索引表

这里对静态表、动态表和索引地址空间再加以说明。

索引地址空间

静态表索引和动态表索引在一个地址空间,大于静态表索引的就是动态表索引。由于动态表是先入先出的队列,所以动态表元素是从s+1的位置插入,如果要超过动态表大小时,就从尾部丢弃元素。

         <----------  Index Address Space ---------->
        <-- Static  Table -->  <-- Dynamic Table -->
        +---+-----------+---+  +---+-----------+---+
        | 1 |    ...    | s |  |s+1|    ...    |s+k|
        +---+-----------+---+  +---+-----------+---+
                               ^                   |
                               |                   V
                        Insertion Point      Dropping Point

静态表

固定的条目组成,根据热门网站常用的头字段生成。如下:

+-------+-----------------------------+---------------+
| Index | Header Name                 | Header Value  |
+-------+-----------------------------+---------------+
| 1     | :authority                  |               |
| 2     | :method                     | GET           |
| 3     | :method                     | POST          |
| 4     | :path                       | /             |
| 5     | :path                       | /index.html   |
| 6     | :scheme                     | http          |
| 7     | :scheme                     | https         |
| 8     | :status                     | 200           |
| 9     | :status                     | 204           |
| 10    | :status                     | 206           |
| 11    | :status                     | 304           |
| 12    | :status                     | 400           |
| 13    | :status                     | 404           |
| 14    | :status                     | 500           |
| 15    | accept-charset              |               |
| 16    | accept-encoding             | gzip, deflate |
| 17    | accept-language             |               |
| 18    | accept-ranges               |               |
| 19    | accept                      |               |
| 20    | access-control-allow-origin |               |
| 21    | age                         |               |
| 22    | allow                       |               |
| 23    | authorization               |               |
| 24    | cache-control               |               |
| 25    | content-disposition         |               |
| 26    | content-encoding            |               |
| 27    | content-language            |               |
| 28    | content-length              |               |
| 29    | content-location            |               |
| 30    | content-range               |               |
| 31    | content-type                |               |
| 32    | cookie                      |               |
| 33    | date                        |               |
| 34    | etag                        |               |
| 35    | expect                      |               |
| 36    | expires                     |               |
| 37    | from                        |               |
| 38    | host                        |               |
| 39    | if-match                    |               |
| 40    | if-modified-since           |               |
| 41    | if-none-match               |               |
| 42    | if-range                    |               |
| 43    | if-unmodified-since         |               |
| 44    | last-modified               |               |
| 45    | link                        |               |
| 46    | location                    |               |
| 47    | max-forwards                |               |
| 48    | proxy-authenticate          |               |
| 49    | proxy-authorization         |               |
| 50    | range                       |               |
| 51    | referer                     |               |
| 52    | refresh                     |               |
| 53    | retry-after                 |               |
| 54    | server                      |               |
| 55    | set-cookie                  |               |
| 56    | strict-transport-security   |               |
| 57    | transfer-encoding           |               |
| 58    | user-agent                  |               |
| 59    | vary                        |               |
| 60    | via                         |               |
| 61    | www-authenticate            |               |
+-------+-----------------------------+---------------+

可以看到基本上是常用的头部字段。例如status:200 这个头字段就可以用8这个索引表示,达到了压缩的目的。

动态表

动态表是一个先进先出的队列,一开始的大小是0。当解码时,如果字段的索引是增量索引则会在解码后将这个字段加到动态表中,动态表中允许重复的元素。由于可以动态增加,不可能无限制的增加下去,所以对动态表的大小有限定,这个限定是用HTTP2协议中的SETTINGS_HEADER_TABLE_SIZE设置确定的。这个大小限定是可以更新的,通过发出更新的信号。信号格式如下:

   0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 |   Max size (5+)   |
+---+---------------------------+

可以看到更新信号格式为001这3个bit位打头,后面跟着的是5bit前缀的最大容量值。新的最大容量必须小于或等于协议中使用HPACK确定的限制(即SETTINGS_HEADER_TABLE_SIZE),意思是最大不能超过SETTINGS_HEADER_TABLE_SIZE,否则视为解码错误。如果减小了动态表的最大容量,可能会导致动态表尾部的元素被丢弃。

Huffman编码

Huffman编码是根据用到的字符和它们出现的频率来编码各个字符,将最常用的字符编码成最短的二进制串,以此达到最大的压缩目的,因此每个字符的编码不是等长的,为此Huffman编码规定每个字符的编码不能是另一个字符编码的前缀,这样就避免了歧义。至于如何构建,是借助二叉树来实现的,这里不做展开。

HPACK用到的Huffman编码是根据大量的HTTP头样本获得的统计信息生成的。完整的码表:httpwg.org/specs/rfc75… 码表部分信息如下:

                                                   code
                       code as bits                 as hex   len
     sym              aligned to MSB                aligned   in
                                                    to LSB   bits
    (  0)  |11111111|11000                             1ff8  [13]
    (  1)  |11111111|11111111|1011000                7fffd8  [23]
    (  2)  |11111111|11111111|11111110|0010         fffffe2  [28]
    (  3)  |11111111|11111111|11111110|0011         fffffe3  [28]
    (  4)  |11111111|11111111|11111110|0100         fffffe4  [28]
    (  5)  |11111111|11111111|11111110|0101         fffffe5  [28]
    (  6)  |11111111|11111111|11111110|0110         fffffe6  [28]
    (  7)  |11111111|11111111|11111110|0111         fffffe7  [28]
    (  8)  |11111111|11111111|11111110|1000         fffffe8  [28]
    (  9)  |11111111|11111111|11101010               ffffea  [24]
    ( 10)  |11111111|11111111|11111111|111100      3ffffffc  [30]
    ...
' ' ( 32)  |010100                                       14  [ 6]
'!' ( 33)  |11111110|00                                 3f8  [10]
'"' ( 34)  |11111110|01                                 3f9  [10]
'#' ( 35)  |11111111|1010                               ffa  [12]
'$' ( 36)  |11111111|11001                             1ff9  [13]
'%' ( 37)  |010101                                       15  [ 6]
'&' ( 38)  |11111000                                     f8  [ 8]
    ...
    (252)  |11111111|11111111|11111101|110          7ffffee  [27]
    (253)  |11111111|11111111|11111101|111          7ffffef  [27]
    (254)  |11111111|11111111|11111110|000          7fffff0  [27]
    (255)  |11111111|11111111|11111011|10           3ffffee  [26]
EOS (256)  |11111111|11111111|11111111|111111      3fffffff  [30]

sym代表表示的符号,可能是一个字节中存放的10进制数,也可能是ASCII码,EOS 作为结束符。

举例1:符号为47(对应于"/"的ASCII字符)的Huffman编码对应于以6位编码的值0x18(以十六进制表示)。

举例2: 以上是一段HTTP2的抓包的片段,我们看:authority这个头部字段。它的编码二进制串如图所示。 以0x41开始,二进制即01000001,因为是01打头,所以对应于增量索引,后六位是索引即1,查索引表可得对应于静态表的第一个字段:authority,这是它的字段名。再看它后面的一个字节0x8b,对应二进制即10001011,这个字节第1位代表是否是Huffman编码,因为是1所以是Huffman编码,后面7位是长度即11,所以再取11个字节:

    9d       29       ac       4b       8f       a8       e9       19       97     21       e9 
 10011101 00101001 10101100 01001011 10001111 10101000 11101001 00011001 10010111 00100001 11101001
 查Huffman编码表重新划分字节的bit位
 100111|01001|01001|101011|00010|010111|00011|1110101|00011|101001|00011|00110|010111|00100|00111|101001
   'h'   't'   't'   'p'    '2'    '.'   'a'    'k'    'a'    'm'   'a'   'i'   '.'   'c'   'o'   'm'

还原成原始字符即为:http2.akamai.com,可以看到原本可能需要16字节表示的字符串用11个字节就表示了。

总结

HPACK本质上是通过建立索引和头字段的映射,从而将出现的头字段编码成简短的索引,同时针对字段值也可以通过Huffman编码进行进一步的压缩,对于HTTP2这种一次性发送多帧的协议,在传输上节省了很多空间,在以前这些重复的头部信息可能要占用大量的带宽。但是,他也有一些安全的隐患,例如动态表可能会被攻击者探测到里面的元素,从而实现自适应的修改请求,可以考虑使用从不索引的文字表示,保护其中敏感的字段。另一方面动态表的大小和所消耗的内存相关,因此需要规定好动态表的最大容量配合更新信号来限定内存的消耗,以免被攻击者耗尽内存。