用Rust手把手编写一个wmproxy(代理,内网穿透等), HTTP改造篇之HPACK原理
项目 ++wmproxy++
gite: https://gitee.com/tickbh/wmproxy
github: https://github.com/tickbh/wmproxy
HTTP/2的简介
HTTP/1.1发表于1999年,该协议持续被使用到了至今
HTTP/2标准于2015年5月以RFC7540正式发表。由于HTTP2对1.1协议保持有高度的兼容,并且主要以字节传输,相比于1.1有更好的传输效率和更强大的传输能力,所以他快速流行起来
- 在2017年5月,全球排名前1000万的网站中,有13.7%支持了HTTP/2。
- 在2019年6月,全球有36.5%的网站支持了HTTP/2
而http/3的标准基于UDP协议的制定,而UDP在很多场合受限相对严重,由此可以得出http/1.1和http/2将会并存非常长的一段时间。所以我们需要对两种协议进行完全支持
库的选择
在这里我们选择自研的解析库 webparse和HTTP服务框架wenmeng,可以将服务转化成可以支持多协议的结构。此过程可能涉及到重写头信息,也可能添加头信息,会涉及到HTTP2的头部编码问题,所以必须将HTTP2的代码做完整的解析。
HTTP2的定义
HTTP2的RFC主要由7540和补充的7541,因为少量数据的请求如GET请求头占比100%,头部流量的优化变成了极为重要的性能提升点,所以很大的一部分把重点放在头部(7541)的优化。
HPACK: HTTP2的头部压缩
头部信息主要以下部分组成:
Header Field: 一个名称-值对。无论是名称还是值都由8位的字节组成,但名称限定为常见的编码(可解析成String),值可以为纯二进制。
Dynamic Table: 动态表是一个将存储的头部字段与索引值相关联的表。这个表是动态的,特定于一个编码或解码上下文。
Static Table: 静态表是一个静态地将频繁出现的头部字段与索引值相关联的表。这个表是有序的、只读的、始终可访问的,并且可以在所有编码或解码上下文之间共享。定义了一个常用的键值对以更好的压缩如(:method, GET)可由一个字节0x82表示
Header List: 一个头部列表是有序的头字段集合,这些头字段被联合编码并可以包含重复的头字段。一个完整的HTTP/2头部块中的头字段列表就是一个头部列表。如返回:status必须在其它的头前面,请求:method必须在其它前面,另一个因为需要动态添加到动态表里,如果前后顺序不一致的话,可能会导致动态表的映射值不一致,所以必须用列表的形式保证顺序。
Header Field Representation: 一个头字段可以在编码形式中表示为字面量或索引
Header Block: 一个有序的头字段表示列表,当解码时,会产生一个完整的头部列表。
动态表索引值
静态表和动态表如何结合成一个单独的索引地址空间。
在HPACK中,静态表和动态表都被结合到一个单独的索引地址空间中。
索引值在1和静态表长度之间(包括两端)指的是静态表中的元素。
索引值严格大于静态表长度指的是动态表中的元素。需要从索引中减去静态表的长度以找到动态表中的索引。
严格大于两个表长度之和的索引值必须被视为解码错误。
对于一个大小为s的静态表和大小为k的动态表,下面的图表展示了整个有效的索引地址空间。
<---------- 索引地址空间 ---------->
<-- 静态表 --> <-- 动态表 -->
+---+-----------+---+ +---+-----------+---+
| 1 | ... | s | |s+1| ... |s+k|
+---+-----------+---+ +---+-----------+---+
^ |
| V
新值插入位置 超出大小删除位置
如何索引
编码的头部字段可以表示为索引或文字。
索引表示将头部字段定义为静态表或动态表中的条目的引用。
文字表示通过指定其名称和值来定义头部字段。头部字段名称可以文字地表示或作为静态表或动态表中的条目的引用。头部字段值以文字形式表示。
定义了三种不同的文字表示:
将头部字段作为新条目添加到动态表开头的文字表示,如(custom-key, custom-value)。
不将头部字段添加到动态表的文字表示,如(:path, /wmproxy)。
不将头部字段添加到动态表,同时附加规定,此头部字段始终使用文字表示,尤其是当由中间人重新编码时。此表示旨在保护不因压缩而处于危险之中的头部字段值这些文字表示的选择可以通过安全考虑来指导,以保护敏感的头部字段值(如Authorization: xxxx),不能做任何的数据索引缓存。
头部字段名称或头部字段值的文字表示可以直接或使用静态Huffman编码对八位字节序列进行编码。
长度编码
整数被用于表示名称索引、头部字段索引或字符串长度。整数的表示可以在一个八位字节内的任何位置开始。为了允许优化的处理,整数的表示始终在八位字节的末尾完成。
整数由两部分表示:一个前缀,该前缀填充当前八位字节,以及一个可选的八位字节列表,当整数值不适合前缀时使用。前缀的位数(称为N)是整数表示的参数。
如果整数值足够小,即严格小于2^N-1,则在前缀的N位中对其进行编码。
可表示0-31的值,前面三位被用来做别的用途,如string的长度前缀为1,如索引头前缀为2
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| ? | ? | ? | Value |
+---+---+---+-------------------+
图:前缀中编码的整数值(以N=5为例),动态表长度更新即N=5
否则,前缀的所有位均设置为1,并且使用一个或多个八位字节列表对值进行编码,该值减去2^N-1。每个八位字节的最高有效位用作续表标记:其值设置为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 |
+---+---------------------------+
图:前缀后编码的整数值(以N=5为例)
从八位字节列表解码整数值首先从列表中反转八位字节的顺序。然后,对于每个八位字节,删除其最高有效位。将八位字节的剩余位连接起来,并增加2N-1以获得整数值。
前缀大小N始终介于1和8位之间。以八位字节边界开始的整数具有8位前缀。
表示整数I的伪代码如下:
if I < 2^N - 1, encode I on N bits
else
encode (2^N - 1) on N bits #即N位的1
I = I - (2^N - 1)
while I >= 128
encode (I % 128 + 128) on 8 bits
I = I / 128
encode I on 8 bits
解码整数I的伪代码如下:
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
在HPACK中跟长度相关的编解码都用如上方式
字符串的表示
标题字段名称和标题字段值可以表示为字符串文字。字符串文字编码为一系列八进制数字,通过直接编码字符串文字的八进制数字或使用霍夫曼编码(参见[HUFFMAN])。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| H | String Length (7+) |
+---+---------------------------+
| String Data (Length octets) |
+-------------------------------+
图:字符串文字表示
字符串文字表示包含以下字段:
- H:一个1位的标记H,指示字符串的八进制数字是否使用霍夫曼编码。如果1则表示用HUFFMAN编码,如果为0则普通编码
- 字符串长度: 用于编码字符串文字的八进制数字的数量,编码为具有7位前缀的整数。
- 字符串数据:字符串文字的编码数据。如果H为"0",则编码数据是字符串文字的原始八进制数字。如果H为"1",则编码数据是字符串文字的霍夫曼编码。
霍夫曼编码的优势:
在头信息中,可读信息出现的频率远大于不可读字节出现的概率,也就是把0-255字节按照频率出现在概率进行数据的重编码,如'0'则编码成'00000',按照ASCII的方式如果表示0需要8位的编码,这里就可以节省(8-5)/8=37.5%的数据大小。
头信息索引
因为HTTP2是多次复用同一个连接,头基本上会相同,如Cookie,HTTP2会将常用的(静态编码)和常用的(动态编码)将其缓存下来,在下次发送的时候,只要发送一个字节接收方就可以知道对方发送的头文件信息。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 1 | Index (7+) |
+---+---------------------------+
图: 头信息索引,Index为长度编码,遵循前缀1的长度编码
增量索引的文字头部字段
增量索引文字头部字段表示法将一个头部字段附加到解码后的头部列表,并将其作为新条目插入到动态表中。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 1 | Index (6+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
表示名字是索引,即Name命中,如:path命中,值为新的值,添加到动态表里
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) |
+-------------------------------+
表示名字和值均没有在原有的表里,现将name和value添加到动态表里。
索引的两个前缀必须为01
,如01000010
十六进制写法则是0x82
,<61,查静态表可得(:method, GET),即我们通过一字节表示请求方法为GET。
以下两种情况前缀为0001开头的则表示不添加到动态表中。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 | Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
不进行索引,但是从表中查Name,如authorization。
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) |
+-------------------------------+
从不进行索引
更新动态表的长度
动态表大小更新表示动态表大小发生了变化。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 | Max size (5+) |
+---+---------------------------+
可以设置新的最大的动态表长度。
至此HTTP2的头部压缩协议已基本了解完成,下一章将进行具体示例的分析。