Hyper 与 Tower:工业级 HTTP 栈

第15章 h2 crate 与 HPACK:HTTP/2 的线路层

作者 杨艺韬 · 10,948 字

第15章 h2 crate 与 HPACK:HTTP/2 的线路层

15.1 HTTP/2 和 HTTP/1 几乎是两个协议

上一章我们收尾 HTTP/1 部分。现在要翻到 HTTP/2。第一件要说清楚的事是——HTTP/2 和 HTTP/1 在线路层几乎是两个完全不同的协议

维度HTTP/1.1HTTP/2
格式ASCII 文本二进制帧
并发一连接一请求(pipelining 实际弃用)一连接多 stream(100+ 并发)
headerName: Value 明文HPACK 压缩
bodyContent-Length 或 chunkedDATA 帧
流控TCP 层连接级 + stream 级两层应用流控
服务器推送Server Push(已 deprecated)
优先级PRIORITY 帧(已 deprecated)

这么大差别,如果让 hyper 把这两套用同一段代码实现——复杂度会爆炸。事实是:hyper 不自己实现 HTTP/2。它依赖一个独立的 crate——h2,作者也是 Sean McArthur(@seanmonstar)。

# hyper/Cargo.toml
h2 = { version = "0.4.6", optional = true }

hyper 的 proto/h2/ 模块只是一个薄适配层——把 h2 crate 提供的 SendStream / RecvStream / SendRequest / SendResponse 这些 API 包成 hyper 内部的 Connection / Dispatcher 模型。真正的协议工作——帧序列化、HPACK 压缩、状态机、流控——全在 h2 crate 里。

这种**“协议实现独立出 crate”**的分拆和 tower-service 独立出 tower crate 是同一种工程哲学:让核心协议层独立演进,让使用方无感地升级

这一章我们先搞清楚:h2 crate 承担什么、HPACK 如何压缩、hyper 的 proto/h2 做什么。后面两章(16、17)再深入多路复用和流控。

15.1.1 h2 crate 版本

本章基于 h2 = 0.4.6(2024 年 10 月发布)。这是 Hyper 1.9 所依赖的 stable 版本。h2 比 hyper 老——2016 年由 Carl Lerche(同时也是 Tokio 作者)启动。后来 Sean McArthur 接手维护。h2 的代码质量在 Rust 生态里堪称典范——它实现了 RFC 7540 (HTTP/2) + RFC 7541 (HPACK) 的全部行为,通过 h2spec 官方合规测试套件的所有 case。

15.2 HTTP/2 的线路:九字节前缀 + payload

HTTP/2 的数据单元是(frame)。每个 frame 以一个9 字节帧头开始:

+-----------------------------------------------+
|                 Length (24)                   |  3 bytes
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |               |  2 bytes
+-+-------------+---------------+-------------+-+
|R|                 Stream ID (31)              |  4 bytes
+-+-------------------------------------------+-+
|                   Payload                     |
+-----------------------------------------------+

字段:

  • Length (24 bit):payload 长度,最大 16MB(但默认 frame 最大 16KB,见 SETTINGS_MAX_FRAME_SIZE)。
  • Type (8 bit):帧类型——DATA, HEADERS, SETTINGS, PING, WINDOW_UPDATE, RST_STREAM, GOAWAY, CONTINUATION 等。
  • Flags (8 bit):类型相关标志位,比如 END_STREAM(流结束)、END_HEADERS(header 块结束)。
  • R (1 bit reserved) + Stream ID (31 bit):帧属于哪个 stream。0 表示连接级(SETTINGS、PING、GOAWAY 等)。

HTTP/2 的流水就是一串串这样的 frame——不同 stream 的 frame 可以交错,这就是多路复用。一个 DATA/stream1 + DATA/stream3 + HEADERS/stream5 的交错序列在一条 TCP 连接上共存——接收方根据 Stream ID 分发到对应的 request 处理器。

15.2.1 几个核心帧类型

HEADERS:传请求/响应的头部。用 HPACK 压缩。可以带 END_HEADERS flag(这是唯一的 HEADERS 帧)或继续用 CONTINUATION 帧传(罕见,因为 header 超过 16KB 才需要)。

DATA:body 的一部分。可以带 END_STREAM flag 表示这是 stream 的最后一个帧。

SETTINGS:连接级配置(见下节)。

PING:连接保活 + RTT 测量。对端必须回一个一模一样的 PING ACK。第 17 章专题讲。

WINDOW_UPDATE:流控窗口更新。HTTP/2 最复杂的一部分,第 16 章专题。

RST_STREAM:取消某个 stream(不影响连接上的其他 stream)。

GOAWAY:连接级关闭通知——“我要关了,ID <= last-stream-id 的 stream 继续处理,之后的不要发了”。优雅关闭的基础。第 17 章讲。

PRIORITY(deprecated):设置 stream 的优先级/依赖树。RFC 9113 已经把它移到附录,现代实现基本不用。

PUSH_PROMISE(deprecated):Server Push。Chrome 97 / Firefox 100 已禁用。新部署基本不考虑。

15.2.2 SETTINGS:连接的可调参数

SETTINGS 帧在连接建立时互相交换。核心参数:

  • SETTINGS_HEADER_TABLE_SIZE (默认 4096):HPACK 动态表大小。
  • SETTINGS_MAX_CONCURRENT_STREAMS (默认无上限):对端允许的并发 stream 数。
  • SETTINGS_INITIAL_WINDOW_SIZE (默认 65535):每个 stream 的初始流控窗口。
  • SETTINGS_MAX_FRAME_SIZE (默认 16384):单帧 payload 最大字节数。
  • SETTINGS_MAX_HEADER_LIST_SIZE (默认无上限):HPACK 解压后的 header 总大小上限。

每个参数都可以在运行时通过 SETTINGS 帧更新——对端收到 SETTINGS 后发一个 SETTINGS ACK 确认。这就是 HTTP/2 “协议参数可协商”的基础。

hyper 通过 hyper::server::conn::http2::Builder 暴露这些:

http2::Builder::new(TokioExecutor::new())
    .initial_stream_window_size(1024 * 1024)       // 1MB per stream
    .initial_connection_window_size(4 * 1024 * 1024) // 4MB per connection
    .max_concurrent_streams(1000)
    .serve_connection(io, svc)
    .await

每一个 setter 底层就是在 h2 的 h2::server::Builder 上调对应方法。完整参数含义第 16 章展开。

15.3 HPACK:为什么 header 要压缩

HTTP/1 的 header 是 ASCII 文本——GET / HTTP/1.1\r\nHost: example.com\r\nUser-Agent: Mozilla...\r\n\r\n。一个典型浏览器请求的 header 有 500-1500 字节。对于大量短请求(AJAX、REST API),header 的字节量可能超过 body

更糟的是 HTTP/1 keep-alive 下每个请求都重复发一模一样的 header——Host, User-Agent, Accept-Language, Cookie 全部每次一个字节不差地传。这明显可压缩。

HPACK 是 HTTP/2 给这个问题的解决方案,由 RFC 7541 定义。核心想法:维护一个双方同步的 “headers 字典”,header 的传输变成”字典索引”

15.3.1 静态表 + 动态表

HPACK 用两个表:

静态表(RFC 7541 Appendix A):61 个固定条目,HTTP/2 标准规定。举例:

IndexHeaderValue
1:authority
2:methodGET
3:methodPOST
4:path/
5:path/index.html
7:schemehttps
8:status200
32cookie
35content-length
58user-agent

