从手写 HTTP/1.1 到拆开 HTTP/2

本文是对 The HTTP crash course nobody asked for 的整理与翻译。

内容结构概览

  1. 文章背景:作者维护一个 HTTP/S/2 proxy,代理位于客户端和后端之间,因此谁都不能完全信任。
  2. HTTP/1.1 入门:在 TCP 连接上发送文本协议,请求行、Header、CRLF、空行、Body。
  3. 用 netcat 手写请求HEAD / HTTP/1.1HostConnection: close,直接和服务器对话。
  4. Host 的意义:HTTP/1.1 要求 Host,用来支持同一 IP 上托管多个站点。
  5. Content-Length:请求或响应 body 需要说明长度,否则对端无法判断 body 是否完整。
  6. 持久连接:HTTP/1.1 默认可在同一 TCP 连接上连续发送多个请求。
  7. chunked transfer encoding:不知道 body 长度时,可以分块发送,每块前面带十六进制长度,最后以 0 长度块结束。
  8. 代理的困难:代理要防无限 header、过多 header、空闲连接、Slowloris、body 传输、压缩、缓存等问题。
  9. 代理必须有立场:在"尽量忠实转发"和"严格规范/安全"之间必须做取舍。
  10. TLS 登场 :HTTP 明文可用 nc 看清;HTTPS 是 HTTP over TLS,需要 openssl s_client 或库来建立 TLS。
  11. reqwest 高层客户端 :用 Rust reqwest 一行发 HTTP/HTTPS 请求,底层默认依赖 TLS 实现。
  12. Wireshark 抓包观察:HTTPS 中只能看到 TLS ClientHello、ServerHello 和加密 Application Data;HTTP 明文则能直接解析头和 body。
  13. hyper 降一层抽象reqwest 用 hyper,直接使用 hyper 能看到 body streaming。
  14. body 不一定一次读完:同一个响应 body 可能一次读完,也可能分多次 buffer 到达。
  15. hyper 默认不处理 HTTPS :要用 hyper-rustls 或 TLS connector 明确接入 TLS。
  16. KeyLogFile 解密 TLS:用 rustls key log file 配合 Wireshark,可以看到 TLS 里的明文 HTTP。
  17. HTTP/2 入门:HTTP/2 仍然跑在 TCP 和 TLS 之上,但引入了 stream multiplexing。
  18. h2 crate :hyper 的很多 HTTP/2 细节在 h2 crate 中,手动使用 h2 可以看到 SendRequestConnection 的分工。
  19. ALPN 必不可少 :TLS 连接上要通过 ALPN 告诉服务器自己想说 h2,否则服务器不会知道。
  20. HTTP/2 多路复用:多个请求可以在一个 TCP 连接上并发,响应 header/body 可能乱序完成。
  21. 手写 HTTP/2 frame:HTTP/2 连接先发 preface,然后发送 SETTINGS、HEADERS、DATA 等 frame。
  22. Frame header 结构:HTTP/2 frame 有长度、类型、flags、reserved bit、stream id、payload。
  23. 强类型建模 frame:不同 frame 类型有不同 flags,用 Rust enum/bitflags 建模能减少混用错误。
  24. HPACK:HTTP/2 的 HEADERS payload 是 HPACK 编码的 Header Block Fragment。
  25. HTTP/2 header 规则 :header 名必须小写,content-length 仍可存在,但必须和 DATA frame 总长度一致。
  26. 流 ID 与 GOAWAY:client initiated stream 使用奇数 ID,stream ID 会耗尽;服务端可用 GOAWAY 指明最后处理的 stream。
  27. 流控是 bug 富矿:SETTINGS_INITIAL_WINDOW_SIZE、WINDOW_UPDATE、负窗口、SETTINGS ack 都可能产生复杂交互。
  28. HTTP/2 的攻击面:SETTINGS/PING/WINDOW_UPDATE 滥用、TCP 与 HTTP/2 双层流控、HPACK 压缩相关攻击。
  29. 作者的最终动机:为了更好观察内部状态,他开始写自己的 H1/H2 Rust 实现,基于 io_uring、rustls/kTLS、固定 buffer pool。

HTTP 是一种非常成功的技术。

成功到什么程度?你每天都在用它,但大多数时候根本不用想它。打开网页、刷文档、看 RSS、下载包、调用 API,很多数据都经过 HTTP 或 HTTPS。对普通用户来说,"不需要想它"就是它的成功标志。

但如果你写的是 HTTP 代理,那就完全不一样了。

普通 HTTP 客户端至少可以相信自己发出去的请求,普通 HTTP 服务端至少可以相信自己生成的响应。可代理不行。代理站在中间,客户端可能乱来,后端可能乱来,网络可能乱来,甚至自己的代码也可能乱来。

作者维护的是一个 HTTP/S/2 proxy。bug 报告通常长这样:

text 复制代码
好像有东西不太对。
我们也不知道为什么。
我们怀疑是代理。

这非常真实。代理夹在所有东西中间,一旦系统出问题,它天然就像嫌疑人。客户端日志没有,后端日志没有,用户只知道"请求失败了",于是代理就成了第一怀疑对象。

这篇文章就是从这里开始。作者说,他在工作中遇到了一个非常棘手的 HTTP/2 bug。但要讲清那个 bug,必须从头开始。于是我们先从 HTTP/1.1 讲起。


一、先忘掉复杂性:HTTP/1.1 其实就是往 TCP 里写文本

为了理解 HTTP/1.1,可以先把互联网想成"一堆管子"。

每个 peer 有一个 IP 地址。客户端可以主动建立到某个服务器的 TCP 连接,服务器可以监听端口并接受连接。连接建立之后,双方得到一个双向 socket:可以从里面读字节,也可以往里面写字节。

