Hyper 与 Tower:工业级 HTTP 栈
第15章 h2 crate 与 HPACK:HTTP/2 的线路层
第15章 h2 crate 与 HPACK:HTTP/2 的线路层
15.1 HTTP/2 和 HTTP/1 几乎是两个协议
上一章我们收尾 HTTP/1 部分。现在要翻到 HTTP/2。第一件要说清楚的事是——HTTP/2 和 HTTP/1 在线路层几乎是两个完全不同的协议。
| 维度 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 格式 | ASCII 文本 | 二进制帧 |
| 并发 | 一连接一请求(pipelining 实际弃用) | 一连接多 stream(100+ 并发) |
| header | Name: Value 明文 | HPACK 压缩 |
| body | Content-Length 或 chunked | DATA 帧 |
| 流控 | 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 标准规定。举例:
| Index | Header | Value |
|---|---|---|
| 1 | :authority | |
| 2 | :method | GET |
| 3 | :method | POST |
| 4 | :path | / |
| 5 | :path | /index.html |
| 7 | :scheme | https |
| 8 | :status | 200 |
| … | … | … |
| 32 | cookie | |
| 35 | content-length | |
| 58 | user-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)。编码方:
- 已经在静态或动态表里:发 index。
- name 在表里,value 是新的:发 (index, new_value) + “要不要加入动态表”。
- 全新的 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 的入口。核心是三件事:
PipeToSendStream<S>:把用户的Bodypipe 到 h2 的SendStream。strip_connection_headers:HTTP/2 禁止的 HTTP/1 连接级 header 要剥掉。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 不停地:
- 从 user
Bodypoll 下一个 frame。 - 如果是 DATA:先向 h2 reserve capacity(流控预留),capacity 够了再
body_tx.send_data(chunk, is_eos)发出。 - 如果是 Trailers:
body_tx.send_trailers(map)发出。 - 如果 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)
四层:
- 用户 API 层:暴露
SendStream/RecvStream等。 - Connection 层:状态机、多路复用调度、流控。
- Frame 层:每种 frame 类型的 serde。
- HPACK 层:Headers 的压缩/解压。
这种分层让每一层可以独立测试——h2 的测试套件对每一层都有独立覆盖。对比 hyper 的 proto/h1,因为协议比较简单,层次没那么清晰。
15.5.1 h2 的依赖
h2 本身的依赖非常精简:
tokio(需要 AsyncRead/Write)byteshttpfutures-*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/http2 → internal/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 >> 4 和 b & 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-448 的 decode_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: GET→ 1 字节(静态表索引 2):scheme: https→ 1 字节(静态表索引 7):path: /api/v1/users/42→ index for:path+ literal Huffman path ≈ 17 字节:authority: example.com→ literal ≈ 12 字节user-agent: Mozilla...→:nameindex + Huffman value ≈ 68 字节- 其他 header 类似
首次合计 ≈ 180 字节,约 HTTP/1 的 45%。
同一连接上第二次相同请求(不同 path):
:method: GET→ 1 字节:scheme: https→ 1 字节:path: /api/v1/users/43→ literal ≈ 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 Index | Representation | 字节代价 | 触发场景 |
|---|---|---|---|
| Indexed | Indexed | 1-3 字节 | 全命中 |
| Name | LiteralWithoutIndexing | name idx + value | name 在表、value 新 |
| Inserted | LiteralWithIndexing | name + value + 进表 | 全新 header |
| InsertedValue | LiteralWithIndexing | name idx + value + 进表 | name 在表、value 新、要进表 |
| NotIndexed | LiteralNeverIndexed | name + 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_indexed 和 encode_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 种 Representation | h2-0.3.27/src/hpack/decoder.rs | 47-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.rs | 8-160+ |
| SizeUpdate 双值枚举 | 同上 | 13-17 |
| update_max_size 状态机 | 同上 | 30-58 |
| encode 的 last_index 优化 | 同上 | 60-97 |
| Huffman decode | h2-0.3.27/src/hpack/huffman/mod.rs | 20-41 |
| Huffman encode | 同上 | 43-66 |
| hyper PipeToSendStream | hyper/src/proto/h2/mod.rs | 85-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 的 PipeToSendStream 用 reserve_capacity + poll_capacity 等待流控——下一章会展开”为什么需要流控、流控窗口怎么计算、WINDOW_UPDATE 帧的发送策略”——本章只展示消费者端怎么用,下一章展示完整机制。
与第 17 章(连接保活和优雅关闭)的呼应:本章列了 GOAWAY 和 PING 帧的存在——下一章和第 17 章会真正展开它们的工作机制。本章只是列出了 HTTP/2 的工具集,后两章是用这些工具解决具体工程问题。
读完本章 + 下两章你会发现 HTTP/2 不是一个”简单替换 HTTP/1”的协议——它是一个带有完整流控、连接管理、加密整合的并发协议。掌握它需要的不只是协议规范、还要理解它在 hyper 这种生产级实现里如何被工程化。
15.9 落到你键盘上
本章给的工具和去处:
- 读
h2crate 的src/proto/connection.rs——它是 HTTP/2 连接状态机的核心。比 hyper HTTP/1 的 conn.rs 更复杂,但分层清晰。 - 用
nghttp工具观察实际 HTTP/2 流量:
会打印所有 SETTINGS / HEADERS / DATA / WINDOW_UPDATE 帧——这是理解协议最直观的方式。nghttp -nv https://example.com/ - 读 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 读完本章能回答的具体问题清单
作为本章掌握度自测:
- HPACK 的 5 种 Representation 是什么?怎么用 1 字节区分?(§15.4.4——top bit pattern dispatch)
- HPACK 的 SizeUpdate 必须在 HEADERS 块的哪里?为什么?(§15.4.6——必须最开头、防中间篡改攻击)
- HeaderValue::set_sensitive(true) 在线路上是什么效果?(§15.4.4 + §15.3.3——LiteralNeverIndexed、不进表、不压缩)
- HPACK 静态表为什么 accept-encoding 索引值是 “gzip, deflate” 不是空?(§15.6.1——RFC 7541 给的”统计经验默认值”,让最常见请求 1 字节搞定)
- HPACK 的整数表示为什么用 prefix-encoded varint?(§15.6.3——前缀位与 type marker 共字节、值 < 127 只占 1 字节)
- MAX_BYTES = 5 是什么作用?(§15.6.3——DoS 防护、防恶意流读无限字节)
- Huffman decode 的 4-bit nibble 拆分是什么策略?(§15.6.2——状态机 + 256×16 lookup table、O(N) 解码)
- Encoder 的 SizeUpdate 为什么要 Two(min, max) 双值?(§15.5.2——RFC 4.2 节强制要求 min/max 都通告)
- 多个 Cache-Control header 在 HPACK 里是什么效果?(§15.5.3——name 只编码一次、用 last_index 复用、巨大压缩)
- HTTP/2 strip 的 4 个连接级 header 是什么?为什么?(§15.4.2——Keep-Alive/Proxy-Connection/TE/Upgrade、HTTP/2 把这些语义内置了)
- gRPC 为什么需要 TE: trailers 例外?(§15.4.3——gRPC 把 status code 放在 trailers)
- 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=axxx、cookie=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 ASCII | 200-300 字节 | 100% |
| HPACK 首次(仅静态表) | 80-120 字节 | 40% |
| HPACK 首次(静态 + Huffman) | 60-90 字节 | 30% |
场景 2:API 客户端连续 100 次相同 endpoint
| 编码 | 单次字节数 | 100 次总字节 | 相对 HTTP/1 |
|---|---|---|---|
| HTTP/1 keep-alive | 250 字节 | 25 KB | 100% |
| HPACK + 动态表(首次后命中) | 5-15 字节 | ~1.2 KB | 5% |
场景 3:浏览器加载一个页面(100 个资源、共享 user-agent + cookie)
| 编码 | 总 header 字节 | 相对 HTTP/1 |
|---|---|---|
| HTTP/1 | ~100 KB | 100% |
| HPACK (动态表 4KB) | ~10 KB | 10% |
| HPACK (动态表 64KB) | ~5 KB | 5% |
这些数字解释了为什么 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 大约只需一两天。