Index 1-14 是 “pseudo-headers”(:method:path 这类)和最常用的 status codes。15-61 是常见的 request/response header name。

传输时,一个 :method: GET 的 header 变成一个字节——index 2 + 1 bit 表示”indexed”。压缩率接近 100:1。

动态表:连接期间双方各自维护的”最近见过的 header”缓存。容量由 SETTINGS_HEADER_TABLE_SIZE 决定(默认 4KB)。编码方:

  1. 已经在静态或动态表里:发 index。
  2. name 在表里,value 是新的:发 (index, new_value) + “要不要加入动态表”。
  3. 全新的 name + value:发 (literal_name, literal_value) + “要不要加入动态表”。

动态表用 LRU(最近最少使用)策略清理。相同的 header 反复出现 → 第一次传字面值,第二次起传 index,极其紧凑。

15.3.2 Huffman 编码

除了索引化,HPACK 对字面量字符串(name / value 字节)还用 静态 Huffman 编码——RFC 7541 Appendix B 提供一张固定的 Huffman 表,把常见 ASCII 字符用短 bit 串表示。

对英文/ASCII 内容(绝大多数 HTTP header),Huffman 能再压缩 20-30%。动态表命中 + Huffman 的双重效果让 HTTP/2 的 header 开销相对 HTTP/1 减少 80-95% 是常见数字。

15.3.3 HPACK 的安全陷阱

动态表有一个历史名坑:CRIME / BREACH 攻击的 HTTP/2 变种。如果 Cookie 或 Authorization 这样的敏感 header 被加入动态表——攻击者可以通过选择性注入不同请求、观察加密后的长度变化来猜测 cookie 值。

解决方案:第 9 章提到过的 HeaderValue::is_sensitive flag。标记为 sensitive 的 header 在 HPACK 编码时用 “Never Indexed”标志——强制每次传字面值,不加入动态表,不压缩

h2 crate 通过 http crate 的这个 flag 自动处理——你用 http::HeaderValue API 的 set_sensitive(true),h2 就会在线路上用正确的 HPACK 标志发。这是 http / http-body / h2 三个 crate 跨层协作的典型例子。

15.4 hyper 的 proto/h2:薄适配

hyper 的 proto/h2/mod.rs(264 行)是 hyper 调用 h2 crate 的入口。核心是三件事:

  1. PipeToSendStream<S>:把用户的 Body pipe 到 h2 的 SendStream
  2. strip_connection_headers:HTTP/2 禁止的 HTTP/1 连接级 header 要剥掉。
  3. server.rs / client.rs:把 h2 的 API 包成 hyper 的内部 trait。

15.4.1 PipeToSendStream:Body → h2 Stream

// hyper/src/proto/h2/mod.rs:85-94
pin_project! {
    pub(crate) struct PipeToSendStream<S> where S: Body {
        body_tx: SendStream<SendBuf<S::Data>>,
        data_done: bool,
        #[pin] stream: S,
    }
}

PipeToSendStream 是一个 future——它的 poll 不停地:

  1. 从 user Body poll 下一个 frame。
  2. 如果是 DATA:先向 h2 reserve capacity(流控预留),capacity 够了再 body_tx.send_data(chunk, is_eos) 发出。
  3. 如果是 Trailers:body_tx.send_trailers(map) 发出。
  4. 如果 body 结束(None):发一个空的 DATA with END_STREAM flag。

整个逻辑在 poll 方法里循环(源码 117-194)。最关键的部分是 reserve_capacity / poll_capacity 的流控循环

// mod.rs:126-148 简化
me.body_tx.reserve_capacity(1);
if me.body_tx.capacity() == 0 {
    loop {
        match ready!(me.body_tx.poll_capacity(cx)) {
            Some(Ok(0)) => {}                     // 仍然 0,继续等
            Some(Ok(_)) => break,                 // 有 capacity 了
            Some(Err(e)) => return error,
            None => return closed_error,
        }
    }
}

翻译:先预留 1 byte 的 capacity(让 stream 状态”活”起来),然后等真正的 capacity 到位。这个 poll_capacity 返回的数字反映stream 和 connection 两级流控窗口的最小值——第 16 章专讲。

这种循环处理是为了应对 “capacity = 0” 的情况——流控窗口瞬间为 0 是 HTTP/2 很常见的状态(刚消费完一个 WINDOW_UPDATE 之前)。hyper 循环等待直到真有 capacity。

15.4.2 strip_connection_headers

HTTP/2 把很多 HTTP/1 的”连接级”语义直接内置到协议里——不再需要在 header 里表达。表达了反而违法。

// hyper/src/proto/h2/mod.rs:34-41
static CONNECTION_HEADERS: [HeaderName; 4] = [
    HeaderName::from_static("keep-alive"),
    HeaderName::from_static("proxy-connection"),
    TRANSFER_ENCODING,
    UPGRADE,
];

fn strip_connection_headers(headers: &mut HeaderMap, is_request: bool) {
    for header in &CONNECTION_HEADERS {
        if headers.remove(header).is_some() {
            warn!("Connection header illegal in HTTP/2: {}", header.as_str());
        }
    }
    // ... 处理 TE 和 Connection header
}

四个禁用 header:

  • Keep-Alive:HTTP/1 的持久连接提示——HTTP/2 本身就是持久的,不需要。
  • Proxy-Connection:HTTP/1 代理的非标准 header——HTTP/2 不承认。
  • Transfer-Encoding: chunked:HTTP/2 用 DATA frame with/without END_STREAM 表示 body 分片——chunked 这个概念根本不存在。
  • Upgrade:HTTP/1 的协议升级——HTTP/2 有自己的扩展机制(Extended CONNECT 方法)。

Connection header 更狠——它的值里可能列了其他 header 的名字(如 Connection: Keep-Alive, X-Custom),这些 header 也要被连带剥掉。

这段 strip 的代码执行时发 warn! 日志——因为技术上这是”用户传了不合法的 header”,hyper 出于宽容帮你修复,但会记录以便排查。发到生产的 HTTP/2 代码应该在上层避免传这些 header,否则 log 噪声很大。

15.4.3 TE: trailers 的特例

if is_request {
    if headers.get(TE).map_or(false, |te_header| te_header != "trailers") {
        warn!("TE headers not set to \"trailers\" are illegal in HTTP/2 requests");
        headers.remove(TE);
    }
}

HTTP/2 允许 TE: trailers——因为它表达”我能处理 trailers”,这是 gRPC over HTTP/2 的必需 header。但其他 TE 值(TE: deflate 之类)都被禁。

这个细节是 gRPC 在 HTTP/2 上能跑的前提——第 10 章讲过 gRPC 把 status code 放在 trailer 里。如果服务端不认 TE: trailers,客户端会拒绝。

15.4.4 源码核对:hpack::Decoder 的 5 种 Representation 与 SizeUpdate

§15.3 介绍了 HPACK 的工作原理。打开 h2-0.3.27/src/hpack/decoder.rs:47-136 能看到 Decoder 内部把每个字节都按 5 种 Representation 之一识别——这是 HPACK 二进制流的全部状态空间:

enum Representation {
    /// Indexed header field representation
    /// First byte: 1xxxxxxx (top bit = 1 means 100% indexed)
    Indexed,

    /// Literal Header Field with Incremental Indexing
    /// First byte: 01xxxxxx
    LiteralWithIndexing,

    /// Literal Header Field without Indexing
    /// First byte: 0000xxxx
    LiteralWithoutIndexing,