TCP 给我们提供有序、可靠传输。底层有校验和、重传、拥塞控制、流控等机制,但这篇文章先不展开。

在这个基础上,HTTP/1.1 看起来很简单:打开 TCP 连接,写一段文本,服务器回一段文本。

比如用 nc,也就是 netcat,直接连到 neverssl.com 的 80 端口,然后手写 HTTP:

bash 复制代码
printf 'HEAD / HTTP/1.1\r\nHost: neverssl.com\r\nConnection: close\r\n\r\n' | nc neverssl.com 80

你发出去的大概是:

http 复制代码
HEAD / HTTP/1.1
Host: neverssl.com
Connection: close

注意每一行实际以 \r\n 结尾,也就是 CRLF:Carriage Return + Line Feed。HTTP 这套东西继承了电传打字机时代的历史包袱,所以换行不是单纯的 \n

请求头和 body 之间用一个空行隔开,也就是最后的 \r\n\r\n

服务器会返回类似:

http 复制代码
HTTP/1.1 200 OK
Date: Tue, 13 Sep 2022 19:10:46 GMT
Server: Apache/2.4.53
Content-Length: 3961
Content-Type: text/html; charset=UTF-8

这里没有 body,因为我们发的是 HEAD 请求。HEADGET 很像,只是告诉服务器:我只要响应头,不要响应体。


二、请求行、Host、Header 和 Body

HTTP/1.1 请求的第一行叫 request line:

http 复制代码
HEAD / HTTP/1.1

它由三部分组成:

text 复制代码
method     HEAD
path       /
version    HTTP/1.1

method 有很多种。最常见的是 GET,意思是"把这个资源给我"。POST 用来提交表单或上传数据。DELETE 用来删除。OPTIONS 常用于 CORS。

第二行:

http 复制代码
Host: neverssl.com

在 HTTP/1.1 里非常重要。Host 让同一个 IP 地址可以托管多个站点。现代世界里,这几乎是默认情况:CDN、反向代理、虚拟主机、应用网关,全都依赖 Host 来决定请求到底属于哪个站点或应用。

有些服务器不严格检查 Host。你连着 neverssl.com,Host 写成 fasterthanli.me,它可能仍然返回 200。但有些服务器会检查,不匹配就返回 404 或其他错误。

如果请求有 body,比如 POST,就要把 body 放在空行后面:

http 复制代码
POST / HTTP/1.1
Host: example.org
Content-Length: 27

Take, eat; this is my body.

Content-Length 告诉服务器 body 有多少字节。这样服务器才能判断:客户端是真的发完了,还是连接中途断了。

响应也是类似结构:先是 status line,然后是 header fields,然后空行,然后 body。

http 复制代码
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1256

<!doctype html>
<html>
...

这里有一个容易混淆的小点:HTTP header 和 HTTP headers 不完全是一回事。

HTTP header 可以指整个头部区域:start-line 加上若干 header fields。HTTP headers 通常指那些 Name: Value 字段。口语里经常混用,但真正看 RFC 时要注意。


三、持久连接:一个 TCP 连接可以发多个请求

HTTP/1.1 默认可以复用 TCP 连接。

如果你不发送:

http 复制代码
Connection: close

服务器可以保持连接打开,等待你继续发下一个请求。

你甚至可以在同一个连接里连续写两个请求:

http 复制代码
HEAD / HTTP/1.1
Host: example.org

HEAD / HTTP/1.1
Host: example.org
Connection: close

服务器会返回两个响应。最后一个响应带 Connection: close,双方再关闭连接。

这就是 HTTP/1.1 persistent connection。它避免了每个请求都重新建立 TCP 连接的开销。但它也带来复杂性:连接什么时候关闭?前一个响应 body 到哪里结束?下一个响应从哪里开始?如果 body 长度不清楚,就麻烦了。

所以 Content-Length 很重要。


四、chunked transfer encoding:不知道长度也能传 body

如果你正在上传一个动态生成的文件,还不知道最终 body 多大,就不能提前写 Content-Length

HTTP/1.1 提供了 chunked transfer encoding:

http 复制代码
POST / HTTP/1.1
Host: example.org
Transfer-Encoding: chunked

4
Help
C
I am chunked
0

每个 chunk 前面先写长度,长度用十六进制表示。I am chunked 是 12 字节,所以长度是 C。最后发送长度为 0 的 chunk,表示结束。

这样服务器仍然能判断 body 是否完整:如果没看到 0 长度 chunk 就断了,那就是中途断开;如果看到 0 长度 chunk,就表示 body 正常结束。

这一点对代理尤其重要。代理不能只是"读到连接关闭为止"。HTTP/1.1 连接可能不关闭,而是继续承载后续请求。你必须根据 Content-Length 或 chunked encoding 判断 body 边界。


五、写 HTTP/1.1 代理时,世界开始变坏

如果你只是写一个客户端,请求某个 URL,拿回响应,事情还比较简单。

如果你写的是代理,复杂度立刻上升。

假设你是 CDN 或 ADN,有很多 edge nodes,接收用户请求,再把请求转发到后端 worker nodes。你监听 80 端口,接受 TCP 连接,然后要解析 HTTP/1.1。

第一道问题是资源限制。攻击者可以:

text 复制代码
发送无限长 header,让你的 buffer 不断增长,直到 OOM。
发送很多很多 header,占用解析和内存资源。
打开大量 TCP 连接,然后什么都不发,占满文件描述符或内存。
每次只发一个字节,极慢地发送 header 或 body,也就是 Slowloris。

