概述
在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了代表解码出错了。
- 文字形式的头字段的表示
这种表示代表着头字段的值是文字,而头字段的键是一个静态表或动态表的索引或者是文字,这取决于这个字段是否已经被映射到索引表中。这种表示有三种类型:
- 增量索引
这一类以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) |
+-------------------------------+
-
不带索引
这一类以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) |
+-------------------------------+
-
从不索引
这一类以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这种一次性发送多帧的协议,在传输上节省了很多空间,在以前这些重复的头部信息可能要占用大量的带宽。但是,他也有一些安全的隐患,例如动态表可能会被攻击者探测到里面的元素,从而实现自适应的修改请求,可以考虑使用从不索引的文字表示,保护其中敏感的字段。另一方面动态表的大小和所消耗的内存相关,因此需要规定好动态表的最大容量配合更新信号来限定内存的消耗,以免被攻击者耗尽内存。