    /// Literal Header Field Never Indexed
    /// First byte: 0001xxxx
    LiteralNeverIndexed,

    /// Dynamic Table Size Update
    /// First byte: 001xxxxx
    SizeUpdate,
}

5 种类型用第一个字节的 top bit 模式区分——这是 HPACK 设计的精髓——单字节就能 dispatch 到正确的解码路径

  • 1xxxxxxx → Indexed(最常见、压缩率最高)
  • 01xxxxxx → LiteralWithIndexing(新 header 第一次出现)
  • 001xxxxx → SizeUpdate(动态表大小变更,在 SETTINGS 协商后触发)
  • 0001xxxx → LiteralNeverIndexed(敏感 header,§15.3.3 讲过)
  • 0000xxxx → LiteralWithoutIndexing(一次性、不进表)

这 5 个分类覆盖所有 HPACK 操作。注释里 RFC 7541 的二进制格式图直接画出来——源码本身就是规范的二次注释——读 h2 源码不需要反复翻 RFC。

特别注意 LiteralNeverIndexed 的存在——它对应 §15.3.3 讲过的敏感 header(Cookie、Authorization)。HeaderValue::set_sensitive(true) 最终编码就走这条路径——固定字面量 + “Never Indexed” 标志。HPACK 协议层把”安全”和”压缩”做成了二选一的位标记——不用单独走另一条协议、就在编码时多设一位。这种设计的高效让安全和性能能共存。

15.4.5 源码核对:Decoder 的 4KB 初始 buffer 与 max_size_update 的二次协商

Decoder::new(size)(h2/src/hpack/decoder.rs:155-162):

pub fn new(size: usize) -> Decoder {
    Decoder {
        max_size_update: None,
        last_max_update: size,
        table: Table::new(size),
        buffer: BytesMut::with_capacity(4096),
    }
}

三条值得读懂的细节:

1、buffer: BytesMut::with_capacity(4096) 预分配 4KB——HPACK 字符串解码时复用这个 buffer 避免每次重新分配。4KB 是个统计经验值——大多数 HTTP/2 header 解码后总长度在这个范围内、超过会自动扩容。

2、max_size_update: Option<usize> + last_max_update: usize 双字段——这是 HPACK 协议两阶段协商的实现:

  • 用户通过 SETTINGS_HEADER_TABLE_SIZE 通知对端”我希望动态表是 X 大”——queue_size_update(X) 把 X 存进 max_size_update
  • 对端收到 SETTINGS 不会立刻改表大小、要等下一次 HEADERS 帧编码时用一个 SizeUpdate Representation 显式声明——这时 max_size_update.take() 取出值、last_max_update = size 应用

为什么要两阶段?因为 HPACK 编/解码器的状态必须严格同步——SETTINGS ACK 之间表大小如果直接改了、对端可能用旧大小编码、解码方就出错。两阶段确保改动只发生在边界点

3、queue_size_update 取 max

pub fn queue_size_update(&mut self, size: usize) {
    let size = match self.max_size_update {
        Some(v) => cmp::max(v, size),
        None => size,
    };
    self.max_size_update = Some(size);
}

多次连续的 SETTINGS 更新被 reduce 成 max——避免多个 SizeUpdate 在同一 batch 里互相覆盖。这种”reduce to upper bound”的 pattern 在 HPACK 规范的 5.1 节有详细规定——h2 的实现严格遵守。

15.4.6 源码核对:Decoder.decode 的 can_resize 状态机

Decoder::decode(h2/src/hpack/decoder.rs:176-220+)的循环里有个微妙状态:

let mut can_resize = true;
if let Some(size) = self.max_size_update.take() {
    self.last_max_update = size;
}

while let Some(ty) = peek_u8(src) {
    match Representation::load(ty)? {
        Indexed => {
            can_resize = false;     // ← 一旦遇到非 SizeUpdate 帧,禁止后续 resize
            let entry = self.decode_indexed(src)?;
            ...
        }
        LiteralWithIndexing => {
            can_resize = false;
            ...
        }
        SizeUpdate => {
            if !can_resize {
                return Err(InvalidMaxDynamicSize);  // ← 已经禁止了还来 resize 就 error
            }
            ...
        }
        ...
    }
}

HPACK 协议规定 SizeUpdate 必须出现在 HEADERS 块的最开始——不能在普通 header 之后再出现。can_resize 这个布尔状态机精确实现了这条规则:

  • 初始 true——开始时允许 SizeUpdate
  • 遇到任何非 SizeUpdate 的 representation → 翻成 false
  • 之后再来 SizeUpdate → 立刻返回 InvalidMaxDynamicSize 错误

如果不做这个状态控制——攻击者能在 header 块中间塞 SizeUpdate、强制对端在解码途中改变动态表大小、引发各种 indexing 错误。这个看似无关紧要的状态机是 HPACK 抗攻击的一道防线——h2 严格遵守了 RFC 7541 第 4.2 节的规定。

读 h2 源码这种”看似严格的位置约束实际上有安全考虑”的细节比比皆是——任何看起来”严格到毫无意义”的协议规定都不是无故为之。HPACK 只有两年开发期就被定型成 RFC——每一条约束都吸取了之前 SPDY 等先驱协议的教训

15.5 h2 crate 的分层设计

虽然我们不深入 h2 crate 源码(它是另一本书的体量),但它的分层值得一讲:

                                       
user code (hyper/tonic)                

h2::server::SendResponse / RecvStream  (stream-level API)
h2::client::SendRequest / ResponseFuture

h2::proto::Connection                  (connection state machine)

h2::frame::* (Data / Headers / Settings / ...) (frame encode/decode)

h2::hpack::{Encoder, Decoder}          (HPACK)

tokio AsyncRead/AsyncWrite             (bytes)

四层:

  1. 用户 API 层:暴露 SendStream / RecvStream 等。
  2. Connection 层:状态机、多路复用调度、流控。
  3. Frame 层:每种 frame 类型的 serde。
  4. HPACK 层:Headers 的压缩/解压。

这种分层让每一层可以独立测试——h2 的测试套件对每一层都有独立覆盖。对比 hyper 的 proto/h1,因为协议比较简单,层次没那么清晰。

15.5.1 h2 的依赖

h2 本身的依赖非常精简:

  • tokio(需要 AsyncRead/Write)
  • bytes
  • http
  • futures-*
  • fnv(faster-than-default hasher for HPACK)

没有依赖 hyper——这是 h2 独立的关键。其他 HTTP/2 库(如 tonic)也能直接用 h2 而不经过 hyper。

15.5.2 源码核对:Encoder 的 SizeUpdate 双值结构与 update_max_size 状态机

§15.4.5 讲过 Decoder 端的 size update。Encoder 端有个对应但更复杂的状态——SizeUpdate 是个双值枚举(h2/src/hpack/encoder.rs:13-17):

enum SizeUpdate {
    One(usize),
    Two(usize, usize), // min, max
}

为什么要 Two 变体?因为 RFC 7541 第 4.2 节规定:编码方调整动态表大小时,必须同时通告 min 和 max 两个 SizeUpdate 帧——一个负责”压缩到小尺寸”(释放空间)、一个负责”扩展到大尺寸”。这让对端能精确同步两次表大小变化。

update_max_size(encoder.rs:30-58)的状态机看似复杂、其实在维护一个不变量——当用户多次调用 update_max_size 时、保留正确的”min/max 跨度”