所以代理不能无限等。你要设置 header 最大长度、header 数量上限、空闲 timeout、读 timeout、body 大小限制等。

第二道问题是转发策略。

比如客户端刚发来:

http 复制代码
GET / HTTP/1.1
Host: fantastic-app.example.org

理论上你已经知道后端应用是谁,可以立刻开始连接后端,并把请求转发过去。但你还没收到完整 header。恶意客户端可能永远不发空行,也可能继续发一堆奇怪 header。为了安全,你很可能要等完整请求头收完、校验完,再决定是否连接后端。

然后还有更多问题:

text 复制代码
如果 header value 里有 NUL 字节,要拒绝还是转发?
如果客户端写 Content-Length: 0,但后面又发 body,怎么办?
如果没有 Content-Length,但后面发 body,怎么办?
如果后端返回 204 No Content,却带了 Content-Length 和 body,怎么办?
请求 body 要不要完整读完再转发?大 body 怎么办?
响应 body 要不要缓存?缓存压缩版还是未压缩版?
后端返回 chunked,你转给客户端时还保留 chunked 吗?
能不能把非 chunked body 改成 chunked body?
Content-Encoding 是 gzip / brotli 时,代理要不要解压?
给后端发什么 Accept-Encoding?
CDN 是存多份压缩格式,还是按客户端能力现场转码?

这就是作者的观点:HTTP/1.1 代理必须有立场。

HTTP 规范有很多边界和历史包袱。代理要在"忠实复现客户端请求"和"为了安全与规范而改写/拒绝"之间做选择。选择不同,行为就不同,bug 也不同。

作者开玩笑说,让人写 HTTP 代理不属于日内瓦公约保护范围,但也许应该算。


六、TLS:明文 HTTP 该结束了

作者尝试用 HTTP 明文读取自己的文章,结果服务器返回 301,重定向到 HTTPS。

这是好事。互联网通信应该默认加密。哪怕你只是读一篇公开文章,如果只有"敏感通信"才加密,反而等于告诉观察者:哪些通信敏感,哪些不敏感。

命令行里可以用 openssl s_client 代替 ncnc 只是原始 TCP,openssl s_client 可以帮我们建立 TLS 连接,把 stdin 加密发出去,把收到的内容解密到 stdout。

例如:

bash 复制代码
printf 'GET /articles/the-http-crash-course-nobody-asked-for HTTP/1.1\r\nHost: fasterthanli.me\r\nConnection: close\r\n\r\n' \
  | openssl s_client -verify_quiet -quiet -connect fasterthanli.me:443

这样就能在 TLS 连接上说 HTTP/1.1。

这里有一个关键概念:HTTPS 不是一套完全不同的应用协议。最朴素地说,它就是 HTTP over TLS。HTTP 仍然是 HTTP,只是中间加了一层加密隧道。

当然,真实世界里 TLS 还有证书验证、SNI、ALPN、密钥协商、协议版本、cipher suite 等一堆细节,但对这篇文章来说,先理解"HTTP 明文文本被包进 TLS 加密通道"就够了。


七、用 Rust 发请求:先从 reqwest 开始

命令行很好玩,但作者想继续往 HTTP/2 走,所以开始写 Rust。

第一层是 reqwest。它是 Rust 生态里常用的高层 HTTP client。配上 tokio:

rust 复制代码
#[tokio::main]
async fn main() {
    let response = reqwest::get("http://example.org").await.unwrap();

    println!(
        "Got HTTP {}, with headers: {:#?}",
        response.status(),
        response.headers()
    );

    let body = response.text().await.unwrap();
    for line in body.lines().take(10) {
        println!("{line}");
    }
}

这里很简单:第一次 .await 得到响应 header,第二次 .await 把 body 读成文本。

把 URL 改成 https://example.org,输出基本一样。对应用代码来说,HTTPS 只是 URL scheme 变了。底层 TLS 由库处理。reqwest 默认会通过依赖链使用 TLS 实现,比如 native-tls / openssl,也可以配置 rustls。

这就是高层抽象的好处:你不必每次都手动建立 TCP、TLS、写请求、读响应。

但作者当然不会停在这里。


八、抓包看 HTTP 和 HTTPS 的差异

为了确认发生了什么,作者用 tcpdump 抓包,再用 Wireshark 打开。

HTTPS 抓包里能看到:

text 复制代码
TCP handshake: SYN, SYN-ACK, ACK
TLS ClientHello
TLS ServerHello
Application Data

ClientHello 里能看到 SNI,也就是 server name extension,里面有 example.org。这能帮助确认抓到的是目标连接。

但后面的 Application Data 是加密的。Wireshark 只能告诉你这是 TLS record,里面是 encrypted application data。它看不到 HTTP header,也看不到 body。

换成 http:// 明文 URL 后,Wireshark 就能直接解析 HTTP 请求和响应:请求行、headers、body,全都清楚。

这非常直观地展示了 TLS 的作用:不是 HTTP 不存在了,而是 HTTP 被加密包裹起来了。


九、降一层:直接使用 hyper

reqwest 底层使用 hyper。所以作者下一步把 reqwest 去掉,直接用 hyper:

rust 复制代码
let response = hyper::Client::new()
    .get("http://example.org".parse().unwrap())
    .await
    .unwrap();

println!("Got HTTP {}", response.status());
println!("Body: {:?}", response.body());

输出里 body 是:

text 复制代码
Body(Streaming)

这很重要。HTTP 响应由 header 和 body 组成。你拿到 response header 时,body 可能还在流式到达。hyper 给你一个 body handle,让你自己决定怎么读。

如果用较低层的接口 poll body:

