本节目标:能手写 HPACK 的编解码器,并用 RFC 测试向量证明它对,而不是看起来对。
适用版本:JDK 25(Temurin 25.0.5+8);只使用java.nio与 JDK 标准库。
承接的问题
9.1 把 HEADERS 帧拆出来了,但载荷是一串没法直接读的字节。一个真实的 GET /books/42 请求,头部至少包含 :method、:scheme、:path、:authority、user-agent、accept,每个请求都重复一遍;HTTP/1.1 里这些字符串每个请求都要占几十到几百字节,HTTP/2 如果照搬,多路复用省下的连接开销会被头部抵消。
HPACK 用三层手段压:静态表把 61 个最常见头部(含 :method GET、:status 200)预先编号;动态表把本次连接里出现过的头部缓存下来,下一个请求只发一个索引;Huffman 编码把剩下的字面量按字符频率压短。三者都是连接级状态,所以 HEADERS 块不能丢、不能乱序,也不能跨连接复用解码器。
linkd 用两个独立上下文:解码器处理客户端到服务端的请求头,编码器处理服务端到客户端的响应头。两个方向的动态表互不相干,这一点写错会让索引错位,表现为随机 COMPRESSION_ERROR。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:整数编码
HPACK 的所有长度、索引、表大小都用同一套「前缀 + 续字节」整数编码。下面两个静态方法放在 com.plume.linkd.http.Http2Hpack 工具类里,编码器和解码器共用。第一个字节的低 N 位是前缀,放得下就直接结束;放不下就填满前缀(全 1),剩余值按 7 位一组、低位在前,塞进后续字节,最高位表示还有没有下一字节。
public static int readInt(ByteBuffer in, int first, int prefixBits) {
int mask = (1 << prefixBits) - 1;
long value = first & mask;
if (value < mask) {
return (int) value;
}
int shift = 0;
while (true) {
if (!in.hasRemaining()) {
throw new Http2ProtocolException(Http2ProtocolException.COMPRESSION_ERROR,
"truncated integer");
}
int next = in.get() & 0xFF;
value += (long) (next & 0x7F) << shift;
if (value > Integer.MAX_VALUE) {
throw new Http2ProtocolException(Http2ProtocolException.COMPRESSION_ERROR,
"integer overflow");
}
if ((next & 0x80) == 0) {
return (int) value;
}
shift += 7;
if (shift > 28) {
throw new Http2ProtocolException(Http2ProtocolException.COMPRESSION_ERROR,
"integer longer than 5 octets");
}
}
}
public static void writeInt(ByteBuffer out, int value, int prefixBits, int highBits) {
int mask = (1 << prefixBits) - 1;
if (value < mask) {
out.put((byte) (highBits | value));
return;
}
out.put((byte) (highBits | mask));
int rest = value - mask;
while (rest >= 0x80) {
out.put((byte) (0x80 | (rest & 0x7F)));
rest >>>= 7;
}
out.put((byte) rest);
}
本机把 127 按 7 位前缀、1337 按 5 位前缀编码,输出:
int 127/7bit = 7f00
int 1337/5bit = 1f9a0a
127 正好等于 2^7-1,不能用一个字节表示,必须写成 7f 00:前缀填满,续字节为 0。这是边界值最容易漏测的一条,客户端把 content-length: 127 编码成单字节 7f,服务端会把后面的字符串长度当成整数续字节,整个头部块从此错位。1337 的 1f 9a 0a 里,1f 是「5 位前缀全 1」,9a 的低 7 位是 26、最高位为 1,0a 表示结束,合起来是 31 + 26 + 10×128 = 1337。
读取时把累加值放进 long,再做上界检查。用 int 累加时,四个续字节就能把符号位顶翻,长度变成负数,后续的 remaining() 判断全部失效。
核心机制二:静态表、动态表与四种表示
静态表是 61 个固定条目,索引 1 到 61,它和后面的查找、记账方法都在 Http2Hpack 里。前 14 条是伪头部,后面是常见头部名;有些条目带常用值,比如 2 是 :method GET,8 是 :status 200:
public static final int STATIC_COUNT = 61;
public static int sizeOf(String name, String value) {
return name.length() + value.length() + 32;
}
private static final String[][] STATIC = {
{":authority", ""}, {":method", "GET"}, {":method", "POST"}, {":path", "/"},
{":path", "/index.html"}, {":scheme", "http"}, {":scheme", "https"}, {":status", "200"},
{":status", "204"}, {":status", "206"}, {":status", "304"}, {":status", "400"},
{":status", "404"}, {":status", "500"}, {"accept-charset", ""}, {"accept-encoding", "gzip, deflate"},
{"accept-language", ""}, {"accept-ranges", ""}, {"accept", ""}, {"access-control-allow-origin", ""},
{"age", ""}, {"allow", ""}, {"authorization", ""}, {"cache-control", ""},
{"content-disposition", ""}, {"content-encoding", ""}, {"content-language", ""}, {"content-length", ""},
{"content-location", ""}, {"content-range", ""}, {"content-type", ""}, {"cookie", ""},
{"date", ""}, {"etag", ""}, {"expect", ""}, {"expires", ""},
{"from", ""}, {"host", ""}, {"if-match", ""}, {"if-modified-since", ""},
{"if-none-match", ""}, {"if-range", ""}, {"if-unmodified-since", ""}, {"last-modified", ""},
{"link", ""}, {"location", ""}, {"max-forwards", ""}, {"proxy-authenticate", ""},
{"proxy-authorization", ""}, {"range", ""}, {"referer", ""}, {"refresh", ""},
{"retry-after", ""}, {"server", ""}, {"set-cookie", ""}, {"strict-transport-security", ""},
{"transfer-encoding", ""}, {"user-agent", ""}, {"vary", ""}, {"via", ""},
{"www-authenticate", ""}
};
动态表接在静态表后面,索引 62 是「最新加入的一条」,63 是第二新,依此类推。每条记录的大小是 名字字节数 + 值字节数 + 32,32 是协议规定的记账开销。表有上限,默认 4096 字节,超了就从最旧的开始淘汰。下面两个方法属于 Http2HpackDecoder,它持有 dynamic 列表和 maxTableSize、tableSize 两个计数;编码器有一份对称实现。
private void add(Http2HeaderField field) {
int size = Http2Hpack.sizeOf(field.name(), field.value());
if (size > maxTableSize) {
dynamic.clear();
tableSize = 0;
return;
}
dynamic.add(0, field);
tableSize += size;
evict();
}
private void evict() {
while (tableSize > maxTableSize && !dynamic.isEmpty()) {
Http2HeaderField evicted = dynamic.remove(dynamic.size() - 1);
tableSize -= Http2Hpack.sizeOf(evicted.name(), evicted.value());
}
}
单条记录比整张表还大时,不是报错,而是清空整张表并放弃缓存这一条。这一点在 set-cookie 很长时会出现,处理错会把表大小算成负数。
解码循环按首字节的高位分类,前四类是字段表示,最后一类是控制指令:
| 首字节模式 | 表示 | 行为 |
|---|---|---|
1xxxxxxx | Indexed | 直接查表,不写动态表 |
01xxxxxx | Literal with incremental indexing | 读名字索引或字面量,读值,写入动态表 |
0000xxxx | Literal without indexing | 读名字与值,不写动态表 |
0001xxxx | Literal never indexed | 同不索引,语义上禁止中间代理改写 |
001xxxxx | Dynamic table size update | 调整表上限,必须 ≤ SETTINGS 声明值 |
四种表示里只有 01 会改动态表,另外两种字面量只影响本次请求。never indexed 是给 authorization、cookie 这类敏感头用的,告诉中间设备不要把它写进自己的表;教学实现可以按不索引处理,但不要把它改成 01。
核心机制三:Huffman 解码
HPACK 的 Huffman 表是 257 个符号(0 到 255 加 EOS),码长 5 到 30 位,且是规范 Huffman 码:同码长的符号按符号值递增分配连续码字。因此不必把 257 行码表全部抄进代码,只存 257 个码长就能重建,实现放在 Http2Huffman 类:
private static final String LENGTHS =
"ISXXXXXXXTZXXZXXXXXXXXZXXXXXXXXXBFFHIBDGFFDGDBBBAAABBBBBBBCDKBHF"
+ "IBCCCCCCCCCCCCCCCCCCCCCCDCDIOIJBKABABABBBACCBBBABCBAABCCCCCKGJIX"
+ "PRPPRRRSRSSSSSTSTTRSTSSSSQRSRSSTRQPRRSSQSRRTQRSSQQRQSRSSPRRRSRRS"
+ "VVPORSRUVVVWWVTUOQVWWVWTQQVVXWWWPTPQRQQSRRUUTTVSVWVVWWWWWXWWWWWV"
+ "Z";
每个字符表示一个码长:A 是 5 位,B 是 6 位,一直到 Z 是 30 位。字符串第 i 个字符就是符号 i 的码长,长度正好 257。重建分两步:先按码长统计每层有多少个码字,算出每层的起始码;再按符号顺序把码字逐个填进二叉树。
private static Node build() {
int[] count = new int[31];
for (int i = 0; i < LENGTHS.length(); i++) {
count[LENGTHS.charAt(i) - 'A' + 5]++;
}
int[] next = new int[31];
int code = 0;
for (int length = 1; length <= 30; length++) {
code = (code + count[length - 1]) << 1;
next[length] = code;
}
Node root = new Node();
for (int symbol = 0; symbol < LENGTHS.length(); symbol++) {
int length = LENGTHS.charAt(symbol) - 'A' + 5;
int bits = next[length]++;
Node node = root;
for (int shift = length - 1; shift >= 0; shift--) {
if (((bits >>> shift) & 1) == 0) {
if (node.zero == null) {
node.zero = new Node();
}
node = node.zero;
} else {
if (node.one == null) {
node.one = new Node();
}
node = node.one;
}
}
node.symbol = symbol;
}
return root;
}
解码按位走树,走到叶子输出符号并回到根:
private static final Node ROOT = build();
public static byte[] decode(byte[] data) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length);
Node node = ROOT;
int pendingBits = 0;
boolean onlyOnes = true;
for (byte b : data) {
for (int shift = 7; shift >= 0; shift--) {
int bit = (b >>> shift) & 1;
node = bit == 0 ? node.zero : node.one;
if (node == null) {
throw new Http2ProtocolException(Http2ProtocolException.COMPRESSION_ERROR,
"invalid huffman code");
}
pendingBits++;
if (bit == 0) onlyOnes = false;
if (node.symbol >= 0) {
if (node.symbol == 256) {
throw new Http2ProtocolException(Http2ProtocolException.COMPRESSION_ERROR,
"EOS symbol in huffman string");
}
out.write(node.symbol);
node = ROOT;
pendingBits = 0;
onlyOnes = true;
}
}
}
if (pendingBits > 7 || !onlyOnes) {
throw new Http2ProtocolException(Http2ProtocolException.COMPRESSION_ERROR,
"invalid huffman padding");
}
return out.toByteArray();
}
两个校验不能省:遇到 EOS(符号 256)说明数据被截断,报 COMPRESSION_ERROR;结尾剩余不足 8 位且必须全为 1,这是协议规定的填充,出现 0 位说明码流损坏。
用 RFC 7541 附录 C 的两段真实编码验证,本机运行结果如下。C.4.1 是请求,C.6.1 是响应,两段都含 Huffman 字面量:
C.4.1: [Http2HeaderField[name=:method, value=GET], Http2HeaderField[name=:scheme, value=http], Http2HeaderField[name=:path, value=/], Http2HeaderField[name=:authority, value=www.example.com]]
C.4.1 table = 57
C.6.1: [Http2HeaderField[name=:status, value=302], Http2HeaderField[name=cache-control, value=private], Http2HeaderField[name=date, value=Mon, 21 Oct 2013 20:13:21 GMT], Http2HeaderField[name=location, value=https://www.example.com]]
C.6.1 table = 222
C.4.1 解完后动态表 57 字节,正好是 :authority 加 www.example.com 再加 32;C.6.1 的四条全部带增量索引,42 + 52 + 65 + 63 = 222 字节。这两个数字对得上,说明表大小记账和索引顺序都正确。
在 linkd 里落地
编码器的策略是「只发索引和字面量,不做 Huffman」,主循环在 Http2HpackEncoder.encode,解码入口是 Http2HpackDecoder.decode。RFC 7541 允许编码器自行选择是否使用 Huffman,H=0 完全合法,代价只是多几个字节;解码器则必须两种都支持,因为对端可能一直用 Huffman。这就是「不支持 Huffman 编码时的合法降级」:不是去协商,也不是假装支持,而是编码端稳定输出 H=0,解码端完整实现 H=1。
public byte[] encode(List<Http2HeaderField> headers) {
ByteBuffer out = ByteBuffer.allocate(4096);
for (Http2HeaderField field : headers) {
int full = find(field.name(), field.value());
if (full > 0) {
Http2Hpack.writeInt(out, full, 7, 0x80);
continue;
}
int nameOnly = Http2Hpack.findName(field.name());
if (nameOnly > 0) {
Http2Hpack.writeInt(out, nameOnly, 6, 0x40);
} else {
out.put((byte) 0x40);
writeString(out, field.name());
}
writeString(out, field.value());
add(field);
}
byte[] block = new byte[out.position()];
out.flip();
out.get(block);
return block;
}
private static void writeString(ByteBuffer out, String value) {
byte[] raw = value.getBytes(StandardCharsets.ISO_8859_1);
Http2Hpack.writeInt(out, raw.length, 7, 0x00);
out.put(raw);
}
find 先查静态表的「名字加值」,再查本连接动态表的完整匹配;只命中名字时用 01 前缀加名字索引,值仍然发字面量。名字只查静态表,动态表只做完整键值匹配,这是简化,会增加几字节但不影响正确性。writeString 的首字节高位是 0,表示 H=0,后面跟 7 位长度。
同一个请求连发三次,本机输出:
request #1: 20 bytes 828684410f7777772e6578616d706c652e636f6d
request #2: 4 bytes 828684be
request #3: 4 bytes 828684be
encoder table = 57, decoder table = 57
round trip equals = true
fresh decoder: index 62 beyond dynamic table size 0 (errorCode=0x9)
第一次 20 字节:82 86 84 是三个静态索引,41 0f 是「增量索引、名字索引 1」,后面 15 字节是 www.example.com 原文。第二次只剩 4 字节,be 就是索引 62。RFC 的同一请求用 Huffman 是 17 字节,我们多花 3 字节;字符串越短,Huffman 能省的比例越小;对长且重复的 user-agent 才明显。
round trip equals = true 来自一个与编码器同步演进的解码器:它按顺序解了三块,动态表状态一致。最后一个 fresh decoder 是反例:新建的解码器没有历史,收到 be 只能报索引越界。这证明 HPACK 的解码器必须绑定连接,且 HEADERS 块必须按到达顺序解码,不能为了并发把不同流的头部块拆到不同线程。
最后把头部块还原成 8.x 已有的 HttpRequest,路由不需要知道 HTTP/2 的存在:
public final class Http2RequestBridge {
private final Http2HpackDecoder decoder = new Http2HpackDecoder();
public HttpRequest toRequest(byte[] headerBlock, byte[] body) {
String method = null;
String target = null;
Map<String, String> headers = new LinkedHashMap<>();
for (Http2HeaderField field : decoder.decode(headerBlock)) {
switch (field.name()) {
case ":method" -> method = field.value();
case ":path" -> target = field.value();
default -> {
if (!field.name().startsWith(":")) headers.put(field.name(), field.value());
}
}
}
return new HttpRequest(method, target, "HTTP/2", headers, body);
}
}
Http2RequestBridge 每个连接一个实例,不能每个请求 new 一个,否则动态表每次都从空开始,第二块就解不动。伪头部不进普通头部表;HttpRequest 的 version 填 HTTP/2,路由只按 method 和 target 匹配,8.3 的代码一行不用改。
常见错误与边界
错误一:把动态表大小按字符数算。 中文头部的 String.length() 是 UTF-16 码元数,不是字节数。本实现用 ISO-8859-1 做逐字节映射,一个字符就是一个字节;如果换成 UTF-8 收发,记账必须用 getBytes(UTF_8).length,否则两端淘汰时机不同,索引会错位。
错误二:每个请求新建解码器。 上面 fresh decoder 的输出就是后果:索引 62 越界。解码器、编码器、动态表都是连接级状态,必须跟随连接生命周期创建和销毁。
错误三:以为不用 Huffman 就「完整兼容」。 编码端可以不用,解码端不能不会。只实现 H=0 的解码器遇到浏览器发的 H=1 必须报 COMPRESSION_ERROR 并关闭连接,不能把 Huffman 字节当原文输出,否则路由会拿到乱码路径。
错误四:表大小更新超过 SETTINGS 声明值。 001xxxxx 里的新上限如果大于本端 SETTINGS_HEADER_TABLE_SIZE,是连接级 COMPRESSION_ERROR。反过来,编码端在收到对端调小上限后,必须先发一个表大小更新再发头部块,否则对端会按旧上限淘汰,两边状态分叉。
错误五:把重复头部塞进 Map。 set-cookie 可以出现多次,HPACK 解码结果是有序列表,Http2HeaderField 用 List 承载。过早合并成 Map 会丢值,而 HttpRequest 的 Map 结构本身也是 8.x 留下的限制,代理多 set-cookie 时要在桥接层特殊处理。
边界:本实现不生成 Huffman,也不做头部列表大小限制。 MAX_HEADER_LIST_SIZE 既不解析也不执行(9.1 的 applySettings 让它落到 default 分支),超大头部块可以耗尽内存;生产实现要在解压过程中累计 name+value+32 并在超过上限时以 ENHANCE_YOUR_CALM 拒绝。本节的目标是协议正确,不是抗攻击。
小结
- HPACK 整数是「前缀 + 7 位续字节」,值等于前缀上限时必须写续字节,127 编成
7f 00。 - 静态表 61 项,动态表从索引 62 开始,最新项在前,每条记账
名字 + 值 + 32字节。 - 只有
01字面量写入动态表,0000、0001都不写;0001用于敏感头。 - Huffman 表是规范码,只存 257 个码长即可重建;解码要校验 EOS 与全 1 填充。
- 编码端不发 Huffman 合法,解码端必须支持;解码器必须绑定连接并按序处理头部块。
- RFC 7541 的 C.4.1、C.6.1 向量能同时验证静态表、动态表、整数和 Huffman 四件事。
头部能还原成请求之后,剩下的是「谁先发」。一条连接上多个流同时有数据,窗口只有 65535 字节,先发哪个流、发多少、什么时候停,决定了多路复用是真并发还是排队。下一节把窗口和调度补上。
阅读导航:上一节:9.1 HTTP/2 帧类型与流状态 · 下一节:9.3 多路复用、流量控制与优先级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。