pub fn update_max_size(&mut self, val: usize) {
    match self.size_update {
        Some(SizeUpdate::One(old)) => {
            if val > old {
                if old > self.table.max_size() {
                    // old 已经比当前大、再来更大的——直接覆盖
                    self.size_update = Some(SizeUpdate::One(val));
                } else {
                    // old 比当前小、val 比 old 大——需要 min=old, max=val
                    self.size_update = Some(SizeUpdate::Two(old, val));
                }
            } else {
                // val 比 old 小——直接覆盖(取小的)
                self.size_update = Some(SizeUpdate::One(val));
            }
        }
        Some(SizeUpdate::Two(min, _)) => {
            if val < min {
                self.size_update = Some(SizeUpdate::One(val));
            } else {
                self.size_update = Some(SizeUpdate::Two(min, val));
            }
        }
        None => {
            if val != self.table.max_size() {
                self.size_update = Some(SizeUpdate::One(val));
            }
        }
    }
}

这段代码实现了 RFC 4.2 节的所有 corner case

  • None 状态 + 新值等于当前表大小 → 不必更新(省一帧)
  • 已有 One(old) + 新 val 更大且 old 比当前更大 → 单帧覆盖(避免 redundant Two)
  • 已有 One(old) + 新 val 更大但 old 比当前小 → 升级到 Two(old, val) 表示”从老到新走一遍”
  • 已有 Two(min, _) + 新 val 更小到 min 以下 → 退回 One

这个状态机看起来繁琐——但每条分支对应一个具体的 RFC 行为约束。协议库的精确性就体现在这种 corner case 处理——一个 if 判断错了就违反 spec、对端会断连。

15.5.3 源码核对:Encoder.encode 的 last_index 优化——重复 name 不重复查表

Encoder.encode(encoder.rs:61-97)有个不起眼的优化:

let mut last_index = None;
for header in headers {
    match header.reify() {
        Ok(header) => {
            let index = self.table.index(header);
            self.encode_header(&index, dst);
            last_index = Some(index);
        }
        Err(value) => {
            // 上一个 header 的 name 复用、只编码新的 value
            self.encode_header_without_name(
                last_index.as_ref().unwrap_or_else(|| panic!(...)),
                &value, dst,
            );
        }
    }
}

Header.reify() 返回 Result<HeaderWithName, Value>——Err(value) 意味着这个 header 没有 name 字段、要复用上一个 header 的 name。这对应 HTTP 里多值 header的场景:

Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: must-revalidate

三个 Cache-Control 在 HPACK 里只编码 name 一次——后两个用 last_index 复用第一个的 name index、只编码各自的 value。压缩比直接翻几倍。

如果不做这个优化——每次都 self.table.index(header) 查表——动态表 O(N) 线性扫一次(N 是表条目数),累加起来是显著开销。last_index 缓存让这种”短期内同 name 反复出现”的场景几乎零开销

这是协议设计层和实现层共同发力的例子——HTTP 层允许 multi-value header、HPACK 层支持 name-only literal、Encoder 实现层用 last_index 缓存——三层配合让”同 name 多 value”成为最高效的 HPACK 用法。

15.6 跨语言对照

来看 HTTP/2 实现在其他生态里的分层:

Go net/http:HTTP/2 支持内嵌在标准库(golang.org/x/net/http2internal/http2)——单一实现,不能替换。

nghttp2 (C):2014 年开始的 HTTP/2 C 参考实现,是 curl / nginx / envoy 的 HTTP/2 底层。许多测试套件(包括 h2spec)首先以 nghttp2 为参考。

Rust h2独立 crate,多个用户(hyper、tonic、vertx-rs、quinn’s h3 子项目的 structural reference)共享。

Rust 的”独立协议 crate”模式不是巧合——它与卷二《MCP 协议设计与实现》里讨论过的”协议实现应当独立于应用框架”是同一种工程哲学。MCP 的 TypeScript SDK / Python SDK 把”协议 + 传输”和”server/client 使用模式”分开;Rust 的 h2 / http / http-body 也是这种模式。把标准部分做成独立 crate,让应用层有选择——是一种重要的 “生态层次” 意识。

15.6.1 源码核对:get_static 的 61 个 match arm——RFC 7541 Appendix A 的字面化

§15.3.1 提到了 HPACK 静态表的 61 个条目。打开 h2-0.3.27/src/hpack/decoder.rs:619+get_static 函数把整个静态表手写成 61 个 match arm

pub fn get_static(idx: usize) -> Header {
    match idx {
        1 => Header::Authority(BytesStr::from_static("")),
        2 => Header::Method(Method::GET),
        3 => Header::Method(Method::POST),
        4 => Header::Path(BytesStr::from_static("/")),
        5 => Header::Path(BytesStr::from_static("/index.html")),
        6 => Header::Scheme(BytesStr::from_static("http")),
        7 => Header::Scheme(BytesStr::from_static("https")),
        8 => Header::Status(StatusCode::OK),
        9 => Header::Status(StatusCode::NO_CONTENT),
        10 => Header::Status(StatusCode::PARTIAL_CONTENT),
        // ...
        15 => Header::Field {
            name: header::ACCEPT_CHARSET,
            value: HeaderValue::from_static(""),
        },
        16 => Header::Field {
            name: header::ACCEPT_ENCODING,
            value: HeaderValue::from_static("gzip, deflate"),  // ← 注意有 default value
        },
        // ... 一直到 61 ...
    }
}

四条值得注意的实现选择:

1、用 match arm 而不是 const array。如果用 const STATIC_TABLE: [(HeaderName, &str); 61]——查找时是 STATIC_TABLE[idx-1]O(1) 数组访问。但 match arm 编译后会被 LLVM 优化成跳表(jump table)——也是 O(1)。两者性能相当。h2 选 match 是因为可读性——每个 arm 显式写明 idx 和值、不用查 array 头部找索引含义。

2、Header::Method(Method::GET) 而不是 Header::Field { name: METHOD, value: "GET" }——pseudo-headers(:method:path:scheme:status:authority)用专门的 enum variant 而不是通用 Field。这让类型系统强制 GET/POST 是合法 method、200/404 是合法 status——在 decode 阶段就拦截非法值。

3、HeaderValue::from_static("gzip, deflate") 不是空字符串——索引 16 (accept-encoding) 在 RFC 7541 Appendix A 里默认值就是 “gzip, deflate”。如果用户的 GET 请求带这个 accept-encoding——HPACK 直接编码成 1 字节(索引 16),不需要传 value。这是 HPACK 利用统计经验做的优化——大多数客户端的 accept-encoding 就这两种。

4、BytesStr::from_static 用于 string 类型字段——from_static 返回的 BytesStr 不持有内存所有权、指向编译期常量字符串。61 个静态表条目编译进二进制后零运行时分配。每次 hpack decode 一个 indexed header 都是零成本拷贝出 BytesStr。

这 100 行 match 看起来”无聊”——实际上是性能 + 类型安全 + 规范精确三位一体的工程产物。每一行都对应 RFC 7541 Appendix A 的一行——读 h2 源码可以不翻 RFC、读这段就是。

15.6.2 源码核对:Huffman decode 的 4-bit 状态机表查询法

§15.3.2 提到 HPACK 用静态 Huffman 编码。打开 h2-0.3.27/src/hpack/huffman/mod.rs:20-41