rust 复制代码
while let Some(buffer) = std::future::poll_fn(|cx| {
    Pin::new(&mut body).poll_data(cx)
}).await {
    let buffer = buffer.unwrap();
    println!("Read {} bytes", buffer.len());
}

有时整个 body 一次读完:

text 复制代码
Read 1256 bytes

有时分两次:

text 复制代码
Read 1125 bytes
Read 131 bytes

这很正常。TCP 是字节流,不是消息流。你写了一个 HTTP response body,不代表应用层每次 read 都会读到完整 body。底层 packet、buffer、socket read 都会影响你看到的切片大小。

这就是网络编程中非常重要的直觉:

text 复制代码
一次 write 不等于一次 read。
一个 HTTP body 不等于一个 TCP packet。
网络给你的是流,你必须自己处理边界。

十、hyper 默认不是"自动会 HTTPS"

接着作者把 hyper URL 改成 https://example.org,程序报错:

text 复制代码
invalid URL, scheme is not http

这不是说 hyper 不能做 HTTPS,而是说当前配置的 hyper client 只支持普通 HTTP。要支持 HTTPS,需要显式加 TLS connector。

作者使用 hyper-rustlsrustls

rust 复制代码
let conn = hyper_rustls::HttpsConnectorBuilder::new()
    .with_native_roots()
    .https_or_http()
    .enable_http1()
    .build();

let client = hyper::Client::builder().build::<_, hyper::Body>(conn);

这比 reqwest 啰嗦很多,但好处是控制力更强。

比如我们可以打开 rustls 的 key log 功能:

rust 复制代码
client_config.key_log = Arc::new(KeyLogFile::new());

然后运行程序时设置:

bash 复制代码
SSLKEYLOGFILE=/shared/sslkeylogfile cargo run

再把这个 key log file 配给 Wireshark,就能解密 TLS 会话。Wireshark 里不再只是 encrypted Application Data,而能看到里面真实的 HTTP/1.1 请求和响应。

这一步非常有教育意义:TLS 不再是黑盒。你能看到明文 HTTP 在 TLS 里面是什么样子,也能理解抓包工具为什么平时看不到内容。


十一、进入 HTTP/2:它仍然跑在 TCP 和 TLS 之上

下一步是 HTTP/2。

使用 hyper 的时候,可以构建一个只启用 HTTP/2 的 client:

rust 复制代码
let connector = hyper_rustls::HttpsConnectorBuilder::new()
    .with_tls_config(client_config)
    .https_only()
    .enable_http2()
    .build();

let client = Client::builder()
    .http2_only(true)
    .build::<_, hyper::Body>(connector);

抓包后,Wireshark 能解码出 HTTP/2。这里要注意:HTTP/2 仍然使用 TCP,也仍然可以通过 TLS 承载。熟悉的 TCP handshake 和 TLS handshake 仍然存在。

但 HTTP/2 和 HTTP/1.1 的模型差别很大。

HTTP/1.1 在一个连接上可以顺序发多个请求,但响应基本也按顺序回来。HTTP/2 则有 stream multiplexing。多个请求可以在同一个 TCP 连接上并发进行,不同 stream 的 frame 可以交错出现。一个请求的 headers 和 body 可以和另一个请求的 headers 和 body 交织在一起。

作者用 h2 发多个请求,用 strace 验证只有一个到 443 端口的连接,但日志显示多个请求的 header/body 完成顺序可以不同。这就是 HTTP/2 多路复用的直观表现。


十二、直接用 h2:SendRequest 和 Connection

hyper 的 HTTP/2 细节很大一部分在 h2 crate 里。于是作者继续降一层,直接使用 h2

流程是:

text 复制代码
手动 DNS 解析
建立 TCP 连接
建立 TLS 会话
把 TLS socket 交给 h2::client::handshake
得到 SendRequest 和 Connection
spawn Connection
用 SendRequest 发送请求

h2::client::handshake(stream).await? 返回两个东西:

text 复制代码
SendRequest
Connection

SendRequest 用来发请求。Connection 是一个 Future,必须被 poll,整个 HTTP/2 连接才能继续推进。作者通过 tokio::spawn(conn) 把它放到后台任务里跑。

这很符合 HTTP/2 的模型:连接本身有很多内部状态和异步事件,不是"发一个请求等一个响应"这么简单。它要处理 frame 读写、流控、SETTINGS、WINDOW_UPDATE、stream 状态、错误等。

第一次直接用 h2 时,作者遇到了:

text 复制代码
frame with invalid size

原因是:虽然连接到 443 并建立了 TLS,但服务器不知道客户端想说 HTTP/2。

这就引出 ALPN。


十三、ALPN:告诉服务器我想说 h2

在 TLS 上使用 HTTP/2,客户端需要通过 ALPN,也就是 Application-Layer Protocol Negotiation,告诉服务器自己支持哪些应用层协议。

如果你不设置 ALPN,服务器可能默认以为你要说 HTTP/1.1。结果客户端把 TLS socket 交给 h2 解析,看到的其实是 HTTP/1.1 响应,于是就解析出"frame size 不合法"。

解决方法是设置:

rust 复制代码
client_config.alpn_protocols = vec![b"h2".to_vec()];

真实世界里通常会同时提供:

text 复制代码
h2
http/1.1

这样如果服务器支持 HTTP/2,就选 h2;否则回退到 HTTP/1.1。

明文 HTTP/2 还有另一个路径:h2c,也就是 HTTP/2 cleartext。它可以通过 HTTP/1.1 Upgrade 机制从普通明文连接升级到 HTTP/2。但作者这里走的是 TLS + ALPN。

加上 ALPN 后,HTTP/2 请求成功。


十四、手写 HTTP/2:从 preface 和 frame 开始

