Appearance
第11章 HTTP/1 wire:parser、encoder、chunked 编码
11.1 一个 TCP 包的变形记
假设你在浏览器里敲回车访问 https://example.com/foo。你看到的是页面——但这背后实际上是一段 ASCII 字节流飞向服务器:
GET /foo HTTP/1.1\r\n
Host: example.com\r\n
Accept: text/html\r\n
User-Agent: curl/7.88.1\r\n
\r\n这段字节到了服务器那一侧,需要被"翻译"成一个 http::Request<Incoming> 才能交给 Axum / Tonic / 你的业务代码。翻译的过程分三步:
- 解析 request line:
GET /foo HTTP/1.1→method=GET, uri=/foo, version=HTTP/1.1。 - 解析 headers:每行
Name: Value\r\n→HeaderMap。 - 准备 body decoder:根据
Content-Length或Transfer-Encoding头,构造出能读后续 body 字节的 decoder。
再反过来,处理完业务之后,Response 又要被"编码"回字节流发回客户端。这一章我们读 Hyper 的 proto::h1 模块——Rust 工业级 HTTP/1 实现的核心。
源码来自 hyper 1.9.0(commit 0d6c7d5),文件在 hyper/src/proto/h1/:
conn.rs 1531 行 连接状态机(下一章)
dispatch.rs 808 行 请求分发(下一章)
decode.rs 1254 行 body decoder(本章重点)
encode.rs 672 行 body encoder(本章重点)
role.rs 3173 行 Server/Client 双角色抽象 + header 解析调度(本章)
io.rs 967 行 buffered I/O 抽象(第 14 章)我们这一章聚焦 decode.rs / encode.rs / role.rs 的前半部分,把字节翻译的细节读清楚。
11.2 httparse:SIMD 加持的底层解析器
很多人以为 hyper 自己写了 HTTP header parser。实际上不是——hyper 依赖 httparse crate。它是 Sean McArthur(hyper 作者)自己做的一个独立小库,专门解决"HTTP 请求/响应头部的 SIMD 加速解析"。
httparse 的接口不大:
rust
let mut headers = [httparse::EMPTY_HEADER; 64];
let mut req = httparse::Request::new(&mut headers);
let status = req.parse(bytes)?;
match status {
Status::Complete(n) => { /* 消费 n 字节 */ }
Status::Partial => { /* 数据不足,等更多 */ }
}给它一个字节切片,它告诉你是否能完成解析。如果能,返回解析位置(n 字节被消费);不能,返回 Partial 等下一次。
11.2.1 为什么 SIMD 重要
header 解析的热点在于字符扫描——寻找 :、\r、\n、校验"每个字节是 valid token char"等。朴素地用 for byte in bytes.iter() 每次 loop 处理一个字节——成本 4-5 CPU cycles/byte。在 10Gbps 网卡的极限场景下,光 header 解析就能吃掉一个 CPU 核。
httparse 用 SIMD 指令(AVX2 / NEON)一次处理 16-32 字节——用 _mm256_cmpeq_epi8 同时和多个 sentinel 字符比较,返回一个位图;用 tzcnt 找最低有效位(最早出现的 sentinel)。单字节扫描降到 0.1-0.3 cycles/byte——15 倍的速度提升。这是 hyper 能在 1M QPS 场景下依然 CPU 富余的重要原因。
SIMD 这块不是本书重点——但你需要知道"Hyper 的 header 解析不是普通代码",它站在一个 SIMD 小引擎之上。httparse 的 hot path 实现全部是 unsafe Rust + intrinsics,经过严格 fuzzing 测试。如果你好奇,去 GitHub 看它的 src/simd/ 目录。
11.2.2 hyper 如何使用 httparse
rust
// hyper/src/proto/h1/role.rs: Server::parse 摘录
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<RequestLine> {
// 分配 headers 数组(按配置,默认 100)
let mut headers_indices = [...; DEFAULT_MAX_HEADERS];
let mut headers = [httparse::EMPTY_HEADER; DEFAULT_MAX_HEADERS];
// 调用 httparse
let mut req = httparse::Request::new(&mut headers);
let res = req.parse_with_uninit_headers(buf, &mut headers_indices);
match res {
Ok(httparse::Status::Complete(len)) => {
// 拿到 method / path / version / headers
// 构造 http::Method / http::Uri / http::HeaderMap
// ... 把字节范围翻译成 http:: 类型
}
Ok(httparse::Status::Partial) => Ok(None), // 数据不足
Err(e) => Err(e.into()),
}
}hyper 拿到 httparse 的"字节范围"之后,用 BytesMut::split_to(len) 把请求头部分从缓冲切出来——这里得到一个独立的 Bytes。然后 HeaderName、HeaderValue 都是这个 Bytes 的子切片(通过 Bytes::slice() 零拷贝)。整个过程没有一次 memcpy——header values 只是 Bytes 里的 offset + len 引用。
这再次印证了第 9 章的观点:bytes::Bytes 是整个 Rust HTTP 栈的零拷贝基础。从 TCP socket 读出字节到一个 BytesMut 缓冲,解析成 HeaderMap,传到 handler,再写回 socket——全程没有把 header 复制过一次。
11.2.3 DEFAULT_MAX_HEADERS
rust
const DEFAULT_MAX_HEADERS: usize = 100;默认最多 100 个 header。超过这个数 httparse 会返回 TooManyHeaders 错误——hyper 把它映射成 413 / 400 响应。100 是一个相对宽松的默认——Chrome 发送的请求大约 20-30 个 header,API 网关的内部请求可能多到 50-80 个。100 给了合理的冗余,同时防止攻击者塞几万个 header 让 server 分配大内存。
Hyper 的 http1::Builder::max_headers(n) 允许覆盖这个值。生产服务强烈建议降到 30 或 50——攻击者如果能让你分配更多 headers 数组,在大连接场景下会累积显著内存。
11.3 body Decoder:三种长度语义
HTTP/1.1 允许三种"body 到哪里结束"的约定:
Content-Length: 12345→ 读 12345 字节就结束。Transfer-Encoding: chunked→ 按 chunk 格式读,最后一个 0 长度 chunk 表示结束。- 都没有(仅响应合法)→ 读到连接关闭为止。
Hyper 把这三种统一成 Decoder::Kind:
rust
// hyper/src/proto/h1/decode.rs:38-66
enum Kind {
/// A Reader used when a Content-Length header is passed with a positive integer.
Length(u64),
/// A Reader used when Transfer-Encoding is `chunked`.
Chunked {
state: ChunkedState,
chunk_len: u64,
extensions_cnt: u64,
trailers_buf: Option<BytesMut>,
trailers_cnt: usize,
h1_max_headers: Option<usize>,
h1_max_header_size: Option<usize>,
},
/// A Reader used for responses that don't indicate a length or chunked.
///
/// The bool tracks when EOF is seen on the transport.
Eof(bool),
}三个 variant,三种协议状态,各有各的 hot path。
11.3.1 Length(n):最简单的情况
Length(n) 只需要从网络 buffer 读 n 字节就完事。源码里对应的 read 方法:
rust
// 精简版
match self.kind {
Length(remaining) => {
if remaining == 0 {
// 已经读完
return Poll::Ready(Ok(None));
}
let buf = ready!(body.read_mem(cx, remaining as usize))?;
let read_len = buf.len();
self.kind = Length(remaining - read_len as u64);
Poll::Ready(Ok(Some(Frame::data(buf))))
}
...
}核心逻辑:
remaining记录还需要读多少字节。- 每次
read_mem(cx, n)拉一块出来(可能一次只拉到一部分)。 - 扣减 remaining,构造
Frame::data(buf)返回。 - remaining 到 0 时,下次 poll 返回
None结束流。
注意 read_mem 的返回值是 Bytes——不是 &[u8]。这是因为 hyper 的底层 buffer 可以"切出一块零拷贝的 Bytes"传给上层。Body decoder 读到的数据直接包装成 Frame 交出去,不复制字节。
11.3.2 Chunked:13 状态的状态机
chunked encoding 的 wire format 是这样的:
4\r\n <-- chunk size in hex
data\r\n <-- chunk data (4 bytes)
5\r\n
hello\r\n
0\r\n <-- final chunk (size 0)
X-Trail: foo\r\n <-- optional trailer headers
\r\n <-- end of stream解析这个 format 不是一行一行对——它是 byte-by-byte 的状态机。看 Hyper 的 ChunkedState enum:
rust
// hyper/src/proto/h1/decode.rs:69-83
enum ChunkedState {
Start,
Size,
SizeLws,
Extension,
SizeLf,
Body,
BodyCr,
BodyLf,
Trailer,
TrailerLf,
EndCr,
EndLf,
End,
}13 种状态。每个字节都可能触发一次状态转移。
每个状态对应一个 read_xxx 函数。比如 read_size:
rust
// hyper/src/proto/h1/decode.rs: read_size 简化
fn read_size<R: MemRead>(cx: &mut Context<'_>, rdr: &mut R, size: &mut u64)
-> Poll<Result<ChunkedState, io::Error>>
{
let radix = 16;
match byte!(rdr, cx) {
b @ b'0'..=b'9' => {
*size = or_overflow!(size.checked_mul(radix));
*size = or_overflow!(size.checked_add((b - b'0') as u64));
}
b @ b'a'..=b'f' => {
*size = or_overflow!(size.checked_mul(radix));
*size = or_overflow!(size.checked_add((b + 10 - b'a') as u64));
}
b @ b'A'..=b'F' => { ... }
b'\t' | b' ' => return Poll::Ready(Ok(ChunkedState::SizeLws)),
b';' => return Poll::Ready(Ok(ChunkedState::Extension)),
b'\r' => return Poll::Ready(Ok(ChunkedState::SizeLf)),
_ => invalid,
}
Poll::Ready(Ok(ChunkedState::Size))
}每个 byte! 宏从 reader 里拿一个字节——可能返回 Pending(数据不够)。如果拿到了:
- hex 数字:累积到
size上(checked_mul/checked_add防溢出)。 - 空白:切到 SizeLws 状态。
;:切到 Extension(chunk 可以有扩展,一般忽略)。\r:切到 SizeLf。- 其他:非法,返回错误。
每次调用 return 的是**"下一个状态"**——调用者的主循环会重新进入 step,根据新状态调不同的 handler。
这是一个非常 classical 的 async state machine:整个 parser 的 state 被编码在一个 enum,driver 通过 Poll::Pending/Poll::Ready 与外界交互。每次 poll 尽量推进状态,数据不够就挂起等 waker。
11.3.3 几个工业级细节
源码里有几个宏不能错过:
rust
const CHUNKED_EXTENSIONS_LIMIT: u64 = 1024 * 16;
const TRAILER_LIMIT: usize = 1024 * 16;chunk extension 和 trailer 都有 16KB 的上限。没这个上限,攻击者可以构造一个请求 1; <巨大 extension>; 0... 让 server 读 GB 级的字符串。
rust
macro_rules! or_overflow {
($e:expr) => (
match $e {
Some(val) => val,
None => return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid chunk size: overflow",
))),
}
)
}chunk size 的累加必须用 checked_mul / checked_add——一个 20 位 hex 字符串 FFFFFFFFFFFFFFFFFFFF 在 u64 上会溢出。溢出就是非法请求,直接错误。这个 or_overflow! 宏在每一处做算术都用到了,体现了"绝不信任网络输入"的一贯态度。
rust
macro_rules! byte (
($rdr:ident, $cx:expr) => ({
let buf = ready!($rdr.read_mem($cx, 1))?;
if !buf.is_empty() {
buf[0]
} else {
return Poll::Ready(Err(io::Error::new(io::ErrorKind::UnexpectedEof,
"unexpected EOF during chunk size line")));
}
})
);byte! 宏不只是"拿一个字节"——它内置了"意外 EOF 的错误处理"。如果 read_mem 返回空(连接关闭但数据未完),立即产出 UnexpectedEof 错误。这类"协议层 EOF"不是"正常结束"——它是"对端在不该关的时候关了"。
11.3.4 Eof(bool):HTTP/1.0 的遗产
第三种 Decoder 是 Eof(bool)——读到连接关闭为止。这是 HTTP/1.0 时代的设计(没有 Transfer-Encoding、没有 Content-Length),实际上HTTP/1.1 里只允许响应用,请求不能用(因为请求不能让 client 关连接来表示结束——server 无法区分 "client 故意关" 和 "client 还在发")。
bool 字段跟踪是否已看到 EOF。读到 EOF 之后下次 poll 返回 None。
源码注释里有一段罕见的规范引文(第 54-65 行):
rust
/// > If a Transfer-Encoding header field is present in a response and
/// > the chunked transfer coding is not the final encoding, the
/// > message body length is determined by reading the connection until
/// > it is closed by the server. If a Transfer-Encoding header field
/// > is present in a request and the chunked transfer coding is not
/// > the final encoding, the message body length cannot be determined
/// > reliably; the server MUST respond with the 400 (Bad Request)
/// > status code and then close the connection.这是 RFC 9112 §6.3 的直接引用——hyper 把规范条文嵌在源码注释里,让读代码的人能直接比对。这种**"源码即规范导航"**的风格是工业级 RFC 实现的标志。读卷二《MCP 协议设计与实现》时也能在 TypeScript SDK 里看到类似做法——规范实现者把 spec 条款拷在代码旁边,让代码和规范形成双向索引。
11.4 body Encoder:反向过程
rust
// hyper/src/proto/h1/encode.rs:35-47
enum Kind {
/// An Encoder for when Transfer-Encoding includes `chunked`.
Chunked(Option<Vec<HeaderName>>),
/// An Encoder for when Content-Length is set.
Length(u64),
/// An Encoder for when neither Content-Length nor Chunked encoding is set.
#[cfg(feature = "server")]
CloseDelimited,
}三个 variant 对称地对应 Decoder:
Chunked:出数据时包裹成 chunk 格式——<size>\r\n<data>\r\n,结束发0\r\n\r\n。Length(n):强制检查 "用户不能写超过 n 字节" ——超了 encoder 截断或报错,防止对端 parse 错误。CloseDelimited:写完 body 之后关连接。
11.4.1 Length(n) 的强制约束
rust
// hyper/src/proto/h1/encode.rs Encoder::encode 精简
match self.kind {
Length(remaining) => {
let amt = cmp::min(remaining, msg.remaining() as u64);
self.kind = Length(remaining - amt);
BufKind::Limited(msg.take(amt as usize))
}
...
}msg.take(amt) 这一行保证"每次最多写 amt 字节"——即使上层给了更多的数据,encoder 也只截取前 amt 字节。剩下的被丢弃。
为什么这样做?因为 HTTP/1.1 严格规定 Content-Length 声明多少字节就必须有多少字节。如果声明 100 但实际发了 150——客户端读到第 100 字节就认为请求/响应结束了,后面 50 字节会被当成下一个请求,造成"HTTP desync 攻击"。通过 encoder 层的强制截断,hyper 消除了这整个漏洞面。
11.4.2 Chunked 的封装
rust
enum BufKind<B> {
Chunked(Chain<Chain<ChunkSize, B>, StaticBuf>),
ChunkedEnd(StaticBuf),
Trailers(Chain<Chain<StaticBuf, Bytes>, StaticBuf>),
...
}Chain<Chain<ChunkSize, B>, StaticBuf> 是 bytes::Buf 的零拷贝 chain——三段数据拼接成一个逻辑 Buf,但底层没有任何 memcpy。
三段分别是:
ChunkSize:chunk size 的 hex 字符串 +\r\n,写在 stack 上的 15 字节数组。B:用户的 body 数据(来自Body::poll_frame的 Frame)。StaticBuf:\r\n,两个字节的常量。
当 hyper 要把这段数据写到 socket 时,用 vectored write(writev 系统调用)一次性提交三个 slice——内核 scatter-gather 完成,零拷贝。这就是为什么 chunked encoding 的开销可以接近 Content-Length——额外开销只是 size 字符串(每 chunk 几字节)加一次 writev。
11.4.3 Trailer 的编码
rust
/// An Encoder for when Transfer-Encoding includes `chunked`.
Chunked(Option<Vec<HeaderName>>),Option<Vec<HeaderName>> 是一个白名单——HTTP/1.1 规定 trailer 里出现的 header name 必须在请求/响应的 Trailer header 里提前声明。hyper 用这个 Vec 校验 user 提供的 trailer 是否都在白名单里。
gRPC over HTTP/1 的响应长这样:
HTTP/1.1 200 OK
Content-Type: application/grpc
Trailer: grpc-status, grpc-message
Transfer-Encoding: chunked
<protobuf frames>
0\r\n
grpc-status: 0\r\n
grpc-message: \r\n
\r\nhyper 的 encoder 对 into_chunked_with_trailing_fields(vec!["grpc-status", "grpc-message"]) 这种配置专门开了一条路径——trailer 的存在被编入 Encoder 的 chunked kind 里。
11.5 Http1Transaction:Server/Client 的对称抽象
role.rs 最精彩的部分是它的对称设计。HTTP/1 parser 要同时支持 Server(收请求)和 Client(收响应)——它们的 wire format 非常对称:
| Server 看到的 | Client 看到的 | |
|---|---|---|
| 第一行 | Request line: GET /foo HTTP/1.1 | Status line: HTTP/1.1 200 OK |
| 头部 | 一堆 Name: Value | 一堆 Name: Value |
| body 决定 | 读 Content-Length / Transfer-Encoding | 读 Content-Length / Transfer-Encoding(同) |
hyper 用一个 trait 把这两种抽象出来:
rust
// hyper/src/proto/h1/role.rs:131 / 1006
impl Http1Transaction for Server {
type Incoming = RequestLine; // Server 收的是请求行
type Outgoing = StatusCode; // Server 发的是状态码
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<RequestLine> { ... }
fn encode(enc: Encode<'_, Self::Outgoing>, dst: &mut Vec<u8>) -> crate::Result<Encoder> { ... }
}
impl Http1Transaction for Client {
type Incoming = StatusCode; // Client 收的是状态码
type Outgoing = RequestLine; // Client 发的是请求行
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<StatusCode> { ... }
fn encode(enc: Encode<'_, Self::Outgoing>, dst: &mut Vec<u8>) -> crate::Result<Encoder> { ... }
}同一个 trait,两个对称的 impl。Server 和 Client 是两个空 enum(pub(crate) enum Server {})——纯粹的类型级别标记。上层代码泛型化成 Dispatcher<T: Http1Transaction>,可以统一处理两侧。
Connection 的顶层也是泛型:
rust
pub struct Connection<T, S> where S: HttpService<...> {
conn: Http1Dispatcher<T, ..., S, ServerTransaction>,
// T = 底层 IO,S = user service,ServerTransaction = Server impl
}这种"通过 type state 分离服务端/客户端逻辑"的做法是 Rust 类型系统的典型用法——两条代码路径在类型层被分开,但共享所有实现模板。不用动态 dispatch、不用 if/else branching——编译期单态化完成 "同一段代码服务两个场景" 的工程目标。
11.5.1 is_complete_fast:提前判断
rust
// hyper/src/proto/h1/role.rs:93-109
fn is_complete_fast(bytes: &[u8], prev_len: usize) -> bool {
let start = prev_len.saturating_sub(3);
let bytes = &bytes[start..];
for (i, b) in bytes.iter().copied().enumerate() {
if b == b'\r' {
if bytes[i + 1..].chunks(3).next() == Some(&b"\n\r\n"[..]) {
return true;
}
} else if b == b'\n' && bytes.get(i + 1) == Some(&b'\n') {
return true;
}
}
false
}这是一个小优化——当一次 TCP read 没读完整请求头(需要下次 read 补齐)时,hyper 会先扫描"最近追加的字节"看是否已经到达 \r\n\r\n(header 结束标志)。只有检测到这个标志才重新调 httparse 做完整解析;没检测到的话直接返回 Partial,避免每次 partial read 都让 httparse 跑完整遍。
对慢连接特别有效——想象一个 mobile 客户端一个字节一个字节发 header(理论上合法,实战可能存在),没有 fast-path 的话 hyper 要对每个字节跑完整 header parse。这个简单的扫描把慢连接场景的 CPU 成本降到可忽略。
11.6 Header 编码:两种大小写策略
细节之一:hyper 编码 HTTP/1 header 时,header name 的大小写怎么处理?
HTTP 协议规定大小写不敏感,但真实客户端对大小写敏感(IIS、一些老旧 lib)。hyper 提供两种策略:
- LowercaseWriter:所有 name 全小写(HTTP/2 兼容、更紧凑)。
- OrigCaseWriter:按照原始大小写(如果 parser 保留了)或者按照
title-case规则(content-type→Content-Type)。
源码里用 HeaderNameWriter trait 把两种策略抽象:
rust
// role.rs:528-580
trait HeaderNameWriter {
fn write_full_header_line(&mut self, dst: &mut Vec<u8>, line: &str, name_val: (HeaderName, &str));
fn write_header_name(&mut self, dst: &mut Vec<u8>, name: &HeaderName);
...
}
struct LowercaseWriter;
impl HeaderNameWriter for LowercaseWriter { ... }
struct OrigCaseWriter<'a> { ... }
impl HeaderNameWriter for OrigCaseWriter<'_> { ... }Builder 配置:
rust
hyper::server::conn::http1::Builder::new()
.preserve_header_case(true) // 用 OrigCaseWriter
.serve_connection(io, svc)生产服务一般不开 preserve_header_case——因为小写更紧凑(HTTP/2 兼容)、缓存命中率更好(规范化)。只有需要和老旧客户端/服务端兼容才开。
11.7 一次完整的 roundtrip
把这一章讲的全部组件串起来,看一次"请求进、响应出"的完整字节流:
1. TCP recv 到 BytesMut buffer
↓
2. is_complete_fast 扫描是否有 \r\n\r\n
↓ 有
3. httparse 解析 request line + headers (SIMD)
↓
4. 构造 http::Request<Incoming> + Decoder
↓
5. 交给 Service::call(req)
↓
6. user handler 处理返回 Response
↓
7. role::encode_headers 把 Response 写到 Vec<u8>
↓
8. Encoder (Chunked/Length) 处理 body
↓
9. writev 把 header_vec + chunked_size + body + crlf 一起写到 TCP socket
↓
10. body 结束后发送 final chunk10 步之间每一步都有挂起恢复点——调用方的 Service 慢、网络慢、客户端读得慢,都会让某一步挂在 Pending 上,waker 被正确注册,task 被正确唤醒。整个链条没有一处"轮询检查"、没有一处 busy wait。
这就是 Rust 对"异步 HTTP 服务"这件事最优雅的落地——协议逻辑 + IO 等待在同一个状态机上,通过 Future 的 Poll 模型优雅交织。
11.8 关联与对照
这一章读的内容有几个地方值得回溯:
- 卷四《Tokio 源码深度解析》第 8 章(I/O Driver):hyper 的
MemRead抽象最终底层是 tokio 的AsyncRead。TCP 字节怎么到 BytesMut 缓冲——依赖卷四讲过的 epoll/kqueue 事件机制。 - 卷三《Rust 编译器与运行时揭秘》第 9 章(async 状态机):ChunkedState 的状态机展开原理和
async fn的编译期状态机有同构性——只是 ChunkedState 是手写 enum,async fn 是编译器自动生成。二者都是 "状态 + 输入 → 下一状态 + 输出" 的 finite state machine。
对比 Go 的 net/http:Go 的 HTTP/1 parser(http1.go)用 bufio.Reader.ReadLine() 一行一行读——阻塞式、没有 SIMD、状态完全由 Go runtime 的 goroutine 栈隐式保存。Go 的优势是代码易读;Rust 的优势是可预测的 zero-copy + CPU 效率。两种哲学各有优劣——写 Go 的 HTTP server 1-2 天能读完源码,写 Rust 的要 1-2 周,但 Rust 的源码教会你的东西更多。
11.9 落到你键盘上
本章读完:
- 用
strace/ktrace观察一次 HTTP/1 请求:启动一个 hyper-based server,strace看它做了多少次read和write系统调用。你会看到典型请求就 2-3 次read、1-2 次write——这是 buffered I/O + vectored I/O 的收益。 - 读
proto/h1/decode.rs的ChunkedState::read_body:关注它如何处理"一次 read 跨多个 chunk"的边界——这是最容易出 bug 的地方。 - 实验一个 chunked response:用 curl
--raw模式看 hyper 发出的 chunked 字节。你会验证上面讲的 "size\r\n + data + \r\n" 格式。
下一章我们从 byte-level 爬升到 connection-level——读 conn.rs 和 dispatch.rs,看 HTTP/1 连接的顶层状态机如何把 Decoder / Encoder / Service 编织在一起。