pub fn decode(src: &[u8], buf: &mut BytesMut) -> Result<BytesMut, DecoderError> {
    let mut decoder = Decoder::new();

    // Max compression ratio is >= 0.5
    buf.reserve(src.len() << 1);

    for b in src {
        if let Some(b) = decoder.decode4(b >> 4)? {
            buf.put_u8(b);
        }
        if let Some(b) = decoder.decode4(b & 0xf)? {
            buf.put_u8(b);
        }
    }

    if !decoder.is_final() {
        return Err(DecoderError::InvalidHuffmanCode);
    }
    Ok(buf.split())
}

四条性能/正确性细节:

1、buf.reserve(src.len() << 1):Huffman 解码后最坏情况是输入大小的 2 倍(最短 Huffman code 是 5 bit、解码出 8 bit 字符——压缩比 5/8、解码膨胀 8/5)。预分配 2x 大小避免 buf 反复扩容。

2、b >> 4b & 0xf:每个字节拆成两个 4-bit nibble——分别送进 decode4。这个 4-bit 拆分配合 DECODE_TABLE 的 256 × 16 大小(256 个状态 × 16 个 4-bit 输入)实现 state machine 查表——每次查 1 次 table 处理 4 bit、效率极高。

3、Huffman 码长度可变(最短 5 bit、最长 30 bit)——但通过状态机方式、每次处理 4 bit 是可行的——多次状态转移能拼出任意长度的码。这是 H2 spec 推荐的实现策略——不需要做复杂的 bit-level 状态管理。

4、if !decoder.is_final() 严格检查 EOS:解码完后必须停在 final state。如果输入数据非法、可能停在中间状态——立刻报错 InvalidHuffmanCode、不接受半合法的 Huffman 流。

编码端encode(mod.rs:43-66)用了一个 64-bit 累加器 + 40 bit 工作窗口的位移技巧——每次累加一个字符的 (nbits, code),bit_left 滑动;每凑够 8 bit 输出一字节。这是经典的”位流编码”实现——在 zlib、PNG 等格式里都见过同模板。

encode 末尾的 (1 << bits_left) - 1 写 EOS 填充——HPACK 规定 EOS 字符的 Huffman code 是全 1(30 个 1 bit)。(1 << bits_left) - 1 在 0 到 7 bit 范围内生成全 1 填充——正好用 EOS 的低 N 位。这种”用全 1 填充 byte 边界”的设计让 decoder 能识别”这后面没有真实数据、只是对齐”——避免错误尝试解码末尾的 padding bit。

这条机制对应 §15.4.5 讲过的 “is_final” 检查——如果填充 bit 数超过 7、就肯定不是合法 EOS、立刻 InvalidHuffmanCode。

整个 huffman/mod.rs 不到 100 行 + table.rs 自动生成几千行 lookup table——用空间(lookup table)换时间(O(N) 解码)——是 high-performance 编码器的经典套路。

15.6.3 源码核对:decode_int 的 prefix-encoded varint——HPACK 整数表示的精髓

HPACK 里所有整数都用一种独特的 “prefix-encoded varint” 表示——h2-0.3.27/src/hpack/decoder.rs:391-448decode_int 是完整实现:

fn decode_int<B: Buf>(buf: &mut B, prefix_size: u8) -> Result<usize, DecoderError> {
    const MAX_BYTES: usize = 5;
    const VARINT_MASK: u8 = 0b0111_1111;
    const VARINT_FLAG: u8 = 0b1000_0000;

    if prefix_size < 1 || prefix_size > 8 {
        return Err(DecoderError::InvalidIntegerPrefix);
    }

    let mask = if prefix_size == 8 { 0xFF } else { (1u8 << prefix_size).wrapping_sub(1) };
    let mut ret = (buf.get_u8() & mask) as usize;

    if ret < mask as usize {
        // Value fits in the prefix bits
        return Ok(ret);
    }

    // The int did not fit in the prefix bits, so continue reading varint
    let mut bytes = 1;
    let mut shift = 0;

    while buf.has_remaining() {
        let b = buf.get_u8();
        bytes += 1;
        ret += ((b & VARINT_MASK) as usize) << shift;
        shift += 7;

        if b & VARINT_FLAG == 0 {
            return Ok(ret);
        }

        if bytes == MAX_BYTES {
            return Err(DecoderError::IntegerOverflow);
        }
    }

    Err(DecoderError::NeedMore(NeedMore::IntegerUnderflow))
}

HPACK 整数编码的核心思想:每个 representation 类型(Indexed、LiteralWithIndexing 等)的第一个字节有几个 bit 用作 type marker(前缀位)——剩下的 bit 用作整数值的”高位”。

举例:Indexed Header 类型用 1xxxxxxx(top bit = 1 是类型标记),剩下 7 bit 是 prefix——值 0-126 直接装进这 7 bit。索引 1-126 只占 1 字节——HPACK 大部分常见 header 都命中这条 fast path。

如果值 ≥ 127——prefix 7 bit 全填 1(即 1xxxxxxxx & 0x7F == 0x7F),后面用经典 varint(每字节 7 bit value + 1 bit continuation)表示余下部分。值越大、字节数越多。

四条值得读懂的细节:

1、prefix_size 参数化:这同一函数对所有 representation 复用——Indexed 用 prefix=7、LiteralWithIndexing 用 prefix=6、SizeUpdate 用 prefix=5——参数控制 mask。一份代码处理 5 种 representation 的 integer field

2、MAX_BYTES = 5 防 overflow:5 字节 prefix-encoded varint 能表示的最大值约 2^28。h2 限制最多读 5 字节、超过抛 IntegerOverflow——防止恶意流让 decoder 读无限多字节、内存爆炸。这是 DoS 防护——任何处理用户输入的 codec 都要有这种限制。

3、if ret < mask as usize fast return:值能装在 prefix bits 里时直接返回——避免进入 varint 循环——这是热路径优化。RFC 7541 的整数编码格式专门为”小值常见、大值罕见”设计。

4、b & VARINT_FLAG == 0 才结束循环——top bit = 1 表示”还有更多字节”。这条 continuation bit 让 varint 长度不需要前置声明、可以”按需扩展”——但代价是每字节只能传 7 bit value。

这个设计是 HPACK 整数表示的精髓——短值快、长值能装——和 Protocol Buffers 的 varint 是同源思路(PB 也用 7-bit value + 1-bit continuation)。两者独立诞生、收敛到同一个最优解,说明这是这类问题的”natural answer”。

15.7 HPACK 实测

来看一个具体的 HPACK 压缩例子。假设一个典型 GET 请求的 header:

:method: GET
:scheme: https
:path: /api/v1/users/42
:authority: example.com
user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
accept-language: en-US,en;q=0.5
accept-encoding: gzip, deflate, br
cookie: session=abc123xyz; prefs=dark-mode

HTTP/1 编码大约 420 字节

HTTP/2 首次 HPACK 编码:

  • :method: GET1 字节(静态表索引 2)
  • :scheme: https1 字节(静态表索引 7)
  • :path: /api/v1/users/42index for :path + literal Huffman path ≈ 17 字节
  • :authority: example.comliteral ≈ 12 字节
  • user-agent: Mozilla...:name index + Huffman value ≈ 68 字节
  • 其他 header 类似

首次合计 ≈ 180 字节,约 HTTP/1 的 45%。

同一连接上第二次相同请求(不同 path):

  • :method: GET → 1 字节
  • :scheme: https → 1 字节
  • :path: /api/v1/users/43literal ≈ 17 字节(path 是新的)
  • :authority: example.com动态表命中 ≈ 2 字节
  • user-agent: ...动态表命中 ≈ 2 字节