接下来作者继续往下,不用 h2 crate,自己写 HTTP/2 frame 读写。

HTTP/2 客户端在 ALPN 协商成功后,需要先发送 connection preface:

rust 复制代码
pub const PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";

然后发送 SETTINGS frame。

HTTP/2 的通信单位是 frame。每个 frame 都有一个 9 字节 header:

text 复制代码
Length      24 bits
Type         8 bits
Flags        8 bits
R            1 bit reserved
Stream ID   31 bits
Payload      Length bytes

frame type 包括:

text 复制代码
DATA
HEADERS
PRIORITY
RST_STREAM
SETTINGS
PUSH_PROMISE
PING
GOAWAY
WINDOW_UPDATE
CONTINUATION

作者先写一个简单的 Frame

rust 复制代码
pub struct Frame {
    pub frame_type: FrameType,
    pub flags: u8,
    pub reserved: u8,
    pub stream_id: u32,
    pub payload: OpaquePayload,
}

然后用 nom 解析 frame header,用 byteorder 写回 header,用 enum-repr 处理 frame type 的数字表示。

一开始 flags 只是 u8。但很快作者把它改成更强类型:不同 frame type 有不同 flags。比如 SETTINGS 有 ACK,HEADERS 有 END_HEADERS、END_STREAM、PADDED、PRIORITY,DATA 有 END_STREAM、PADDED。

如果只是 u8,你很容易把 HEADERS 的 flag 用到 SETTINGS 上。Rust 可以用 enum + bitflags 表达:

rust 复制代码
pub enum FrameType {
    Data(DataFlags),
    Headers(HeadersFlags),
    Settings(SettingsFlags),
    WindowUpdate,
    ...
}

这样 FrameType 本身携带对应 flags,错误组合更难出现。

作者说,即使是 throwaway code,他也愿意做这些抽象。因为这样心智负担反而更小:类型系统帮你保证"这个 frame 的 flags 属于这个 frame type"。


十五、SETTINGS、ACK、HEADERS 和 HPACK

HTTP/2 连接建立后,客户端发送 preface 和 SETTINGS。服务器也会返回 SETTINGS。收到对方的 SETTINGS 后,如果不是 ACK,就要发送一个带 ACK flag 的 SETTINGS frame 作为确认。

接着要发请求。HTTP/2 里的请求不再是文本:

http 复制代码
GET / HTTP/1.1
Host: example.org

而是通过 HEADERS frame 表达。请求头里会有一些 pseudo-header:

text 复制代码
:method     GET
:path       /
:scheme     https
:authority  example.org

这些 header 会被 HPACK 编码。HPACK 是 HTTP/2 的头部压缩格式,有自己的 RFC。作者没有手写 HPACK,而是用了 Rust 的 hpack crate。

发送 HEADERS frame 时,选择 stream ID 1。客户端主动创建的 stream ID 必须是奇数,服务器主动创建的 stream ID 是偶数。第一个客户端请求自然是 stream 1。

因为这次没有请求 body,所以 HEADERS frame 可以带:

text 复制代码
END_HEADERS
END_STREAM

END_HEADERS 表示 header block 到这里结束,不需要 CONTINUATION frame。END_STREAM 表示客户端这一侧不再发送数据,也就是请求 body 结束。

请求发出去后,服务器返回:

text 复制代码
SETTINGS
WINDOW_UPDATE
SETTINGS ACK
HEADERS
DATA

HEADERS 里包含响应头,比如 :status: 200content-typecontent-length 等。DATA 里是响应 body。如果 DATA 带 END_STREAM,说明响应 body 结束。

到这里,作者已经用自己写的最小 HTTP/2 frame 层,发出了一个真实的 HTTPS HTTP/2 请求,并读到了真实响应。


十六、HTTP/2 的 header 规则和 body 规则

HTTP/2 里,header 名必须小写。这是正确决定。HTTP/1.x 里 header 名大小写不敏感,导致代理和库之间有各种 normalize 问题。HTTP/2 直接规定:构造 HTTP/2 message 时,field names 必须转成 lowercase。

content-length 仍然存在,但语义更严格。

在 HTTP/2 里,body 是 DATA frames。content-length 如果存在,必须等于所有 DATA frame payload 长度之和。否则 message malformed。

也有例外:某些响应定义上没有内容,比如 204 No Content、304 Not Modified、HEAD 请求的响应。它们可以有非零 content-length header,但实际没有 DATA frame 内容。

HTTP/1.1 的 chunked transfer encoding 在 HTTP/2 里不再需要。因为 DATA frame 本身已经是分块结构。请求或响应 body 就是同一 stream 方向上的多个 DATA frame。

这说明 HTTP/2 不是"把 HTTP/1.1 文本换成二进制"这么简单。它重新定义了消息如何分帧、如何多路复用、如何表达流结束。


十七、stream ID、GOAWAY 和重试语义

HTTP/2 每个请求/响应运行在 stream 上。

客户端发起的 stream ID 是奇数,并且必须递增。也就是说,客户端可以发 stream 1、3、5、7......不能倒退,也不能重用旧 ID。

stream ID 是 31 bit,所以理论上会耗尽。客户端耗尽后可以新开 HTTP/2 连接继续请求。服务端如果想优雅关闭连接,可以发送 GOAWAY frame。

GOAWAY 很有意思。它里面包含 last stream ID,表示服务端最后接受处理的是哪个 stream。

假设服务端视角:

text 复制代码
收到 stream 1
收到 stream 3
收到 stream 5
决定优雅关闭,发送 GOAWAY last_stream_id = 5

而客户端视角可能是:

text 复制代码
发送 stream 1
发送 stream 3
发送 stream 5
发送 stream 7
发送 stream 9
发送 stream 11
收到 GOAWAY last_stream_id = 5

