Skip to content

第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)。

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 标准规定。举例:

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

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 不停地:

  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 的流控循环

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)

四层:

  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.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.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.9 落到你键盘上

本章给的工具和去处:

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

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

基于 VitePress 构建