合计 ≈ 30 字节,约 HTTP/1 的 7%。

这就是”长连接 + 重复 header”场景下 HTTP/2 相对 HTTP/1 的头部压缩优势。对 API 网关、微服务间通信(同一个 user-agent、同一套 auth token、同一套 trace header),HPACK 是显著降低带宽的工程手段。

15.8 何时用 HTTP/2、何时不用

把这一章的 insight 合成一个实战判断表:

场景建议
大量小请求(REST API / AJAX)HTTP/2 强力推荐。HPACK + 多路复用巨大收益。
大文件上传/下载HTTP/1.1 相当。HTTP/2 的流控开销 vs HTTP/1 简单——没啥差别。
低延迟/WebSocket 替代HTTP/2 + Server-Sent Events 或 WebSocket-over-HTTP/2。
内部服务间通信gRPC-over-HTTP/2。Tonic 生态成熟。
客户端库通过 NAT注意 HTTP/2 的 PING 保活(第 17 章)。
客户端是嵌入式设备 / 低带宽HTTP/2 有额外开销(SETTINGS、流控)——每连接 ~2KB 固定成本。
终端用户的 Web 服务HTTP/2 on edge、HTTP/1 to origin 是主流部署(CDN 这么干)。

15.6.4 源码核对:try_decode_string 的 Huffman flag 与 marker 模式

Decoder::try_decode_string(h2/src/hpack/decoder.rs:304-346)展示了 HPACK 字符串解码的完整逻辑:

fn try_decode_string(
    &mut self,
    buf: &mut Cursor<&mut BytesMut>,
) -> Result<StringMarker, DecoderError> {
    let old_pos = buf.position();
    const HUFF_FLAG: u8 = 0b1000_0000;

    // The first bit in the first byte contains the huffman encoded flag.
    let huff = match peek_u8(buf) {
        Some(hdr) => (hdr & HUFF_FLAG) == HUFF_FLAG,
        None => return Err(DecoderError::NeedMore(NeedMore::UnexpectedEndOfStream)),
    };

    // Decode the string length using 7 bit prefix
    let len = decode_int(buf, 7)?;

    if len > buf.remaining() {
        return Err(DecoderError::NeedMore(NeedMore::StringUnderflow));
    }

    let offset = (buf.position() - old_pos) as usize;
    if huff {
        let ret = {
            let raw = &buf.chunk()[..len];
            huffman::decode(raw, &mut self.buffer).map(|buf| StringMarker {
                offset, len,
                string: Some(BytesMut::freeze(buf)),
            })
        };
        buf.advance(len);
        ret
    } else {
        buf.advance(len);
        Ok(StringMarker {
            offset, len,
            string: None,    // ← 非 Huffman 时不立刻 copy
        })
    }
}

四个值得读懂的设计:

1、HUFF_FLAG = 0b1000_0000——字符串首字节的最高位是 Huffman 标志= 1 表示后面是 Huffman 编码、= 0 表示原始 ASCII。剩下 7 bit 是字符串长度的 prefix(同 §15.6.3 的 prefix-encoded varint)。一个 byte 同时携带 type marker + 长度高位——HPACK 的字节级紧凑设计的又一例。

2、Huffman 编码的字符串需要立刻 decode——因为 huffman::decode 是 streaming 的、必须从头扫一遍才能知道结果长度。代码里 huffman::decode(raw, &mut self.buffer) 把解码结果写进 self.buffer(§15.4.5 的 4KB 预分配 buffer)。

3、非 Huffman 字符串走 string: None 的 lazy 路径——只记录”在 buf 的什么位置、多长”,不立刻 copy 出来。等真正用到时再用 marker.consume 提取。这是 zero-copy 优化——能直接从 input buffer 引用就不重复分配。

4、StringMarker 类型本身是 lazy 求值的设计——offset + len + Option<Bytes> 三个字段——Some(bytes) 表示已 decode 的 Huffman 结果;None 表示还在 buf 里、按 offset+len 提取。两条路径用同一类型表达——上层代码不需要分支

这条 try_decode_string 短短 40 行代码同时做了:HUFF flag 识别、长度解析(带 underflow 检测)、Huffman/literal 双路径、zero-copy lazy、buffer 复用——是协议解析里 微观优化的教科书示范

15.6.5 源码核对:encode_header 的 5 种 Index——和 5 种 Representation 的对应

Encoder.encode_header(h2/src/hpack/encoder.rs:115-154)按 Index 的 5 种 variant dispatch 到不同的编码路径:

fn encode_header(&mut self, index: &Index, dst: &mut BytesMut) {
    match *index {
        Index::Indexed(idx, _) => {
            // 静态/动态表 100% 命中 → 1 字节 indexed
            encode_int(idx, 7, 0x80, dst);
        }
        Index::Name(idx, _) => {
            // name 在表里、value 是新的 → not indexed (literal value)
            let header = self.table.resolve(index);
            encode_not_indexed(idx, header.value_slice(), header.is_sensitive(), dst);
        }
        Index::Inserted(_) => {
            // 全新 header、要插入动态表
            let header = self.table.resolve(index);
            assert!(!header.is_sensitive());
            dst.put_u8(0b0100_0000);
            encode_str(header.name().as_slice(), dst);
            encode_str(header.value_slice(), dst);
        }
        Index::InsertedValue(idx, _) => {
            // name 在表里、value 是新的、要把整条插进表
            let header = self.table.resolve(index);
            assert!(!header.is_sensitive());
            encode_int(idx, 6, 0b0100_0000, dst);
            encode_str(header.value_slice(), dst);
        }
        Index::NotIndexed(_) => {
            // 不进表、可能是敏感 header
            let header = self.table.resolve(index);
            encode_not_indexed2(
                header.name().as_slice(),
                header.value_slice(),
                header.is_sensitive(),
                dst,
            );
        }
    }
}

5 种 Index variant 与 §15.4.4 的 5 种 Representation 一一对应:

Encoder IndexRepresentation字节代价触发场景
IndexedIndexed1-3 字节全命中
NameLiteralWithoutIndexingname idx + valuename 在表、value 新
InsertedLiteralWithIndexingname + value + 进表全新 header
InsertedValueLiteralWithIndexingname idx + value + 进表name 在表、value 新、要进表
NotIndexedLiteralNeverIndexedname + value + 永不进表sensitive header

两条值得注意的安全检查:

1、assert!(!header.is_sensitive()) 在 Inserted 和 InsertedValue 路径——sensitive header 不应该走”进动态表”路径。如果上层逻辑误传 sensitive header 到这里——panic——比静默把 cookie 加进表然后被 CRIME 攻击好得多。这是 fail-fast 在协议安全场景的典型应用。

2、Encoder 的 sensitive 判定来自 HeaderValue::is_sensitive——encode_not_indexedencode_not_indexed2(encoder.rs:195-214)根据 sensitive flag 在第一字节填 0b10000(NeverIndex)或 0(NotIndexed)——单 bit 区分两种路径

这条 5 路 dispatch 是 HPACK 编码器的 brain——上层只决策”如何使用动态表”(5 种 Index),编码器自动按规范产出对应的 binary representation。

15.6.6 源码核对:encode_size_update 的 prefix=5——SizeUpdate Representation 的字节边界

encode_size_update(h2/src/hpack/encoder.rs:191-193)只有一行:

fn encode_size_update(val: usize, dst: &mut BytesMut) {
    encode_int(val, 5, 0b0010_0000, dst)
}