客户端就知道:1、3、5 可能被处理;7、9、11 没有被服务端接受,应该在别的连接上重试。

HTTP/1.1 很难做到这一点。如果 TCP 连接在响应 header 前断了,客户端不一定知道服务器有没有处理请求。服务器返回 503 或 429 也许表示可以重试,但这依赖具体后端语义。代理还要区分这个错误是后端生成的,还是代理自己生成的。

HTTP/2 的 GOAWAY 给了更清晰的 out-of-band 连接级信号。


十八、HTTP/2 流控:复杂性真正开始

HTTP/2 最容易出 bug 的地方之一是 flow control。

HTTP/2 有连接级 flow control,也有 stream 级 flow control。双方通过 WINDOW_UPDATE frame 增加对方可发送的窗口。还有 SETTINGS_INITIAL_WINDOW_SIZE,用来设定新 stream 的初始窗口大小。

复杂之处在于:SETTINGS 是异步的。

客户端建立连接后可以立刻发请求,不必等服务器 SETTINGS。但服务器 SETTINGS 可能改变初始窗口大小。于是可能出现这种情况:

text 复制代码
客户端一开始按默认窗口发了 60KB。
服务器随后 SETTINGS_INITIAL_WINDOW_SIZE = 16KB。
客户端收到后要重新计算窗口。
结果窗口变成 -44KB。

是的,窗口可以变成负数。规范要求发送方必须记录这个负窗口,在收到足够 WINDOW_UPDATE 让窗口变正之前,不能继续发送新的受流控 frame。

更麻烦的是,如果一方发送 SETTINGS 改变窗口,对端在 ACK 之前可能还会继续按旧窗口发送数据。发送 SETTINGS 的一方必须准备好接收比新窗口更多的数据。

如果 SETTINGS 永远不被 ACK,规范允许一定时间后发 SETTINGS_TIMEOUT 连接错误。也可以对某个 stream 发 RST_STREAM,比如 FLOW_CONTROL_ERROR。

HTTP/1.1 里,很多事情可以简单地关闭 TCP 连接。HTTP/2 不一样。多个 stream 共享同一个 TCP 连接。一个 stream 出问题,不一定应该杀掉整个连接。你要在 stream 级别处理错误。

这就是 HTTP/2 强大但复杂的地方。


十九、HTTP/2 的新攻击面

HTTP/2 不只是功能更多,攻击面也更多。

攻击者可以:

text 复制代码
疯狂发送 SETTINGS frame,让对方不断 ACK。
疯狂发送 PING frame,消耗处理资源。
发送很多 WINDOW_UPDATE,每次 increment 很小,诱导对方生成大量小 DATA frame。
在 HTTP/2 层给大量 flow-control credit,但在 TCP 层不读数据,导致对方构造并缓存大量待发送 frame,造成资源耗尽。

此外,HTTP/2 的 header 使用 HPACK 压缩。压缩本身也可能引入侧信道攻击,例如 CRIME 类攻击:攻击者通过构造输入并观察压缩后密文大小变化,推测 secret header 或 cookie 内容。

这不是说 HTTP/2 不好,而是说协议越强大,状态越复杂,安全边界越多。

作者最后还提到一个实际复现:某些场景下,HTTP/1.1 能完成所有请求,而 HTTP/2 在某些请求数和最大 stream 数组合下会卡住,比如 240 个请求、200 个 stream 时卡在 222/240。作者没有在这篇文章里完全解释原因,只是说明这类问题确实存在,而且 h2spec 这种一致性测试只能作为起点,不足以覆盖所有现实交互。


二十、为什么作者想写自己的 H1/H2 实现

文章后记里,作者说研究这篇文章让他既谦卑,又更想自己实现协议。

他正在写一个新的 Rust H1/H2 实现,有非常明确的目标:

text 复制代码
只支持 Linux
使用 io_uring 做异步 I/O
使用 rustls 做 TLS 握手,然后尝试 kTLS
仔细控制内存使用,使用固定大小 buffer pool
暴露 H1/H2 连接的精确内部状态

这和 hyper/h2 的目标不同,所以不是竞争关系。

hyper 是 Rust 事实标准 HTTP 实现,API 稳定、通用、生态依赖广。它需要服务大量用户,不能轻易暴露内部状态或大改公共 API。

作者想要的是另一个方向:更强可观测性,更精确状态,更可控内存,更贴近代理场景。

他特别提到 io_uring 会改变异步 I/O 接口的设计。提交一个操作后,buffer 就不再属于应用,而暂时属于内核。所以 API 可能像 tokio-uring 那样:把 owned buffer 传进去,完成后再把 buffer 还回来。

这会导致代码经常写成:

rust 复制代码
let mut buf = get_a_buf();
let res;
(res, buf) = src.read(buf).await;
res?;

如果使用 Vec<u8> 作为 buffer 类型很直接,但不一定最优。Bytes 很常用,hyper 也大量使用它,但 BytesSend,内部用 atomic reference counting。作者设想如果每个 tokio-uring runtime 是 current-thread 模式,一个线程处理自己的连接,就可能不需要 Arc 这种原子引用计数,普通引用计数就够。

他还提到 Arc<GlobalState> 模式在高流量 hyper 应用中的真实成本。多线程共享全局状态很方便,但原子操作、mutex、跨线程协调都不是免费的。如果连接处理尽量局部化,很多 mutex 可以变成 RefCell,因为同一线程里只需要运行时借用检查,不需要阻塞或切线程。

这些都是作者实现新协议栈时想探索的问题。


二十一、这篇文章真正想讲什么

