《Java 网络编程基础》9.2 HPACK 与头部压缩

手写 HPACK 的最小可用实现:静态表、动态表、整数编码与 Huffman 解码,用 RFC 7541 的 C.4.1、C.6.1 测试向量逐字节验证解码结果,并说明编码端不支持 Huffman 时如何通过 H=0 字面量合法降级、解码端为何仍必须支持。

本节目标:能手写 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 很长时会出现,处理错会把表大小算成负数。

解码循环按首字节的高位分类,前四类是字段表示,最后一类是控制指令:

首字节模式表示行为
1xxxxxxxIndexed直接查表,不写动态表
01xxxxxxLiteral with incremental indexing读名字索引或字面量,读值,写入动态表
0000xxxxLiteral without indexing读名字与值,不写动态表
0001xxxxLiteral never indexed同不索引,语义上禁止中间代理改写
001xxxxxDynamic 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 多路复用、流量控制与优先级 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发