三个数字串起来的语义:

1、prefix=5——SizeUpdate 的 type marker 占 3 bit(顶部 001),剩下 5 bit 是值的低位。如果值 < 31 直接 1 字节搞定;超过用 varint 续传。

2、flag=0b0010_0000——这是 SizeUpdate 的 type marker bit pattern。001x_xxxx——和 §15.4.4 的 5 种 representation 表格里 SizeUpdate 的”001xxxxx”对得上。

3、val: usize——动态表大小(字节数)。RFC 默认 4096、可被 SETTINGS_HEADER_TABLE_SIZE 调整。

encode_int(val, 5, 0b0010_0000, dst) 把 val 用 5-bit prefix 写到 dst 里、首字节顶部 3 bit 设为 001——一行代码完成完整 SizeUpdate 编码。HPACK 的紧凑性让协议层每一种动作都能用 1-3 行代码表达——这是协议设计的”正交性”——每个原语独立、不冲突、组合产生表达力。

15.8.1 本章源码定位索引

为便于读者按图索骥:

主题源文件关键行号/位置
Decoder 5 种 Representationh2-0.3.27/src/hpack/decoder.rs47-136
Decoder::new + 4KB buffer同上153-162
max_size_update 双字段同上16-19
queue_size_update同上165-173
decode 主循环 + can_resize同上176-220+
decode_int 的 prefix-varint同上391-448
get_static 静态表 61 条同上619-770+
Encoder 主体h2-0.3.27/src/hpack/encoder.rs8-160+
SizeUpdate 双值枚举同上13-17
update_max_size 状态机同上30-58
encode 的 last_index 优化同上60-97
Huffman decodeh2-0.3.27/src/hpack/huffman/mod.rs20-41
Huffman encode同上43-66
hyper PipeToSendStreamhyper/src/proto/h2/mod.rs85-94 + 117-194
strip_connection_headers同上34-41

源码版本:h2 0.3.27 + hyper 1.x。

15.8.2 本章与全书体系的呼应

本章讲 HPACK + h2 接口,和书中其他部分的接合点:

与第 9 章(http crate 的 HeaderValue)的接续:第 9 章讲过 HeaderValue::set_sensitive(true)——这一章揭示了它在 HPACK 编码层的真实效果(走 LiteralNeverIndexed 路径、强制每次传字面值)。两章合起来构成”用户层标记 → 协议层行为”的完整故事。

与第 10 章(gRPC over HTTP/2)的关键依赖:本章 §15.4.3 的 TE: trailers 特例——是 gRPC 能在 HTTP/2 上跑的前提条件。第 10 章讲 gRPC 把 status code 放在 trailers 里——本章讲为什么 trailers 能传到对端不被 strip。

与第 11 章(HTTP/1 解析)的对比:HTTP/1 的解析靠 httparse(手写状态机);HTTP/2 的解析靠 h2 crate(独立的 codec 库)。两者都把”协议解析”独立出 hyper——但分别为不同协议复杂度做了不同程度的抽象。HTTP/1 一个 crate 就够、HTTP/2 需要 frame + connection + hpack 三层。

与第 16 章(HTTP/2 流控)的铺垫:本章 §15.4.1 的 PipeToSendStreamreserve_capacity + poll_capacity 等待流控——下一章会展开”为什么需要流控、流控窗口怎么计算、WINDOW_UPDATE 帧的发送策略”——本章只展示消费者端怎么用,下一章展示完整机制

与第 17 章(连接保活和优雅关闭)的呼应:本章列了 GOAWAY 和 PING 帧的存在——下一章和第 17 章会真正展开它们的工作机制。本章只是列出了 HTTP/2 的工具集,后两章是用这些工具解决具体工程问题

读完本章 + 下两章你会发现 HTTP/2 不是一个”简单替换 HTTP/1”的协议——它是一个带有完整流控、连接管理、加密整合的并发协议。掌握它需要的不只是协议规范、还要理解它在 hyper 这种生产级实现里如何被工程化

15.9 落到你键盘上

本章给的工具和去处:

  • h2 crate 的 src/proto/connection.rs——它是 HTTP/2 连接状态机的核心。比 hyper HTTP/1 的 conn.rs 更复杂,但分层清晰。
  • nghttp 工具观察实际 HTTP/2 流量
    nghttp -nv https://example.com/
    会打印所有 SETTINGS / HEADERS / DATA / WINDOW_UPDATE 帧——这是理解协议最直观的方式。
  • 读 RFC 7541 的 Appendix A——静态表 61 条。把它扫一遍,你下次看 HPACK 压缩数据时会直接认出各种索引。

下一章我们进入 HTTP/2 最难但最有收获的部分——多流调度与流控。这里是所有”HTTP/2 怎么配置”问题的归宿。

15.9.1 给 Rust 协议库作者的 6 条工程启示

读完本章 + h2 源码片段,能提炼出几条可移植的工程模式——任何写网络协议库的 Rust 开发者都能用:

1、独立 crate 优于内嵌实现。h2 独立于 hyper、tower-service 独立于 tower——让协议层和应用框架分别演进。如果你在写一个新协议(QUIC、WebTransport、自定义私有协议),考虑把”线路层”独立出 crate——好处会随时间显现。

2、用 enum + match 表达协议的有限状态空间。HPACK 的 5 种 Representation、HTTP/2 的 frame types、TLS 的 record types——都是有限的、可枚举的状态。Rust 的 enum + 穷尽 match 让”漏处理某种状态”在编译期就被发现。

3、用 cmp::max reduce 多次配置变更。如 §15.4.5 的 queue_size_update——多次 SETTINGS 更新被 reduce 成 max。这种”幂等聚合”让协议参数变更不会因为 batch 内顺序而失效。

4、prefix-encoded varint 是值范围分布严重不均时的最优编码。短值快、长值能装。除了 HPACK,Protocol Buffers、CBOR、Cap’n Proto 都用类似设计。

5、防 DoS 必须有显式 MAX_BYTES。任何处理用户输入的 codec 都要给”读多少字节就放弃”一个上限。h2 的 MAX_BYTES = 5 防 integer overflow——你的代码也应该有类似硬限制。

6、用 marker 类型实现 lazy 求值StringMarker { offset, len, string: Option<Bytes> }——已计算和未计算用同一类型表达、调用方不需要分支。zero-copy 优化的优雅方式。

这 6 条模式不只在 HPACK 里——你会在 quinn (QUIC)、rustls (TLS)、prost (PB) 等 Rust 协议库里反复见到。学会识别这些模式让你能快速读懂任何 Rust 协议代码

15.10 读完本章能回答的具体问题清单