表面上,这是一篇 HTTP 速成课。它从 HTTP/1.1 讲到 HTTP/2,从 netcat 讲到 hyper,从 TLS 抓包讲到 h2 frame,从 chunked transfer 讲到 flow control。

但更深层,它讲的是"抽象背后到底藏着多少状态"。

你用 reqwest 发请求,代码很短:

rust 复制代码
reqwest::get("https://example.org").await?

但背后发生了:

text 复制代码
DNS 查询
TCP 连接
TLS ClientHello / ServerHello
SNI
证书验证
ALPN
HTTP/1.1 或 HTTP/2 选择
请求 header 编码
body streaming
响应 header
响应 body
连接复用
流控
缓存
压缩
错误处理

如果是 HTTP/2,还要加上:

text 复制代码
connection preface
SETTINGS
SETTINGS ACK
stream ID
HEADERS
HPACK
DATA
WINDOW_UPDATE
RST_STREAM
GOAWAY
PING
frame flags
stream lifecycle
connection-level state
stream-level state

高层库把这些都隐藏起来,这是好事。否则每个应用开发者都要疯掉。

但当你写 proxy、debug 诡异问题、排查性能、做高流量系统时,你又必须知道下面发生了什么。否则遇到 bug 时,只能看见一个抽象的 hyper::Error 或"请求卡住",不知道状态机在哪里停了。

这就是作者写这篇文章的动机:把 HTTP 从"神秘黑盒"变成"虽然复杂,但可以接近"的系统。


二十二、对工程实践的启发

第一,HTTP/1.1 看起来简单,但代理场景很难。

手写一个客户端和写一个代理不是一回事。代理要面对恶意输入、资源耗尽、规范歧义、后端异常、缓存、压缩和传输编码。不能只按 happy path 理解 HTTP。

第二,body 是流,不是一次性字符串。

无论 HTTP/1.1 还是 HTTP/2,body 都可能分多次到达。你不能假设一次 read 得到完整响应,也不能假设 write 和 read 边界一致。

第三,TLS 不只是"加密开关"。

TLS 会影响抓包、调试、协议协商。SNI 帮服务器知道你访问哪个域名,ALPN 帮双方协商 HTTP/2 或 HTTP/1.1。使用 key log file 可以在调试环境里解密 TLS 流量,理解真实传输内容。

第四,HTTP/2 不是 HTTP/1.1 的二进制皮肤。

它引入 stream multiplexing、frame、HPACK、flow control、GOAWAY、RST_STREAM 等机制。它解决了一些 HTTP/1.1 难题,也引入了新状态和新 bug。

第五,协议实现需要可观测性。

当状态机复杂到一定程度,只有高层错误不够。你需要能 dump 内部状态、看到连接和 stream 的生命周期、知道窗口大小、知道哪些 frame 发出/收到、知道哪个 stream 卡住。

第六,Rust 非常适合写这类协议探索。

Rust 的 enum、bitflags、Result、类型系统、所有权模型,都适合把协议状态建模成难以误用的结构。作者多次强调,即使是 throwaway code,把 frame type 和 flags 做成强类型也能减少心智负担。


二十三、总结

这篇文章从一个代理维护者的痛苦开始。HTTP 在普通使用中很成功,因为我们几乎不用想它。但当你维护 HTTP/S/2 proxy 时,任何问题都会被怀疑是代理导致的。代理处在客户端和后端之间,不能完全信任任何一方,还要在规范、兼容性、安全、性能和资源限制之间做取舍。

文章先从 HTTP/1.1 讲起。HTTP/1.1 本质上是在 TCP 连接上发送文本。请求由 request line、headers、空行和可选 body 构成。每行以 CRLF 结束。Host 是 HTTP/1.1 的重要要求,用于同一个 IP 上托管多个站点。Content-Length 用来标明 body 长度,帮助对端判断 body 是否完整。连接默认可以复用,因此 body 边界必须明确。未知 body 长度时,可以使用 chunked transfer encoding,每个 chunk 前面带十六进制长度,最后以 0 长度 chunk 结束。

然后文章转向代理问题。一个 HTTP/1.1 代理要防无限长 header、过多 header、空闲连接、Slowloris、过大 body 等资源耗尽攻击。它还要决定如何处理非法 header、奇怪的 Content-Length、204 却带 body 的响应、chunked 与非 chunked 的转换、gzip/brotli 压缩、缓存格式和 Accept-Encoding。代理必须在"忠实转发"和"严格校验"之间做选择。HTTP/1.1 很简单,但只是在忽略大部分边界条件时简单。

随后 TLS 登场。作者用 openssl s_client 代替 nc,在 TLS 上手写 HTTP/1.1。接着用 Rust 的 reqwest 发 HTTP/HTTPS 请求,再用 tcpdump 和 Wireshark 抓包。明文 HTTP 可以被 Wireshark 直接解析出 header 和 body;HTTPS 只能看到 TLS ClientHello、ServerHello 和 encrypted Application Data。用 rustls 的 KeyLogFile 配合 Wireshark,可以在调试环境中解密 TLS,看到 TLS 里面真正的 HTTP 明文。

接下来文章从 reqwest 降到 hyper。hyper 让我们更清楚看到 header 和 body 的分离:拿到 response 后,body 是 streaming 的。body 可能一次读完,也可能分多次读到。通过 strace 可以看到这和底层 socket read 对应。hyper 默认 HTTP client 不能直接处理 HTTPS,需要加 hyper-rustls 这样的 TLS connector。这样代码更啰嗦,但控制力更强。

