Appearance
第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)。
toml
# 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 暴露这些:
rust
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
rust
// 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 的流控循环:
rust
// 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 里表达。表达了反而违法。
rust
// 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 的特例
rust
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.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.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.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-modeHTTP/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.9 落到你键盘上
本章给的工具和去处:
- 读
h2crate 的src/proto/connection.rs——它是 HTTP/2 连接状态机的核心。比 hyper HTTP/1 的 conn.rs 更复杂,但分层清晰。 - 用
nghttp工具观察实际 HTTP/2 流量:bash会打印所有 SETTINGS / HEADERS / DATA / WINDOW_UPDATE 帧——这是理解协议最直观的方式。nghttp -nv https://example.com/ - 读 RFC 7541 的 Appendix A——静态表 61 条。把它扫一遍,你下次看 HPACK 压缩数据时会直接认出各种索引。
下一章我们进入 HTTP/2 最难但最有收获的部分——多流调度与流控。这里是所有"HTTP/2 怎么配置"问题的归宿。