HPACK协议解读与源码解析
简介
在HTTP/1.1中,头部字段是没有做压缩处理的。在随着页面的请求数的增长,这些请求中的冗余头部字段占用了不必要的带宽。
而在HTTP/2中,对这个问题进行了优化,其头部压缩所使用的压缩编码规范就是 HPACK。
HPACK协议讲解
1、静态表与动态表
1.1 静态表
静态表(Static Table)包含的是预定义且不可修改的头部字段列表。其由常用网站使用的最常见的头部字段组成的,其中还包含了该字段对应的索引(index)。 而 Index 的值从 1~61 ,使用一个 8bit 的字节来表示。
+-------+-----------------------------+---------------+
| 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 | |
+-------+-----------------------------+---------------+
Table 1: Static Table Entries
1.2 动态表
动态表则是由一个初始长度为空的 FIFO 列表组成。每添加一个元素,就是一个进队的过程,所以对头的元素是最旧的,其索引值也是最大的; 队尾的元素是最新的,其索引值也是最小的。这样的设计能更加有效地提高压缩效率。
动态表的大小是有限的,这个值由使用者给定。如在 HTTP/2 中,动态表的大小就有 SETTINGS_HEADER_TABLE_SIZE 设置确定。
动态表添加新元素的时候,如果当前的动态表大小超出给定值的时候,会把尾部的元素出队,直到动态表的大小回归到给定值以内。(注意:动态表中每个元素的大小是不一样的,所以添加一个新元素的时候,可以需要移除多个元素才能保证动态表的大小不超过阀值)。
更新动态表大小时,以 ‘001’ 3位模式开始:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 | Max size (5+) |
+---+---------------------------+
Figure 12: Maximum Dynamic Table Size Change
1.3 索引地址空间
静态表和动态表的地址空间是连在一起。
<---------- Index Address Space ---------->
<-- Static Table --> <-- Dynamic Table -->
+---+-----------+---+ +---+-----------+---+
| 1 | ... | s | |s+1| ... |s+k|
+---+-----------+---+ +---+-----------+---+
^ |
| V
Insertion Point Dropping Point
Figure 1: Index Address Space
2、HPACK 编码的类型表示
HPACK 编码使用的基本类型给有两种:无符号可变长度整数 和 八位字节串。
2.1 整数表示
整数用于表示名称索引,头字段索引或字符串长度。 整数表示可以在八位组中的任何位置开始。 为了优化处理,整数表示总是在八位字节的末尾完成。
整数用两部分表示:充当当前字节的前缀 以及 在整数值不适合前缀时使用的可选八位字节列表。 前缀的位数(称为N)是整数表示的参数。
如果整数值足够小,即严格小于2 ^ N-1,则它被编码在N位前缀内。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| ? | ? | ? | Value |
+---+---+---+-------------------+
Figure 2: Integer Value Encoded within the Prefix (Shown for 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 |
+---+---------------------------+
Figure 3: Integer Value Encoded after the Prefix (Shown for N = 5)
使用伪代码来表示整数编码如下:
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
使用伪代码来表示整数解码如下:
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
举个例子:
1、当编码整数 10, N 为 5 的时候:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| X | X | X | 0 | 1 | 0 | 1 | 0 | 10 stored on 5 bits
+---+---+---+---+---+---+---+---+
2、当编码整数 1337 ,N 为 5 的时候:
1337 is greater than 31 (2^5 - 1).
The 5-bit prefix is filled with its max value (31).
I = 1337 - (2^5 - 1) = 1306.
I (1306) is greater than or equal to 128, so the while loop
body executes:
I % 128 == 26
26 + 128 == 154
154 is encoded in 8 bits as: 10011010
I is set to 10 (1306 / 128 == 10)
I is no longer greater than or equal to 128, so the while
loop terminates.
I, now 10, is encoded in 8 bits as: 00001010.
The process ends.
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
+---+---+---+---+---+---+---+---+
2.2 八位字节串表示
头部字段名称和头部字段值可以表示为字符串文字。 字符串文字被编码为八位字节序列,可以通过直接编码字符串字节的八位字节或使用霍夫曼编码(参见[HUFFMAN])。
由于霍夫曼编码数据并不总是在八位字节边界处结束,所以在它之后插入一些填充字符,直到下一个八位字节边界。为了防止这种填充被误解为字符串文字的一部分,使用与EOS(end-of-string)符号相对应的代码的最高有效位。
在解码时,编码数据末尾的不完整代码将被视为填充和丢弃。严格大于7位的填充必须被视为解码错误。与EOS符号的最高有效位不对应的填充必须被视为解码错误。包含EOS符号的Huffman编码的字符串文字必须被视为解码错误。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| H | String Length (7+) |
+---+---------------------------+
| String Data (Length octets) |
+-------------------------------+
Figure 4: String Literal Representation
- H:一位标志H,表示字符串的八位位组是否被霍夫曼编码。
- String Length:用于编码字符串文字的八位位组数,编码为带有7位前缀的整数。
- String Data:字符串文字的编码数据。 如果H为’0’,则编码数据是字符串文字的原始八位字节。 如果H是’1’,则编码数据是字符串文字的霍夫曼编码。
3、二进制格式
3.1 带索引的头部字段
索引头部字段表示标识静态表或动态表中的条目。索引头部字段以 1 位模式开始,随后是匹配头部字段的索引,以带有7位前缀的整数表示。没有使用0的索引值。如果在索引头部字段表示中找到它,它必须被视为解码错误。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 1 | Index (7+) |
+---+---------------------------+
Figure 5: Indexed Header Field
3.2 文字头部字段表示
文字头部字段表示定义了3种表示法,分别为 带增量索引、不带索引、从不索引。
3.2.1 带增量索引
具有增量索引表示的文字头部字段导致将头部字段附加到解码的头部列表,并将其作为新条目插入到动态表中。带增量索引表示的文字头部字段以 ‘01’ 2位开始:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 1 | Index (6+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
Figure 6: Literal Header Field with Incremental Indexing -- Indexed
Name
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) |
+-------------------------------+
Figure 7: Literal Header Field with Incremental Indexing -- New Name
3.2.2 不带索引
没有索引表示的文字头部字段导致将头部字段附加到解码的头部列表而不改变动态表格。没有索引表示的文字头部字段以 ’0000‘ 4位模式开始。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 | Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
Figure 8: Literal Header Field without Indexing -- Indexed Name
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) |
+-------------------------------+
Figure 9: Literal Header Field without Indexing -- New Name
3.2.3 从不索引
文字头部字段从未索引的表示导致在不更改动态表格的情况下将头部字段附加到解码的头部列表。从未索引的文字头部字段表示以 ’0001‘ 4位模式开始。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 | Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
Figure 10: Literal Header Field Never Indexed -- Indexed Name
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) |
+-------------------------------+
Figure 11: Literal Header Field Never Indexed -- New Name
Twitter 使用的 HPACK 源码分析
github地址:github.com/twitter/hpa…
Twitter 有个开源库,可以先从 HpackTest.java 看起
@Test
public void test() throws Exception {
InputStream is = HpackTest.class.getResourceAsStream(TEST_DIR + fileName);
TestCase testCase = TestCase.load(is);
// 测试编码
testCase.testCompress();
// 测试解码
testCase.testDecompress();
}
1、编码过程
TestCase.java 的 testCompress() 方法
void testCompress() throws Exception {
Encoder encoder = createEncoder();
for (HeaderBlock headerBlock : headerBlocks) {
// 编码
byte[] actual = encode(encoder, headerBlock.getHeaders(), headerBlock.getMaxHeaderTableSize(), sensitiveHeaders);
...
}
}
private static byte[] encode(Encoder encoder, List<HeaderField> headers, int maxHeaderTableSize, boolean sensitive)
throws IOException {
for (HeaderField e: headers) {
// Encoder 的 encodeHeader 方法
encoder.encodeHeader(baos, e.name, e.value, sensitive);
}
return baos.toByteArray();
}
Encoder.java 的 encodeHeader 方法:
public void encodeHeader(OutputStream out, byte[] name, byte[] value, boolean sensitive) throws IOException {
// If the header value is sensitive then it must never be indexed
// 如果是敏感字符,就使用从不索引的表示
if (sensitive) {
// 从静态表或者动态表取出 name 对应的 index
int nameIndex = getNameIndex(name);
// 进行从不索引的编码
encodeLiteral(out, name, value, IndexType.NEVER, nameIndex);
return;
}
// If the peer will only use the static table
if (capacity == 0) {
int staticTableIndex = StaticTable.getIndex(name, value);
if (staticTableIndex == -1) {
// 使用静态表中预留的空字段
int nameIndex = StaticTable.getIndex(name);
// 进行不带索引的编码
encodeLiteral(out, name, value, IndexType.NONE, nameIndex);
} else {
// 静态表中能找到,直接编码
encodeInteger(out, 0x80, 7, staticTableIndex);
}
return;
}
int headerSize = HeaderField.sizeOf(name, value);
// If the headerSize is greater than the max table size then it must be encoded literally
// 如果 headerSize 大于 最大的 tableSize ,则使用不带索引的编码
if (headerSize > capacity) {
int nameIndex = getNameIndex(name);
encodeLiteral(out, name, value, IndexType.NONE, nameIndex);
return;
}
// 从 静态表 中取出 HeaderEntry
HeaderEntry headerField = getEntry(name, value);
if (headerField != null) { // 静态表中找到了
int index = getIndex(headerField.index) + StaticTable.length;
// Section 6.1. Indexed Header Field Representation
// 使用整数表示
encodeInteger(out, 0x80, 7, index);
} else {
int staticTableIndex = StaticTable.getIndex(name, value);
if (staticTableIndex != -1) {
// Section 6.1. Indexed Header Field Representation
// 使用索引字段表示
encodeInteger(out, 0x80, 7, staticTableIndex);
} else {
int nameIndex = getNameIndex(name);
if (useIndexing) {
// 确保动态表有足够的空间存放
ensureCapacity(headerSize);
}
IndexType indexType = useIndexing ? IndexType.INCREMENTAL : IndexType.NONE;
// 进行增量索引表示编码或者不带索引表示编码
encodeLiteral(out, name, value, indexType, nameIndex);
if (useIndexing) {
// 添加到动态表中
add(name, value);
}
}
}
}
先看看 encodeLiteral() 方法
private void encodeLiteral(OutputStream out, byte[] name, byte[] value, IndexType indexType, int nameIndex)
throws IOException {
int mask;
int prefixBits;
switch(indexType) {
case INCREMENTAL:
mask = 0x40;
prefixBits = 6;
break;
case NONE:
mask = 0x00;
prefixBits = 4;
break;
case NEVER:
mask = 0x10;
prefixBits = 4;
break;
default:
throw new IllegalStateException("should not reach here");
}
encodeInteger(out, mask, prefixBits, nameIndex == -1 ? 0 : nameIndex);
if (nameIndex == -1) {
encodeStringLiteral(out, name);
}
encodeStringLiteral(out, value);
}
再来看看 encodeInteger() 方法,这里就是之前 2.1 整数表示 中伪代码的实现。
private static void encodeInteger(OutputStream out, int mask, int n, int i) throws IOException {
if (n < 0 || n > 8) {
throw new IllegalArgumentException("N: " + n);
}
int nbits = 0xFF >>> (8 - n);
if (i < nbits) {
out.write(mask | i);
} else {
out.write(mask | nbits);
int length = i - nbits;
while (true) {
if ((length & ~0x7F) == 0) {
out.write(length);
return;
} else {
out.write((length & 0x7F) | 0x80);
length >>>= 7;
}
}
}
}
再看看 encodeStringLiteral() 方法,这里使用了哈夫曼算法
private void encodeStringLiteral(OutputStream out, byte[] string) throws IOException {
int huffmanLength = Huffman.ENCODER.getEncodedLength(string);
if ((huffmanLength < string.length && !forceHuffmanOff) || forceHuffmanOn) {
encodeInteger(out, 0x80, 7, huffmanLength);
Huffman.ENCODER.encode(out, string);
} else {
encodeInteger(out, 0x00, 7, string.length);
out.write(string, 0, string.length);
}
}
2、解码过程
直接看 Decoder.java 的 decode() 方法
public void decode(InputStream in, HeaderListener headerListener) throws IOException {
while (in.available() > 0) {
switch(state) {
case READ_HEADER_REPRESENTATION: // 主要看这里
byte b = (byte) in.read();
if (maxDynamicTableSizeChangeRequired && (b & 0xE0) != 0x20) {
// Encoder MUST signal maximum dynamic table size change
throw MAX_DYNAMIC_TABLE_SIZE_CHANGE_REQUIRED;
}
if (b < 0) { // 这里表示最高位为 1
// Indexed Header Field
index = b & 0x7F;
if (index == 0) {
throw ILLEGAL_INDEX_VALUE;
} else if (index == 0x7F) {
state = State.READ_INDEXED_HEADER;
} else {
indexHeader(index, headerListener);
}
} else if ((b & 0x40) == 0x40) { // 有增量索引
// Literal Header Field with Incremental Indexing
indexType = IndexType.INCREMENTAL;
index = b & 0x3F;
if (index == 0) {
state = State.READ_LITERAL_HEADER_NAME_LENGTH_PREFIX;
} else if (index == 0x3F) {
state = State.READ_INDEXED_HEADER_NAME;
} else {
// Index was stored as the prefix
readName(index);
state = State.READ_LITERAL_HEADER_VALUE_LENGTH_PREFIX;
}
} else if ((b & 0x20) == 0x20) { // 需要更新动态表 size
// Dynamic Table Size Update
index = b & 0x1F;
if (index == 0x1F) {
state = State.READ_MAX_DYNAMIC_TABLE_SIZE;
} else {
setDynamicTableSize(index);
state = State.READ_HEADER_REPRESENTATION;
}
} else {
// Literal Header Field without Indexing / never Indexed
// 剩下的就是 没有索引表示和从不索引表示
indexType = ((b & 0x10) == 0x10) ? IndexType.NEVER : IndexType.NONE;
index = b & 0x0F;
if (index == 0) {
state = State.READ_LITERAL_HEADER_NAME_LENGTH_PREFIX;
} else if (index == 0x0F) {
state = State.READ_INDEXED_HEADER_NAME;
} else {
// Index was stored as the prefix
readName(index);
state = State.READ_LITERAL_HEADER_VALUE_LENGTH_PREFIX;
}
}
break;
...
default:
throw new IllegalStateException("should not reach here");
}
}
}
到这里就差不多了,本来还想讲讲 哈夫曼 算法的,但是理解还差点,后面再不上。