进入 HTTP/2 后,文章先用 hyper 发 HTTP/2 请求,再降到 h2 crate。HTTP/2 仍然跑在 TCP 和 TLS 上,但增加了 stream multiplexing。多个请求可以共享一个 TCP 连接,响应 header 和 body 可以乱序完成。直接使用 h2 时,需要建立 TCP、建立 TLS,然后调用 h2::client::handshake 得到 SendRequestConnectionConnection 必须作为 Future 被 poll,否则连接不会推进。第一次失败的原因是没有通过 ALPN 告诉服务器要说 HTTP/2;加上 client_config.alpn_protocols = vec![b"h2".to_vec()] 后,请求成功。

然后作者继续手写 HTTP/2 frame 层。HTTP/2 客户端先发送 connection preface:PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,然后发送 SETTINGS frame。HTTP/2 frame 有 9 字节 header:24-bit length、8-bit type、8-bit flags、1-bit reserved、31-bit stream id,再跟 payload。frame type 包括 DATA、HEADERS、SETTINGS、PING、GOAWAY、WINDOW_UPDATE 等。作者用 Rust enum 和 bitflags 建模 frame type 和 flags,让不同 frame type 的 flags 不容易混用。即使是实验代码,这种强类型抽象也能减少心智负担。

为了发送请求,需要 HEADERS frame。HTTP/2 请求头包含 pseudo-headers,如 :method:path:scheme:authority。HEADERS payload 使用 HPACK 编码,所以作者引入 hpack crate。客户端选择 stream ID 1,因为客户端发起的 stream 必须是奇数。没有请求 body 时,HEADERS frame 可以同时带 END_HEADERS 和 END_STREAM。服务器返回 SETTINGS、WINDOW_UPDATE、SETTINGS ACK、HEADERS 和 DATA。作者解析 HEADERS,解出 :status: 200 等响应头,再读取 DATA frame 得到 HTML body。

文章随后讲 HTTP/2 的细节:所有 header 名必须小写;content-length 仍然存在,但必须等于所有 DATA frame payload 长度之和,除非消息定义上没有内容,比如 204、304 或 HEAD 响应;HTTP/1.1 的 chunked transfer encoding 在 HTTP/2 中不再需要,因为 DATA frame 本身已经是分块。client initiated stream 使用奇数 ID,且必须递增。stream ID 会耗尽,服务端可以发送 GOAWAY,并用 last stream ID 告诉客户端哪些请求已被接受,哪些应该重试。

最复杂的部分是 HTTP/2 flow control。HTTP/2 有连接级和 stream 级流控,WINDOW_UPDATE 增加窗口,SETTINGS_INITIAL_WINDOW_SIZE 可以改变初始窗口。由于 SETTINGS 是异步的,窗口甚至可能变成负数,发送方必须记录负窗口,直到收到 WINDOW_UPDATE 后恢复。SETTINGS 如果不被 ACK,也可能触发 SETTINGS_TIMEOUT。和 HTTP/1.1 不同,HTTP/2 不能遇到单个 stream 问题就粗暴关闭 TCP,因为同一个连接上还有其他 stream。RST_STREAM、GOAWAY、SETTINGS、WINDOW_UPDATE 这些机制让协议更强大,也让状态机更复杂。

文章最后指出,HTTP/2 还有很多攻击面:滥发 SETTINGS 或 PING、发送极小 WINDOW_UPDATE 诱导对方生成大量小 DATA frame、在 HTTP/2 层提供大量流控额度但在 TCP 层不读数据造成资源耗尽,以及 HPACK 压缩带来的侧信道风险。h2spec 这样的 conformance testing tool 有价值,但只能作为起点,真实系统仍然有大量交互 bug 空间。

后记中,作者说他正在写一个新的 Rust H1/H2 实现:只面向 Linux,使用 io_uring,使用 rustls/kTLS,固定大小 buffer pool,并尽量暴露精确内部状态。这个项目不是要取代 hyper,而是为了不同设计目标:更可控的内存、更清楚的连接状态、更容易追查未知问题。作者认为 Rust 很适合做这类协议实验,因为它能用类型系统把 frame、flags、state 和错误路径建模得更难误用。

整篇文章真正想表达的是:HTTP 之所以好用,是因为大量复杂性被库、协议和基础设施隐藏了。但如果你写代理、排查性能和协议 bug,或者想理解系统真实行为,就必须往下走。HTTP/1.1 不是"几行文本"那么简单,HTTP/2 也不是"二进制版 HTTP/1.1"。它们背后是连接、流、frame、flow control、TLS、ALPN、压缩、缓存、安全和状态机。理解这些东西,会让 HTTP 从神秘黑盒变成一个虽然复杂、但可以靠近和实验的系统。

相关推荐
CaffeinePro1 小时前
FastAPI自动接口文档定制与美化、权限管控
后端·fastapi
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第151题】【06_Spring篇】第11题:说一下 Spring Bean 的生命周期?
java·开发语言·后端·spring·面试
赫媒派3 小时前
Gin 12年零破坏API,架构哲学如何练成?
后端·go·gin
fliter4 小时前
Arborium:把 tree-sitter 语法高亮打包成 Rust 文档生态的基础设施
后端
张三丰24 小时前
不会写代码的高管用Claude Code两天上线新程序,工程师接手后发现:一个Bug,让AI一天烧掉一个月服务器费!
后端
Ai拆代码的曹操5 小时前
从一条转账 SQL 到分布式事务:5 种方案的全方位对比与实战
后端
掘金小豆5 小时前
Spring 事务失效的 6 大场景,你踩过几个?
后端·spring·面试
im_lanny5 小时前
Agent = Model + Harness:决定 AI 智能体上限的,往往不是模型而是“装具”
后端
阿文和她的Key5 小时前
AI新词太多?把它们串成一条线就清楚了
后端