Hyper 与 Tower:工业级 HTTP 栈
第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(RFC 9113 仍保留,但主流浏览器已移除支持) |
| 优先级 | 无 | PRIORITY 帧(已 deprecated) |
这么大差别,如果让 hyper 把这两套用同一段代码实现——复杂度会爆炸。事实是:hyper 不自己实现 HTTP/2。它依赖一个独立的 crate——h2,最初由 Carl Lerche 编写、现在的维护者也是 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 章讲 PING / GOAWAY。
15.2 h2 crate 版本
hyper 1.9.0 的 Cargo.toml 依赖 h2 = 0.4.6(git tag v0.4.6,2024 年 8 月 19 日);本章引用的 HPACK 代码按专栏 index 的说明以 h2 0.3.27 核对行号——被引用的 src/hpack/decoder.rs、src/hpack/huffman/mod.rs 在 0.3.27 与 0.4.7 之间逐字节相同,encoder.rs 只有测试代码里一行调用方式不同,header.rs、table.rs 各有一行签名或 use 的差别,行号都不变。h2 比 hyper 年轻——hyper 的首次提交在 2014 年 8 月,h2 在 2017 年 3 月由 Carl Lerche(同时也是 Tokio 作者)启动,后来 Sean McArthur 接手维护。h2 的代码质量在 Rust 生态里堪称典范——它实现了 RFC 7540 (HTTP/2) + RFC 7541 (HPACK)(README 的原话是 “Implements the full HTTP/2 specification”),CI 里用 h2spec 合规测试套件跑回归(仓库 ci/h2spec.sh)。
15.3 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.3.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 §5.3.2 已把 RFC 7540 的这套优先级信号标为 deprecated,现代实现基本不用。
PUSH_PROMISE:Server Push。主流浏览器已陆续移除对它的支持。新部署基本不考虑。
15.3.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 暴露其中大部分(服务端 Builder 没有 header_table_size,这个只在客户端的 client::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 先记进 hyper 的 Config,建连时再统一转成 h2::server::Builder 上的对应调用(hyper-1.9.0/src/proto/h2/server.rs:131-148);keep_alive_interval、timer、auto_date_header 这类则是 hyper 自己的逻辑,不经过 h2 的 Builder。完整参数含义第 16 章展开。
15.4 HPACK:为什么 header 要压缩
HTTP/1 的 header 是 ASCII 文本——GET / HTTP/1.1\r\nHost: example.com\r\nUser-Agent: Mozilla...\r\n\r\n。一个浏览器请求的 header 动辄几百字节,带上 cookie 还会更多。对于大量短请求(AJAX、REST API),header 的字节量可能超过 body。
更糟的是 HTTP/1 keep-alive 下每个请求都重复发一模一样的 header——Host, User-Agent, Accept-Language, Cookie 全部每次一个字节不差地传。这明显可压缩。
HPACK 是 HTTP/2 给这个问题的解决方案,由 RFC 7541 定义。核心想法:维护一个双方同步的 "headers 字典",header 的传输变成"字典索引"。
15.4.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 |
| ... | ... | ... |
| 28 | content-length |
|
| 32 | cookie |
|
| 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"。十几个字节的明文压成 1 字节。
动态表:连接期间双方各自维护的"最近见过的 header"缓存。容量由 SETTINGS_HEADER_TABLE_SIZE 决定(默认 4KB)。编码方:
- 已经在静态或动态表里:发 index。
- name 在表里,value 是新的:发 (index, new_value) + "要不要加入动态表"。
- 全新的 name + value:发 (literal_name, literal_value) + "要不要加入动态表"。
动态表按 FIFO 清理——容量不够时从最老的条目开始驱逐(RFC 7541 §4.4)。相同的 header 反复出现 → 第一次传字面值,第二次起传 index,极其紧凑。
15.4.2 Huffman 编码
除了索引化,HPACK 对字面量字符串(name / value 字节)还用 静态 Huffman 编码——RFC 7541 Appendix B 提供一张固定的 Huffman 表,把常见 ASCII 字符用短 bit 串表示。
对英文/ASCII 内容(绝大多数 HTTP header),Huffman 能在索引化之外再压掉一部分字节。动态表命中 + Huffman 的双重效果让长连接上重复 header 的开销相对 HTTP/1 大幅下降(§15.8 有一组实测)。
15.4.3 HPACK 的安全陷阱
动态表有一个历史名坑:CRIME / BREACH 攻击的 HTTP/2 变种。如果 Cookie 或 Authorization 这样的敏感 header 被加入动态表——攻击者可以通过选择性注入不同请求、观察加密后的长度变化来猜测 cookie 值。
解决方案:第 9 章提到过的 HeaderValue::is_sensitive flag。标记为 sensitive 的 header 在 HPACK 编码时用 "Never Indexed"标志——强制每次传字面值,不加入动态表(字面值本身在 h2 里仍然走 Huffman 编码,见 h2-0.3.27/src/hpack/encoder.rs:205-224)。
h2 crate 通过 http crate 的这个 flag 自动处理——你用 http::HeaderValue API 的 set_sensitive(true),h2 就会在线路上用正确的 HPACK 标志发。这是 http / h2 两个 crate 跨层协作的典型例子。
另外,即使不标 sensitive,h2 的编码器也从不把 :path、cookie、set-cookie、authorization、content-length、etag、location、age、if-modified-since、if-none-match 这几类 header 的值写进动态表(h2-0.3.27/src/hpack/table.rs:138-145 调用 h2-0.3.27/src/hpack/header.rs:189-208 的 skip_value_index,源码注释说这条规则借自 nghttp2)——它们每次都以"按名索引 + 字面值"发出。
15.5 hyper 的 proto/h2:薄适配
hyper-1.9.0/src/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.5.1 PipeToSendStream:Body → h2 Stream
// hyper-1.9.0/src/proto/h2/mod.rs:84-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 不停地:
- 先向 h2 reserve capacity(
reserve_capacity(1),流控预留);capacity 为 0 就在poll_capacity上等,不为 0 时顺带检查对端有没有发来 RST_STREAM。 - 从 user
Bodypoll 下一个 frame。 - 如果是 DATA:直接
body_tx.send_data(chunk, is_eos)交给 h2——整块数据怎么按流控窗口分批发出,由 h2 负责。 - 如果是 Trailers:先
reserve_capacity(0)归还预留,再body_tx.send_trailers(map)发出。 - 如果 body 结束(None):发一个空的 DATA with END_STREAM flag。
整个逻辑在 poll 方法里循环(源码 121-194)。最关键的部分是 reserve_capacity / poll_capacity 的流控循环:
// mod.rs:127-145 简化
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——源码注释的解释是:此时还没拿到下一块数据,只预留 1 字节确保有容量可用,真正数据块的容量管理交给 h2——然后等 capacity 到位。这个 poll_capacity 返回的数字反映stream 和 connection 两级流控窗口的最小值——第 16 章专讲。
这种循环处理是为了应对 "capacity = 0" 的情况——流控窗口瞬间为 0 是 HTTP/2 很常见的状态(发送方用光了窗口、对端的 WINDOW_UPDATE 还没到)。hyper 循环等待直到真有 capacity。
15.5.2 strip_connection_headers
HTTP/2 把很多 HTTP/1 的"连接级"语义直接内置到协议里——不再需要在 header 里表达。表达了反而违法。
// hyper-1.9.0/src/proto/h2/mod.rs:36-41, 43-80
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.5.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 里。gRPC 的协议规范(grpc/grpc 仓库 doc/PROTOCOL-HTTP2.md)要求请求带 te: trailers,用途是识别不兼容的代理;hyper 要是把它也剥掉,gRPC 请求就不合规范了。
15.5.4 源码核对:hpack::Decoder 的 5 种 Representation 与 SizeUpdate
§15.4 介绍了 HPACK 的工作原理。打开 h2 的 src/hpack/decoder.rs:47-136 能看到 Decoder 内部把每个字节都按 5 种 Representation 之一识别——这是 HPACK 二进制流的全部状态空间:
// 简化:源码里每个变体的 doc comment 是 RFC 7541 的位格式图,这里只留首字节模式
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.4.3 讲过)0000xxxx→ LiteralWithoutIndexing(一次性、不进表)
这 5 个分类覆盖所有 HPACK 操作。注释里 RFC 7541 的二进制格式图直接画出来——源码本身就是规范的二次注释——读 h2 源码不需要反复翻 RFC。
特别注意 LiteralNeverIndexed 的存在——它对应 §15.4.3 讲过的敏感 header(Cookie、Authorization)。HeaderValue::set_sensitive(true) 最终编码就走这条路径——固定字面量 + "Never Indexed" 标志。HPACK 协议层把"安全"和"压缩"做成了二选一的位标记——不用单独走另一条协议、就在编码时多设一位。这种设计的高效让安全和性能能共存。
15.5.5 源码核对:Decoder 的 4KB 初始 buffer 与 max_size_update 的二次协商
Decoder::new(size)(h2-0.3.27/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——Huffman 字符串解码时把结果写进这个 buffer(见 §15.9.1),避免每次重新分配。源码没有说明 4096 的来由;不够用时 huffman::decode 里的 reserve 会扩容。
2、max_size_update: Option<usize> + last_max_update: usize 双字段——这是 HPACK 协议两阶段协商的实现:
- 本端通过 SETTINGS_HEADER_TABLE_SIZE 通知对端"动态表最多允许 X 大"——
queue_size_update(X)把 X 存进max_size_update(注意:这个方法虽然标着#[allow(dead_code)],h2 其实调用了它——本端 SETTINGS 收到对端 ACK 后,h2-0.3.27/src/proto/settings.rs:62-63经Codec::set_recv_header_table_size(h2-0.3.27/src/codec/mod.rs:99-101)、FramedRead::set_header_table_size(h2-0.3.27/src/codec/framed_read.rs:105-107)走到这里) - 对端收到 SETTINGS 不会立刻改表大小、要等下一次 HEADERS 块开头用一个 SizeUpdate Representation 显式声明——
decode开头先max_size_update.take()更新last_max_update这个上界,process_size_update再检查声明值不超过它、然后真正 resize 表
为什么要两阶段?因为 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——decoder 只关心"对端最多能把表调到多大"这个上界。RFC 7541 §4.2 规定 decoder 必须接受任何不超过 SETTINGS 值的 SizeUpdate——h2 用 max 记录这个上界、在 process_size_update 里用它做检查。
15.5.6 源码核对:Decoder.decode 的 can_resize 状态机
Decoder::decode(h2-0.3.27/src/hpack/decoder.rs:176-251)的循环里有个微妙状态:
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 会让动态表在解码途中改变大小、已经解出的索引和后面的索引对不上同一张表。这个看似无关紧要的状态机守的是 RFC 7541 第 4.2 节的规定,h2 严格遵守了它:用 h2 0.4.7 实测,解码 82 20 86 84(SizeUpdate 夹在 :method 之后)时连接直接以 PROTOCOL_ERROR 结束,把同样的字节换成 20 82 86 84(SizeUpdate 在最前)则正常解码。
15.6 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.6.1 h2 的依赖
h2 本身的依赖非常精简:
tokio(只开io-util,要 AsyncRead/Write)+tokio-util(codec / io)byteshttp(0.3.x 依赖 http 0.2,0.4.x 换成 http 1.x——这也是 hyper 1.x 必须用 h2 0.4 的原因)futures-core/futures-sink(0.3.x 还带futures-util,0.4.x 去掉了它、换成atomic-waker)tracingfnv(HPACK 表用的 hasher)、slab+indexmap(stream 存储:slab放 stream 本体、indexmap做StreamId → SlabIndex的有序索引,见 h2 的src/proto/streams/store.rs:11-15)
没有依赖 hyper——这是 h2 独立的关键。其他 HTTP/2 库(如 tonic)也能直接用 h2 而不经过 hyper。
15.6.2 源码核对:Encoder 的 SizeUpdate 双值结构与 update_max_size 状态机
§15.5.5 讲过 Decoder 端的 size update。Encoder 端有个对应但更复杂的状态——SizeUpdate 是个双值枚举(h2-0.3.27/src/hpack/encoder.rs:13-17):
enum SizeUpdate {
One(usize),
Two(usize, usize), // min, max
}
为什么要 Two 变体?因为 RFC 7541 §4.2 规定:如果两个 header 块之间表大小先缩小、再放大,编码方必须先通告最小值、再通告最终值——第一个 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 7541 §4.2 的几种组合:
None状态 + 新值等于当前表大小 → 不必发(省一条 SizeUpdate)- 已有 One(old) + 新 val 更大且 old 比当前更大 → 只发一条(避免多余的 Two)
- 已有 One(old) + 新 val 更大但 old 比当前小 → 升级到 Two(old, val) 表示"从老到新走一遍"
- 已有 Two(min, _) + 新 val 更小到 min 以下 → 退回 One
这个状态机看起来繁琐——但每条分支对应一个具体的 RFC 行为约束。协议库的精确性就体现在这种 corner case 处理——一个 if 判断错了就违反 spec、对端会断连。
15.6.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<Header, HeaderValue>——Err(value) 意味着这个 header 没有 name 字段、要复用上一个 header 的 name。这种输入来自 http 的 HeaderMap 迭代:同名多值时只有第一个值带 name(h2-0.3.27/src/frame/headers.rs 的 Iter 把 (Option<HeaderName>, HeaderValue) 原样转成 Header::Field)。这对应 HTTP 里多值 header的场景:
Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: must-revalidate
三个 Cache-Control 只查一次表——第一个值查表后以 InsertedValue 进动态表;后两个用 last_index 直接拿到这个 name 的索引,各发一条"按名索引 + 字面值"(不进动态表)。用 h2 0.4.7 实测,这三个值编成 58 86 …、0f 2f 86 …、0f 2f 8b …——58 是带索引插入、name 取静态表 24,0f 2f 是按 4 位前缀编码的动态表索引 62。name 在线路上每次仍以索引出现,省下的是查表。
没有 name 的 header 本来就没法拿去查表(table.index 按 name 做哈希),复用 last_index 既解决了 name 从哪来的问题,也省掉了后续值的哈希与比较。
这是协议设计层和实现层共同发力的例子——HTTP 层允许 multi-value header、HPACK 层支持"按索引引用 name 的字面量"、Encoder 实现层用 last_index 缓存——三层配合让"同 name 多 value"只查一次表。
15.7 跨语言对照
来看 HTTP/2 实现在其他生态里的分层:
Go net/http:HTTP/2 支持内嵌在标准库(golang.org/x/net/http2 → internal/http2)——单一实现,不能替换。
nghttp2 (C):HTTP/2 的 C 参考实现,curl 等项目的 HTTP/2 就建在它上面(nginx 的 HTTP/2 是自己的 ngx_http_v2 模块,不用 nghttp2)。h2 的 HPACK 编码器里"哪些 header 不进动态表"的规则,源码注释就写明借自 nghttp2(h2-0.3.27/src/hpack/table.rs:138)。
Rust h2:独立 crate,多个用户(hyper、tonic、以及各种直接用 h2 的自研 server)共享。
Rust 的"独立协议 crate"模式不是巧合——它与《MCP 协议设计与实现》专栏第 21 章讨论过的"协议逻辑应该与通信方式解耦"是同一种工程哲学。MCP 的 TypeScript SDK / Python SDK 把"协议 + 传输"和"server/client 使用模式"分开;Rust 的 h2 / http / http-body 也是这种模式。把标准部分做成独立 crate,让应用层有选择——是一种重要的 "生态层次" 意识。
15.7.1 源码核对:get_static 的 61 个 match arm——RFC 7541 Appendix A 的字面化
§15.4.1 提到了 HPACK 静态表的 61 个条目。打开 h2 的 src/hpack/decoder.rs:619-827,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 通常会被 LLVM 编成跳表(jump table)——也是 O(1)。源码没有说明为什么选 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。RFC 7541 附录 A 说静态表取自当时热门网站上最常出现的 header;如今浏览器的 accept-encoding 多半还带着 br 等编码,这一条整体命中的机会已经不多。
4、BytesStr::from_static 用于 string 类型字段——from_static 返回的 BytesStr 不持有内存所有权、指向编译期常量字符串。61 个静态表条目编译进二进制后零运行时分配。每次 hpack decode 一个 indexed header 都是零成本拷贝出 BytesStr。
这两百行 match(decoder.rs:619-827)看起来"无聊"——实际上是性能 + 类型安全 + 规范精确三位一体的工程产物。每一行都对应 RFC 7541 Appendix A 的一行——读 h2 源码可以不翻 RFC、读这段就是。
15.7.2 源码核对:Huffman decode 的 4-bit 状态机表查询法
§15.4.2 提到 HPACK 用静态 Huffman 编码。打开 h2 的 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 code 是 5 bit、解码出 8 bit 字符,所以解码后最多膨胀到输入的 8/5 = 1.6 倍(源码注释 "Max compression ratio is >= 0.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 是可行的——多次状态转移能拼出任意长度的码。这是常见的 Huffman 查表实现策略——不需要做复杂的 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 把最后一个字节里剩下的 1–7 个空位全部填 1——正好是 EOS 码的高 N 位。这种"用全 1 填充 byte 边界"的设计让 decoder 能识别"这后面没有真实数据、只是对齐"——避免错误尝试解码末尾的 padding bit。
这条机制对应上面第 4 条的 "is_final" 检查——RFC 7541 §5.2 规定填充超过 7 bit、或者填充不是 EOS 码的高位(不全是 1),都必须当作解码错误。用 h2 0.4.7 实测:user-agent 值为 3f('o' 加 3 位全 1 填充)正常解码,3f ff(11 位填充)和 38(填充是 000)都以 PROTOCOL_ERROR 断连。顺带一提:RFC 9113 §4.3 要求 header 块解码失败时以 COMPRESSION_ERROR 结束连接,h2 0.4.7 实测回的是 PROTOCOL_ERROR,与规范的错误码并不一致(连接同样会被关闭)。
整个 huffman/mod.rs 约 200 行(含测试)+ table.rs 自动生成的五千多行 lookup table——用空间(lookup table)换时间(O(N) 解码)——是 high-performance 编码器的经典套路。
15.7.3 源码核对:decode_int 的 prefix-encoded varint——HPACK 整数表示的精髓
HPACK 里所有整数都用一种独特的 "prefix-encoded varint" 表示——h2 的 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);
}
if !buf.has_remaining() {
return Err(DecoderError::NeedMore(NeedMore::IntegerUnderflow));
}
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(即首字节 & 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——源码注释说,这个上限保证解出的值永远不会超出 u32,恶意流没法用超长整数制造溢出。任何处理用户输入的 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)。
15.8 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 编码(请求行 + Host + 上面 5 个普通 header)是 344 字节。下面的 HPACK 字节数是用 h2 0.4.7 的编码器实测的(开 unstable feature,经 h2::Codec 发 HEADERS 帧,取帧 payload;h2 按 :method、:scheme、:authority、:path 的顺序写伪头)。
HTTP/2 首次 HPACK 编码:
:method: GET→ 1 字节(静态表索引 2):scheme: https→ 1 字节(静态表索引 7):authority: example.com→ 10 字节(静态表 name 索引 1 + Huffman 值,写进动态表):path: /api/v1/users/42→ 14 字节(静态表 name 索引 4 + Huffman 值;h2 从不把:path写进动态表)user-agent: Mozilla...→ 55 字节(静态表 name 索引 58 + Huffman 值,写进动态表)- 其他 header 类似
首次合计 203 字节,约 HTTP/1 的 59%。
同一连接上第二次请求(只有 path 不同):
:method: GET、:scheme: https→ 各 1 字节:authority: example.com→ 动态表命中,1 字节:path: /api/v1/users/43→ 14 字节(仍是字面值)user-agent、accept、accept-language、accept-encoding→ 动态表命中,各 1 字节cookie: ...→ 28 字节(h2 不把 cookie 写进动态表,每次重发)
合计 49 字节,约 HTTP/1 的 14%。
这就是"长连接 + 重复 header"场景下 HTTP/2 相对 HTTP/1 的头部压缩优势。对 API 网关、微服务间通信(同一个 user-agent、同一套 trace header),HPACK 是显著降低带宽的工程手段;只是在 h2 里,authorization、cookie 这类 header 的值不进动态表(§15.4.3),每次都要重发字面值。
15.9 何时用 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 交换、流控状态)——每连接有固定成本。 |
| 终端用户的 Web 服务 | HTTP/2 on edge、HTTP/1 to origin 是主流部署(CDN 这么干)。 |
15.9.1 源码核对:try_decode_string 的 Huffman flag 与 marker 模式
Decoder::try_decode_string(h2-0.3.27/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.7.3 的 prefix-encoded varint)。一个 byte 同时携带 type marker + 长度高位——HPACK 的字节级紧凑设计的又一例。
2、Huffman 编码的字符串需要立刻 decode——因为 huffman::decode 是 streaming 的、必须从头扫一遍才能知道结果长度。代码里 huffman::decode(raw, &mut self.buffer) 把解码结果写进 self.buffer(§15.5.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.9.2 源码核对:encode_header 的 5 种 Index——和 5 种 Representation 的对应
Encoder.encode_header(h2-0.3.27/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% 命中 → 只发索引
encode_int(idx, 7, 0x80, dst);
}
Index::Name(idx, _) => {
// name 在表里、value 不进表 → literal value(sensitive 时 never indexed)
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.5.4 的 Representation 上是这样的(SizeUpdate 不走 Index,由 encode_size_updates 单独写):
| Encoder Index | Representation | 字节代价 | 触发场景 |
|---|---|---|---|
| Indexed | Indexed | 1-3 字节 | 全命中 |
| Name | LiteralWithoutIndexing(sensitive 时 LiteralNeverIndexed) | name idx + value | name 在表、value 不进表:skip_value_index 那几类(:path、cookie、authorization 等)、超过动态表 3/4 的大 header、sensitive |
| Inserted | LiteralWithIndexing | name + value + 进表 | name 不在任何表里的新 header |
| InsertedValue | LiteralWithIndexing | name idx + value + 进表 | name 在表、value 新、要进表 |
| NotIndexed | LiteralWithoutIndexing(sensitive 时 LiteralNeverIndexed) | name + value | name 不在表、又不能进表:sensitive 或超过动态表 3/4 的大 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 编码器的核心分发——上层只决策"如何使用动态表"(5 种 Index),编码器自动按规范产出对应的 binary representation。
15.9.3 源码核对:encode_size_update 的 prefix=5——SizeUpdate Representation 的字节边界
encode_size_update(h2-0.3.27/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.5.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.10 本章源码定位索引
为便于读者按图索骥:
| 主题 | 源文件 | 关键行号/位置 |
|---|---|---|
| Decoder 5 种 Representation | h2-0.3.27/src/hpack/decoder.rs |
47-136 |
| Decoder::new + 4KB buffer | 同上 | 155-162 |
| max_size_update 双字段 | 同上 | 17-18 |
| queue_size_update | 同上 | 165-173 |
| decode 主循环 + can_resize | 同上 | 176-251 |
| decode_int 的 prefix-varint | 同上 | 391-448 |
| get_static 静态表 61 条 | 同上 | 619-827 |
| Encoder 主体 | h2-0.3.27/src/hpack/encoder.rs |
8-183(struct Encoder + impl Encoder) |
| SizeUpdate 双值枚举 | 同上 | 13-17 |
| update_max_size 状态机 | 同上 | 30-58 |
| encode 的 last_index 优化 | 同上 | 60-97 |
| skip_value_index(值不进动态表的 header) | h2-0.3.27/src/hpack/header.rs |
189-208 |
| Huffman decode | h2-0.3.27/src/hpack/huffman/mod.rs |
20-41 |
| Huffman encode | 同上 | 43-66 |
| hyper PipeToSendStream | hyper-1.9.0/src/proto/h2/mod.rs |
84-94 + 121-194 |
| strip_connection_headers | 同上 | 36-41 + 43-80 |
源码版本:h2 0.3.27(与 hyper 1.9.0 实际依赖的 0.4.x 在这几处行号一致,其中 decoder.rs、huffman/mod.rs 逐字节相同,见 index)+ hyper 1.9.0。
15.11 本章与全专栏体系的呼应
本章讲 HPACK + h2 接口,和其他各章的接合点:
与第 9 章(http crate 的 HeaderValue)的接续:第 9 章讲过 HeaderValue::set_sensitive(true)——这一章揭示了它在 HPACK 编码层的真实效果(走 LiteralNeverIndexed 路径、强制每次传字面值)。两章合起来构成"用户层标记 → 协议层行为"的完整故事。
与第 10 章(http-body 与 trailers)的关键依赖:本章 §15.5.3 的 TE: trailers 特例——是 gRPC 能在 HTTP/2 上跑的前提条件。第 10 章讲 gRPC 把 status code 放在 trailers 里——本章讲 hyper 剥连接级 header 时为什么唯独放过 TE: trailers。
与第 11 章(HTTP/1 解析)的对比:HTTP/1 的解析靠 httparse(手写状态机);HTTP/2 的解析靠 h2 crate(独立的 codec 库)。两者都把"协议解析"独立出 hyper——但分别为不同协议复杂度做了不同程度的抽象。HTTP/1 一个 crate 就够、HTTP/2 需要 frame + connection + hpack 三层。
与第 16 章(HTTP/2 流控)的铺垫:本章 §15.5.1 的 PipeToSendStream 用 reserve_capacity + poll_capacity 等待流控——下一章会展开"为什么需要流控、流控窗口怎么计算、WINDOW_UPDATE 帧的发送策略"——本章只展示消费者端怎么用,下一章展示完整机制。
与第 17 章(连接保活和优雅关闭)的呼应:本章列了 GOAWAY 和 PING 帧的存在——第 17 章会真正展开它们的工作机制。本章只是列出了 HTTP/2 的工具集,后两章是用这些工具解决具体工程问题。
读完本章 + 下两章你会发现 HTTP/2 不是一个"简单替换 HTTP/1"的协议——它是一个带有完整流控、连接管理的并发协议。掌握它需要的不只是协议规范、还要理解它在 hyper 这种生产级实现里如何被工程化。
15.12 落到你键盘上
本章给的工具和去处:
- 读
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.13 给 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.5.5 的 queue_size_update——多次 SETTINGS 更新被 reduce 成 max。这种"幂等聚合"让协议参数变更不会因为 batch 内顺序而失效。
4、prefix-encoded varint 是值范围分布严重不均时的常用编码。短值快、长值能装。除了 HPACK,Protocol Buffers 的 varint、CBOR 的头字节也是类似设计。
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.14 读完本章能回答的具体问题清单
作为本章掌握度自测:
- HPACK 的 5 种 Representation 是什么?怎么用 1 字节区分?(§15.5.4——top bit pattern dispatch)
- HPACK 的 SizeUpdate 必须在 HEADERS 块的哪里?为什么?(§15.5.6——必须在块的最开头,RFC 7541 §4.2 的规定)
- HeaderValue::set_sensitive(true) 在线路上是什么效果?(§15.5.4 + §15.4.3——LiteralNeverIndexed、不进表、每次传字面值)
- HPACK 静态表为什么 accept-encoding 索引值是 "gzip, deflate" 不是空?(§15.7.1——RFC 7541 给的"统计经验默认值",让最常见请求 1 字节搞定)
- HPACK 的整数表示为什么用 prefix-encoded varint?(§15.7.3——前缀位与 type marker 共字节、值 < 127 只占 1 字节)
- MAX_BYTES = 5 是什么作用?(§15.7.3——DoS 防护、防恶意流读无限字节)
- Huffman decode 的 4-bit nibble 拆分是什么策略?(§15.7.2——状态机 + 256×16 lookup table、O(N) 解码)
- Encoder 的 SizeUpdate 为什么要 Two(min, max) 双值?(§15.6.2——RFC 7541 §4.2 要求通告区间内的最小值和最终值)
- 多个 Cache-Control header 在 HPACK 里是什么效果?(§15.6.3——只查一次表,后续值用 last_index 复用 name 索引)
- HTTP/2 strip 的 4 个连接级 header 是什么?为什么?(§15.5.2——Keep-Alive/Proxy-Connection/Transfer-Encoding/Upgrade、外加
Connection及它点名的 header、HTTP/2 把这些语义内置了) - gRPC 为什么需要 TE: trailers 例外?(§15.5.3——gRPC 把 status code 放在 trailers)
- PipeToSendStream 为什么要 reserve_capacity + poll_capacity 循环?(§15.5.1——HTTP/2 流控窗口可能瞬间为 0、循环等待)
15.15 HPACK 的安全研究历史——CRIME / BREACH 之外
§15.4.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,CVE-2016-6581):攻击者先插入一个与动态表容量一样大的条目,再发一个只由"引用这个条目"的 1 字节索引反复组成的 header 块——16KB 的块能在对端解出约 64MB(python-hyper hpack 的安全公告)。防御手段是限制解压后的 header 列表总大小:h2 的 SETTINGS_MAX_HEADER_LIST_SIZE 就在 h2-0.3.27/src/codec/framed_read.rs 解码 HEADERS / CONTINUATION 时检查。
这条历史告诉我们一件事——协议安全是个持续投入的领域、不是一次性达成。h2 / nghttp2 / curl 等成熟实现至今仍在持续修边界 case——自己从头写一个 HPACK 实现很难不留漏洞。用成熟 crate 而不是自己实现——这是协议层的金科玉律。
15.16 HPACK 给非 HTTP 协议的启示
HPACK 的设计思路不只用于 HTTP——任何"短消息频繁交换、消息内容高度重复"的协议都能借鉴:
MQTT 5.0 的 "Topic Alias" 功能——和 HPACK 动态表是一个思路。发布过的 topic 可以用一个 alias number 引用、避免重复传 topic 字符串。
TLS 1.3 的 0-RTT 利用之前 session 的 PSK——和 HPACK 用动态表"利用之前 connection 的状态"是同源思路。两者都是 "stateful protocol upgrade"——协议层维持状态以减少冗余传输。
QUIC 的 QPACK(HTTP/3 的头部压缩)——直接是 HPACK 的演化版。QPACK 解决了 HPACK 的"head-of-line blocking"问题(HPACK 必须按顺序解码、QPACK 让流可以乱序解码)——但核心的"静态表 + 动态表 + 索引"思路完全继承。
如果你将来设计一个新的网络协议——任何短消息高频场景下 认真考虑动态字典 + 索引这个 pattern。HPACK 用一份不长的 RFC 把这个 pattern 标准化、换来的是重复 header 场景下数量级的字节节省——这是协议设计能给的最高回报。
15.17 HPACK 收益的场景差异
把本章讲的所有源码细节合起来——按 §15.8 的估算方法看 HPACK 在不同场景下的收益差别:
场景 1:单次 GET 请求(无 cookie)——只有静态表和 Huffman 能起作用。pseudo-header 全部命中静态表、常见 header name 命中 name 索引、value 走 Huffman 字面量。相对 HTTP/1 明文能省下一部分字节、但不是数量级的差别。
场景 2:API 客户端在同一连接上连续请求相同 endpoint——第一次之后 :authority、user-agent、accept-* 这些 header 全部命中动态表、每个只剩 1-2 字节的索引;只有 :path 这类每次变化的值还要传字面量(在 h2 里,authorization、cookie 的值也不进动态表,每次都重发,见 §15.4.3)。这是 HPACK 收益最大的场景——header 字节数相对 HTTP/1 keep-alive 是数量级的下降。
场景 3:浏览器加载一个页面(几十上百个资源、共享 user-agent + cookie)——和场景 2 同理、动态表越大命中越多;SETTINGS_HEADER_TABLE_SIZE 从默认 4 KB 往上调能进一步提高命中率、代价是每条连接多占内存。
这就是为什么 CDN 与 API 网关普遍采用 HTTP/2——HPACK 在"高频小请求"场景下的收益是数量级的。
但也注意:单次大文件下载的 HPACK 收益接近 0——因为 header 占总流量比例极小。HTTP/2 在大文件场景的收益主要来自"流控允许 server 推流"和"多路复用避免队头阻塞"——而不是 HPACK。HTTP/2 的优势是分场景的、不是一刀切——理解这点能帮你回答"我的服务该用 HTTP/1 还是 HTTP/2"的工程决策。
15.18 与 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 大约只需一两天。