作为本章掌握度自测:

  1. HPACK 的 5 种 Representation 是什么?怎么用 1 字节区分?(§15.4.4——top bit pattern dispatch)
  2. HPACK 的 SizeUpdate 必须在 HEADERS 块的哪里?为什么?(§15.4.6——必须最开头、防中间篡改攻击)
  3. HeaderValue::set_sensitive(true) 在线路上是什么效果?(§15.4.4 + §15.3.3——LiteralNeverIndexed、不进表、不压缩)
  4. HPACK 静态表为什么 accept-encoding 索引值是 “gzip, deflate” 不是空?(§15.6.1——RFC 7541 给的”统计经验默认值”,让最常见请求 1 字节搞定)
  5. HPACK 的整数表示为什么用 prefix-encoded varint?(§15.6.3——前缀位与 type marker 共字节、值 < 127 只占 1 字节)
  6. MAX_BYTES = 5 是什么作用?(§15.6.3——DoS 防护、防恶意流读无限字节)
  7. Huffman decode 的 4-bit nibble 拆分是什么策略?(§15.6.2——状态机 + 256×16 lookup table、O(N) 解码)
  8. Encoder 的 SizeUpdate 为什么要 Two(min, max) 双值?(§15.5.2——RFC 4.2 节强制要求 min/max 都通告)
  9. 多个 Cache-Control header 在 HPACK 里是什么效果?(§15.5.3——name 只编码一次、用 last_index 复用、巨大压缩)
  10. HTTP/2 strip 的 4 个连接级 header 是什么?为什么?(§15.4.2——Keep-Alive/Proxy-Connection/TE/Upgrade、HTTP/2 把这些语义内置了)
  11. gRPC 为什么需要 TE: trailers 例外?(§15.4.3——gRPC 把 status code 放在 trailers)
  12. PipeToSendStream 为什么要 reserve_capacity + poll_capacity 循环?(§15.4.1——HTTP/2 流控窗口可能瞬间为 0、循环等待)

能答 8 条以上——你对 HTTP/2 协议层 + h2 crate 实现的理解已经超越大多数 Rust 后端工程师。这种深度的协议理解力在 gRPC 调优、API gateway 设计、Service Mesh 内部实现等场景下都是稀缺技能。

15.9.2 HPACK 的安全研究历史——CRIME / BREACH 之外

§15.3.3 提了 CRIME/BREACH 是 HPACK 防 sensitive header 的动机。但 HPACK 出过的安全问题不止这些——一份完整的安全研究简史能让你理解为什么协议库要那么严格:

CRIME (2012):针对 SPDY 的 deflate 头部压缩——攻击者通过自适应注入 cookie=axxxcookie=bxxx…、观察压缩后的字节数变化、二分查找出真实 cookie 值。HPACK 引入”NeverIndexed”标志就是为了 mitigate 这类攻击——sensitive header 永远不进表、不被压缩、长度变化无法泄漏信息。

BREACH (2013):CRIME 的 HTTP/1 + gzip 变种——同样的字节长度侧信道、攻击 HTTP body 而不是 header。和 HPACK 无直接关系、但揭示了”任何动态压缩 + 用户控制输入”都有侧信道风险。HTTP/2 没引入 body 压缩协议(用户层用 gzip/br 是另一回事)部分受 BREACH 影响。

HPACK Bomb (2016):恶意 client 发一个超大动态表条目(比如 4GB header 值)让 server OOM。h2 的 SETTINGS_MAX_HEADER_LIST_SIZE 和 decoder 的 length 检查就是 mitigate 这类攻击。

HPACK 0day (2024 多次):每年都有人在 h2spec 之外发现新的边界 case——比如某种构造的 SizeUpdate 序列让 server 状态机进入死循环。h2 crate 的每次小版本升级 changelog 经常包含 “fix HPACK decoder edge case”。

这条历史告诉我们一件事——协议安全是个持续投入的领域、不是一次性达成。h2 / nghttp2 / curl 等成熟实现至今每年都在修 bug——你自己写一个 HPACK 实现 99% 会有漏洞。用成熟 crate 而不是自己实现——这是协议层的金科玉律。

15.9.3 HPACK 给非 HTTP 协议的启示

HPACK 的设计思路不只用于 HTTP——任何”短消息频繁交换、消息内容高度重复”的协议都能借鉴:

MQTT 5.0 的 “Topic Alias” 功能——和 HPACK 静态/动态表是一个思路。客户端订阅过的 topic 可以用一个 alias number 引用、避免重复传 topic 字符串。MQTT 5.0 spec 直接引用了 HPACK 的设计。

gRPC 的 reflection service 用动态注册的 method 和 message 类型表——本质也是”客户端和服务端共享状态、用 index 引用”。

TLS 1.3 的 0-RTT 利用之前 session 的 PSK——和 HPACK 用动态表”利用之前 connection 的状态”是同源思路。两者都是 “state ful protocol upgrade”——协议层维持状态以减少冗余传输。

QUIC 的 QPACK(HTTP/3 的头部压缩)——直接是 HPACK 的演化版。QPACK 解决了 HPACK 的”head-of-line blocking”问题(HPACK 必须按顺序解码、QPACK 让流可以乱序解码)——但核心的”静态表 + 动态表 + 索引”思路完全继承。

如果你将来设计一个新的网络协议——任何短消息高频场景下 认真考虑动态字典 + 索引这个 pattern。HPACK 用 7000 字 RFC 把这个 pattern 标准化、成果是带宽节省 80-90%——这是协议设计能给的最高回报。

15.10.1 HPACK 性能微观对比

把本章讲的所有源码细节合起来——给一组实际数字让你感受 HPACK 在不同场景下的实际收益:

场景 1:单次 GET 请求(无 cookie)

编码字节数相对 HTTP/1
HTTP/1 ASCII200-300 字节100%
HPACK 首次(仅静态表)80-120 字节40%
HPACK 首次(静态 + Huffman)60-90 字节30%

场景 2:API 客户端连续 100 次相同 endpoint

编码单次字节数100 次总字节相对 HTTP/1
HTTP/1 keep-alive250 字节25 KB100%
HPACK + 动态表(首次后命中)5-15 字节~1.2 KB5%

场景 3:浏览器加载一个页面(100 个资源、共享 user-agent + cookie)

编码总 header 字节相对 HTTP/1
HTTP/1~100 KB100%
HPACK (动态表 4KB)~10 KB10%
HPACK (动态表 64KB)~5 KB5%

这些数字解释了为什么 CDN 几乎全部转向 HTTP/2——HPACK 在”高频小请求”场景下的收益是数量级的。Cloudflare、Fastly、Akamai 的统计公开数据显示生产 HTTP/2 流量的 header 压缩比常达 90% 以上。

但也注意:单次大文件下载的 HPACK 收益接近 0——因为 header 占总流量比例极小。HTTP/2 在大文件场景的收益主要来自”流控允许 server 推流”和”多路复用避免队头阻塞”——而不是 HPACK。HTTP/2 的优势是分场景的、不是一刀切——理解这点能帮你回答”我的服务该用 HTTP/1 还是 HTTP/2”的工程决策。

15.11 与 HTTP/3 QPACK 的关系

HPACK 有一个已知问题:动态表更新和帧送达严格串行——如果 stream N 的 HEADERS 更新了表项、stream N+1 的 HEADERS 引用这个表项,N+1 必须等 N 解码完才能动。这在 TCP 有序投递上没问题,但到了 QUIC(可以乱序投递不同 stream)就变成头部块阻塞:一个 stream 丢包会把其他引用同一 HEADERS 的 stream 全部拖住。

QPACK(HTTP/3 用的压缩算法)的解决思路是把动态表更新和引用分离

  • 一条单独的 “encoder stream” 专门送动态表更新;
  • 每个 HEADERS 帧用 “Required Insert Count” 字段声明”我依赖的表项至少要更新到第 N 项”;
  • 如果表项已到位、立刻解码;否则挂起该 stream 的解码直到 encoder stream 送达第 N 项。

这让不依赖最新表项的 stream 不受阻——保留了压缩收益、消除了队头阻塞。

QPACK 的静态表是 99 条(HPACK 是 61 条)、动态表机制保留、整数/字符串编码格式和 HPACK 完全一致——所以读懂 HPACK 再读 QPACK 大约只需一两天。