Hyper 与 Tower:工业级 HTTP 栈
第11章 HTTP/1 wire:parser、encoder、chunked 编码
11.1 一个 TCP 包的变形记
假设你在浏览器里敲回车访问 http://example.com/foo。你看到的是页面——但这背后实际上是一段 ASCII 字节流飞向服务器:
GET /foo HTTP/1.1\r\n
Host: example.com\r\n
Accept: text/html\r\n
User-Agent: Mozilla/5.0 (...)\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-1.9.0/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 作者)自己做的一个独立小库,README 的自我介绍是"HTTP/1.x 的 push parser,不分配、不拷贝、快"——SIMD 加速是它在这个定位上的实现手段。
httparse 的接口不大:
let mut headers = [httparse::EMPTY_HEADER; 64];
let mut req = httparse::Request::new(&mut headers);
let status = req.parse(bytes)?;
match status {
httparse::Status::Complete(n) => { /* 消费 n 字节 */ }
httparse::Status::Partial => { /* 数据不足,等更多 */ }
}
给它一个字节切片,它告诉你是否能完成解析。如果能,返回解析位置(n 字节被消费);不能,返回 Partial 等下一次。
11.2.1 httparse 的公开 API 很小
httparse crate 作为 hyper 的底层依赖、公开 API 极其精简——核心大致只有:
Request::new(headers)/Request::parse(buf)/Request::parse_with_uninit_headersResponse::new(headers)/Response::parse(buf)ParserConfig(宽松模式开关、以及 hyper 真正调用的parse_request_with_uninit_headers)Status::Complete(n)/Status::PartialHeader/EMPTY_HEADER、parse_headers/parse_chunk_size、Errorenum
这一小组 symbol(外加 ParserConfig 上十几个宽松模式开关与解析方法)就是整个 HTTP/1 parser 的对外门面——内部可能有几千行 SIMD / Unicode / edge case 代码、但用户只需要理解这一把钥匙。
极简 API 的价值——
- 学习成本低——新 maintainer 接手 hyper、几分钟就能搞懂 httparse 接口
- stability 容易保证——API 少、breaking change 少、用户升级顺
- measured 测得准——所有热路径只有这几个入口、benchmark 结果可信
这和第 9 章 http crate 的克制(只定义数据模型、不做 I/O)以及 bytes crate 的极简 API(主要就是 Bytes / BytesMut 两个类型)一脉相承——Rust 生态的基础库普遍 API 极简、留出巨大空间给实现变化。
对比某些"功能丰富"的 C++ HTTP library(public API 几百个函数、各种 deprecated 的历史包袱)、Rust 小库的克制让组合式架构成为可能。hyper / tower / http / bytes / httparse ——每个都是小精悍的 crate、但它们联合起来是工业级 HTTP 能力。
这种生态协作比一个大库做所有事更健康——每个 crate 自己维护者负责、演进独立、失败隔离。
11.2.2 为什么 SIMD 重要
header 解析的热点在于字符扫描——寻找 :、\r、\n、校验"每个字节是 valid token char"等。朴素地用 for byte in bytes.iter() 每次 loop 处理一个字节——每个字节都是一次比较加一次分支。在高吞吐场景下,光 header 解析就能占掉可观的 CPU。
httparse 在扫描 URI 和 header value 时用 SIMD 指令(x86 上运行时检测 AVX2 / SSE4.2,ARM 上用 NEON)一次处理 16-32 字节——以 AVX2 为例,用 _mm256_cmpeq_epi8 等指令一次判断 32 个字节是否都落在合法字符范围内,_mm256_movemask_epi8 把结果压成一个位图,再用 trailing_zeros 找到第一个不合法的字节(通常就是 \r 或空格这类分隔符)。header name 走的是按 8 字节一组的 SWAR(寄存器内并行)扫描。逐字节扫描变成按向量宽度批量扫描——这是 hyper 在高 QPS 场景下 header 解析开销依然很低的重要原因。
SIMD 这块不是本专栏重点——但你需要知道"Hyper 的 header 解析不是普通代码",它站在一个 SIMD 小引擎之上。httparse 的 SIMD 路径是 unsafe Rust + intrinsics,仓库里带有 fuzz 目标。如果你好奇,去 GitHub 看它的 src/simd/ 目录。
11.2.3 hyper 如何使用 httparse
// hyper-1.9.0/src/proto/h1/role.rs:137-210 Server::parse 摘录
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<RequestLine> {
// headers 数组:默认 100 个槽位放在栈上(SmallVec inline)、配置了 max_headers 就按配置堆分配
let mut headers_indices: SmallVec<[MaybeUninit<HeaderIndices>; DEFAULT_MAX_HEADERS]> = ...;
let mut headers: SmallVec<[MaybeUninit<httparse::Header<'_>>; DEFAULT_MAX_HEADERS]> = ...;
// 调用 httparse
let mut req = httparse::Request::new(&mut []);
let bytes = buf.as_ref();
match ctx.h1_parser_config.parse_request_with_uninit_headers(&mut req, bytes, &mut headers) {
Ok(httparse::Status::Complete(parsed_len)) => {
// 拿到 method / path / version / headers
// 记录 path 和每个 header 在 bytes 里的字节范围(record_header_indices)
// ... 后面再把字节范围翻译成 http:: 类型
}
Ok(httparse::Status::Partial) => return Ok(None), // 数据不足
Err(httparse::Error::Token) => return Err(/* Parse::Method 或 Parse::Uri */),
Err(err) => return Err(err.into()),
}
let slice = buf.split_to(len).freeze(); // 把请求头从缓冲切出来、变成 Bytes
// ...
}
hyper 拿到 httparse 的"字节范围"之后,用 BytesMut::split_to(len) 把请求头部分从缓冲切出来——这里得到一个独立的 Bytes。然后每个 HeaderValue 都是这个 Bytes 的子切片(通过 Bytes::slice() 零拷贝)——header value 只是 Bytes 里的 offset + len 引用。HeaderName 则走 HeaderName::from_bytes:标准头名直接映射成内置常量、不分配;自定义头名会复制出一份小写化的名字(一次分配)。
这再次印证了第 9 章的观点:bytes::Bytes 是整个 Rust HTTP 栈的零拷贝基础。从 TCP socket 读出字节到一个 BytesMut 缓冲,解析成 HeaderMap,传到 handler——header value 一路没有被复制过,只有自定义头名各拷了一份。
11.2.4 parse_with_uninit_headers 和零初始化规避
看 hyper 真实调用 httparse 的代码(hyper-1.9.0/src/proto/h1/role.rs:165-170):
let mut req = httparse::Request::new(&mut []);
let bytes = buf.as_ref();
match ctx.h1_parser_config.parse_request_with_uninit_headers(&mut req, bytes, &mut headers) {
注意是 parse_request_with_uninit_headers、不是普通的 parse。为什么要 "uninit"?——因为 100 个 httparse::Header 槽位的初始化本身在高频场景是有成本的——每个 Header 是一个 name 切片加一个 value 切片、把 100 个都置零就是一次 memset。源码上方的注释直接给了数字:不清零这块栈内存能省下 "a good ~5% on pipeline benchmarks"(hyper-1.9.0/src/proto/h1/role.rs:149-152)。
parse_request_with_uninit_headers 告诉 httparse "数组里的内容我不保证初始化过、你只读你写过的那些"——httparse 内部向 MaybeUninit 槽位写入、然后返回 "parse 成功、真的被写入了 N 个"(req.headers.len())。hyper 只读前 N 个(headers_indices[..headers_len] 上 assume_init_ref)、保证不读未初始化内存。
这和第 14 章 §14.5.2 讲的 "栈数组 vs 堆分配" 是配套的——既然默认走栈(smallvec_inline!)、那连初始化这 100 个槽位的写入都要省。每一步都不放过、才是 hyper 作为 Rust 工业库的姿态。
注意 MaybeUninit<T> 是一个零成本的类型层构造——它和 T 布局相同、编译出来没任何额外指令、只是告诉编译器 "这块内存可能未初始化、别假设它有合法值"。如果代码读没写过的槽位、是 UB——所以 httparse 内部必须严格按照 "返回 N 后只保证前 N 个可读" 的契约。
11.2.5 DEFAULT_MAX_HEADERS
const DEFAULT_MAX_HEADERS: usize = 100;
默认最多 100 个 header。超过这个数 httparse 会返回 TooManyHeaders 错误——hyper 把它映射成 Parse::TooLarge(hyper-1.9.0/src/error.rs:623)、服务端回 431 Request Header Fields Too Large(hyper-1.9.0/src/proto/h1/role.rs:472)。100 是一个相对宽松的默认——浏览器的普通请求只有一二十个 header,经过多层网关的内部请求会多一些。100 给了合理的冗余,同时防止攻击者塞几万个 header 让 server 分配大内存。
Hyper 的 http1::Builder::max_headers(n)(server 与 client 两侧都有)允许覆盖这个值。注意从 hyper-1.9.0/src/proto/h1/role.rs:153-162 可以看到、一旦显式配置了 max_headers、headers 数组就从栈上的 inline SmallVec 变成按配置容量的堆分配——调高它是有代价的;按自己的流量把它调低则能省内存。
11.3 body Decoder:三种长度语义
HTTP/1.1 允许三种"body 到哪里结束"的约定:
Content-Length: 12345→ 读 12345 字节就结束。Transfer-Encoding: chunked→ 按 chunk 格式读,最后一个 0 长度 chunk 表示结束。- 都没有(仅响应合法)→ 读到连接关闭为止。
Hyper 把这三种统一成 Decoder::Kind:
// hyper-1.9.0/src/proto/h1/decode.rs:36-66(省略了 Eof 上的长注释)
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 字节就完事。源码里对应的 decode 方法(hyper-1.9.0/src/proto/h1/decode.rs:144-172):
pub(crate) fn decode<R: MemRead>(&mut self, cx: &mut Context<'_>, body: &mut R)
-> Poll<Result<Frame<Bytes>, io::Error>>
{
match self.kind {
Length(ref mut remaining) => {
if *remaining == 0 {
Poll::Ready(Ok(Frame::data(Bytes::new())))
} else {
let to_read = *remaining as usize;
let buf = ready!(body.read_mem(cx, to_read))?;
let num = buf.as_ref().len() as u64;
if num > *remaining {
*remaining = 0;
} else if num == 0 {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::UnexpectedEof, IncompleteBody,
)));
} else {
*remaining -= num;
}
Poll::Ready(Ok(Frame::data(buf)))
}
}
...
}
}
核心逻辑:
remaining记录还需要读多少字节。- 每次
read_mem(cx, n)拉一块出来(可能一次只拉到一部分)。 - 扣减 remaining,构造
Frame::data(buf)返回。 - 读到 0 字节(对端在 body 没发完时就关了连接)→
UnexpectedEof+IncompleteBody。
注意 decode 的返回类型是 Poll<Result<Frame<Bytes>, io::Error>>、没有 Option——它自己不表达"流结束"。"结束"由旁边的 is_eof()(hyper-1.9.0/src/proto/h1/decode.rs:132-142)回答:
pub(crate) fn is_eof(&self) -> bool {
matches!(self.kind, Length(0) | Chunked { state: ChunkedState::End, .. } | Eof(true))
}
调用方 Conn::poll_read_body(hyper-1.9.0/src/proto/h1/conn.rs:363-425)每拿到一帧就问一次 decoder.is_eof():是——把读状态切到 Reading::KeepAlive、空帧就直接丢掉不往上送;否则把这一帧交给 body channel。所以 Length(0) 时返回的那个空 Frame::data(Bytes::new()) 永远到不了用户手里——它只是让 decode 有个东西可返回。
注意 read_mem 的返回值是 Bytes——不是 &[u8]。这是因为 hyper 的底层 buffer 可以"切出一块零拷贝的 Bytes"传给上层。Body decoder 读到的数据直接包装成 Frame 交出去,不复制字节。
11.3.2 Content-Length: 0:空 body 根本不进 Decoder
Content-Length: 0——合法的空 body——是最常见的 HTTP 请求形态(GET / HEAD / DELETE 通常没 body、204 / 304 响应必须没 body)。hyper 对它的处理不在 decode.rs 里、而在更上一层:role.rs 解析头部时把长度算成 DecodedLength::ZERO、Conn::read_head(hyper-1.9.0/src/proto/h1/conn.rs:303-308)看到 ZERO 就直接把读状态设为 Reading::KeepAlive、根本不构造 Reading::Body(Decoder):
if msg.decode == DecodedLength::ZERO {
if msg.expect_continue {
debug!("ignoring expect-continue since body is empty");
}
self.state.reading = Reading::KeepAlive;
...
}
看起来 trivial、实际上意义重大:
- GET / HEAD / DELETE 请求通常无 body——Content-Length 要么不存在要么是 0
- HTTP/1.1 spec 允许
Content-Length: 0显式声明 "我就是没 body" - 204 / 304 响应按规范没有 body(
hyper-1.9.0/src/proto/h1/role.rs:1267对这两个状态码直接返回DecodedLength::ZERO)
如果没有这条捷径、每个空 body 请求都要经过 "构造 Decoder → poll 一次 → 发现 is_eof → 切状态" 的完整流程。空 body 请求是最常见的请求形态,这段本不必要的状态流转省掉是划算的(本专栏没有专门测量它的开销)。
ZERO → KeepAlive 一个判断、把最常见的 HTTP 请求模式变成零 body 读取。这和《React 19 内核探秘》专栏第 6 章讲的 React 用 subtreeFlags & MutationMask 位检查跳过无 effect 子树是同样的工程直觉——最常见的 trivial case 必须有最短路径。
11.3.3 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:
// hyper-1.9.0/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:
// hyper-1.9.0/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.4 read_extension 对 LF-only 的显式拒绝
read_extension 的源码(hyper-1.9.0/src/proto/h1/decode.rs:430-460)里有一段微妙的分支:
fn read_extension<R: MemRead>(...) -> Poll<Result<ChunkedState, io::Error>> {
// We don't care about extensions really at all. Just ignore them.
// They "end" at the next CRLF.
//
// However, some implementations may not check for the CR, so to save
// them from themselves, we reject extensions containing plain LF as
// well.
match byte!(rdr, cx) {
b'\r' => Poll::Ready(Ok(ChunkedState::SizeLf)),
b'\n' => Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid chunk extension contains newline",
))),
_ => {
*extensions_cnt += 1;
if *extensions_cnt >= CHUNKED_EXTENSIONS_LIMIT {
Poll::Ready(Err(...))
} else {
Poll::Ready(Ok(ChunkedState::Extension))
}
}
}
}
RFC 9112 §7.1.1 给 extension 定了语法(;name=value,name 是 token、value 是 token 或 quoted-string),整行以 \r\n 结尾——裸 \r、\n 本来就不可能出现在合法的 extension 里。hyper 的注释承认:"We don't care about extensions really at all."——它并不按这套语法校验,只是把 \r 之前的字节统统 skip 掉。
但 b'\n' 被单独拒绝——返回 InvalidData error。为什么?注释说得很清楚:
some implementations may not check for the CR, so to save them from themselves, we reject extensions containing plain LF as well.
某些实现不严格检查 CR(只看 LF)——RFC 9112 §2.2 本身就允许接收方把单独的 LF 当作行结束符,于是这类实现会以为一个 含 LF 的 extension 已经结束了。如果 hyper 容忍 LF-in-extension,同一段字节在 hyper 和这类实现眼里的 chunk 边界就不一样——前后两跳对报文边界理解不一致,正是 HTTP request smuggling 的成因。
hyper 对 extension 内容本来很宽松,却在 LF 这一点上收紧——"为了救这些实现"。这是典型的 "对端可能有 bug、我不给他们出错的机会" 的防御性协议实现。
这种 "主动加 constraint 防止下游误解" 在网络协议实现里非常有价值——每一处"本可以宽松放过、但我们拒绝"的地方,背后往往是一个真实世界的兼容问题或安全漏洞。
11.3.5 read_body 里的 rem > usize::MAX as u64 保护
read_body(line 483-516)里有一段看似不可能触发的检查:
// cap remaining bytes at the max capacity of usize
let rem_cap = match *rem {
r if r > usize::MAX as u64 => usize::MAX,
r => r as usize,
};
rem 是 u64(chunk size 可以到 16 exabytes)、usize 在 32-bit 平台只有 4GB。如果一个 chunk 声明自己是 10GB、在 32-bit 目标上 rem as usize 就会静默截断——读到错误的字节数。
rem > usize::MAX as u64 的检查把 rem cap 到 usize::MAX——如果真的有这么大的 chunk、hyper 会分多次 read 慢慢啃。当然 32-bit 服务器处理 GB 级 chunk 的场景很罕见——但 hyper 作为通用库不能假设目标架构。
这种 "跨平台整数宽度" 的细节是系统编程的常见坑——不只 hyper、Linux kernel 的 ioctl、Rust 的 std::io::Read 都要小心。u64 → usize 的转换要提防截断——不过这条纪律在 hyper 里也没有处处执行:同一个文件里 Length 分支的 let to_read = *remaining as usize;(hyper-1.9.0/src/proto/h1/decode.rs:155)就没有这样的 guard。
hyper 在 64-bit 目标(绝大多数生产服务器)上、usize::MAX == u64::MAX、这个分支永远不会命中——代价是零。但 32-bit 嵌入式 / mobile / WASM 目标、这个 guard 就救了命。
11.3.6 几个工业级细节
源码里有几个常量和宏不能错过:
const CHUNKED_EXTENSIONS_LIMIT: u64 = 1024 * 16;
const TRAILER_LIMIT: usize = 1024 * 16;
chunk extension 和 trailer 都有 16KB 的上限。没这个上限,攻击者可以构造一个请求 1; <巨大 extension>; 0... 让 server 读 GB 级的字符串。
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! 宏在每一处做算术都用到了,体现了"绝不信任网络输入"的一贯态度。
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.7 read_size_lf 和 size == 0 的特殊跃迁
read_size_lf(line 461-481)在读完 size 后的 \n 时有一个关键分支:
fn read_size_lf<R: MemRead>(...) -> Poll<Result<ChunkedState, io::Error>> {
match byte!(rdr, cx) {
b'\n' => {
if size == 0 {
Poll::Ready(Ok(ChunkedState::EndCr)) // 终止块、跳转到 EndCr
} else {
Poll::Ready(Ok(ChunkedState::Body)) // 正常 chunk、读 body
}
}
_ => Poll::Ready(Err(...)),
}
}
size == 0 的特殊跃迁——零长度 chunk 是 chunked encoding 的终止信号(RFC 9112 §7.1)。它不是 "读 0 字节 body"、而是 "整个 body 流到此结束"。
如果没有这个分支、状态机会走到 Body 状态、试图 read 0 字节——而 read_body 把"读到 0 字节"当成对端提前断开、直接报 IncompleteBody;即便绕过这一点,BodyCr/BodyLf 吞掉紧跟的 \r\n 后又会回到 Start,把 trailer 和结束行当成下一个 chunk size 去解析。直接跳 EndCr 才是语义正确——size 行末尾的 \r\n 是 chunk header 的一部分(已被 SizeLf 消费)、而0 长度 chunk 之后的 trailer(可选)与最后那个 \r\n 是流结束的一部分(对应 EndCr → [Trailer → TrailerLf →] EndLf → End)。
这是 状态机设计里 "特殊情况特殊对待" 的典型——不能强行把 0-chunk 塞进通用 chunk 流程、否则 trailer 处理会错位。size == 0 → EndCr 这一个判断把"chunk 流的 body 阶段"和"chunk 流的终止阶段"明确分开。
11.3.8 为什么 BodyCr 读到的不是 CR 报 InvalidInput、读 body 短了报 UnexpectedEof
两个相似但语义不同的错误:
fn read_body_cr<R: MemRead>(...) -> Poll<...> {
match byte!(rdr, cx) {
b'\r' => Poll::Ready(Ok(ChunkedState::BodyLf)),
_ => Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid chunk body CR", // ← InvalidInput
))),
}
}
fn read_body<R: MemRead>(...) -> Poll<...> {
if count == 0 {
*rem = 0;
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::UnexpectedEof, // ← UnexpectedEof
IncompleteBody,
)));
}
// ...
}
两种 error kind 对应两种不同的协议错误:
InvalidInput——对端发了合法字节、但不符合协议。比如 chunk body 之后该是\r、结果对端发的是x——对端协议实现有 bug 或恶意。UnexpectedEof——对端把连接关了、我们还没读完。比如 chunk 声明自己 100 字节、对端发了 50 就关连接——要么对端崩了、要么中间网络有问题。
在 hyper 内部、两种错误走的是同一条路:Conn::poll_read_body 收到 Err(e) 后把读状态切到 Reading::Closed、并把错误原样送进 body channel(hyper-1.9.0/src/proto/h1/conn.rs:403-406)——此时响应头可能早已发出去、hyper 不会替用户回 400。区别在 body 的消费者:handler 从 Incoming 里拿到的 hyper::Error 带着这个 io::Error、可以按 kind 决定是记一条"对端协议违规"的告警、还是当成普通的"客户端提前断开"。
hyper 用 io::ErrorKind 区分 就让这两种场景在 io::Error 这一层就分流——调用方根据 kind 做不同反应。这种"把语义信息编进错误类型"的做法比"字符串匹配 error message"健壮得多——即使 message 文案变了、kind 保持一致。
11.3.9 CHUNKED_EXTENSIONS_LIMIT vs TRAILER_LIMIT 两个 16KB 常量的来源
decode.rs 的开头有两个 16KB 的常量:
const CHUNKED_EXTENSIONS_LIMIT: u64 = 1024 * 16;
const TRAILER_LIMIT: usize = 1024 * 16;
两个数字相同、但类型不同(一个 u64、一个 usize)、语义也不同——
CHUNKED_EXTENSIONS_LIMIT是 整个 body 的 extension 字节数总和上限(源码注释:"This limit is currently applied for the entire body, not per chunk")——防止攻击者塞几 MB 的 extension 让 server "读个字节扔个字节"消耗 CPU。TRAILER_LIMIT是 trailer 总字节数上限——防止 body 发完后塞大量 trailer header 让 server OOM。
为什么都是 16KB?——源码没有解释、只能从上下文推:
① 16KB 也是 HTTP/2 的 SETTINGS_MAX_FRAME_SIZE 默认值(第 16 章)——协议栈各层 threshold 保持一致、方便运维和调试。
② 16KB 足够正常用例——真实世界里 chunk extension 几乎没人用、典型 trailer 也只有 grpc-status + grpc-message 这几十字节——16KB 是两个数量级以上的冗余空间。
为什么 CHUNKED_EXTENSIONS_LIMIT 是 u64、TRAILER_LIMIT 是 usize?——
- extension 计数器
extensions_cnt每读一个 extension 字节加 1、和旁边的chunk_len一样是u64字段——常量跟着字段类型走 - trailer 是保存在
BytesMut里、比较的是trailers_buf.len()(put_u8!宏)——len()是 usize、直接用 usize 省一次 cast
这种 "类型跟着使用场景走" 是 Rust 源码里常见的 pragmatic 选择——不追求 "所有字节数都用 usize" 的统一、而是每个字段选最自然的类型。
11.3.10 Eof(bool):HTTP/1.0 的遗产
第三种 Decoder 是 Eof(bool)——读到连接关闭为止。这是 HTTP/1.0 时代的遗留做法(响应既不带 Transfer-Encoding、也不带 Content-Length),实际上HTTP/1.1 里只允许响应用,请求不能用(因为请求不能让 client 关连接来表示结束——server 无法区分 "client 故意关" 和 "client 还在发")。
bool 字段跟踪是否已看到 EOF:读到空切片就把它置为 true,此后 is_eof() 为真,Conn::poll_read_body 据此结束 body 流(和 §11.3.1 的 Length(0) 一样,decode 本身不返回 Option)。
源码注释里有一段罕见的规范引文(第 57-64 行):
// hyper-1.9.0/src/proto/h1/decode.rs:58
/// > 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 7230 §3.3.3 的直接引用(现行的 RFC 9112 §6.3 沿用了同样的文字)——hyper 把规范条文嵌在源码注释里,让读代码的人能直接比对。这种"源码即规范导航"的风格是工业级 RFC 实现的标志。读《MCP 协议设计与实现》专栏时也能在 TypeScript SDK 里看到类似做法——规范实现者把 spec 条款拷在代码旁边,让代码和规范形成双向索引。
11.3.11 Trailer 解析也通过 httparse + 独立缓冲
Chunked kind 的结构(hyper-1.9.0/src/proto/h1/decode.rs:38-66)里有:
Chunked {
state: ChunkedState,
chunk_len: u64,
extensions_cnt: u64,
trailers_buf: Option<BytesMut>, // ← 独立的 trailer 缓冲
trailers_cnt: usize,
h1_max_headers: Option<usize>,
h1_max_header_size: Option<usize>,
}
trailers_buf 是一个独立的 BytesMut——只在读到终止 chunk(size=0)之后开始积累 trailer 数据。为什么要独立缓冲?因为:
① trailer 的格式和 header 相同——可以直接复用 httparse 的 header parser。但 httparse 期望输入是一整段连续字节(包含终止 \r\n\r\n),不是每读一个字节给它一个字节。所以要积累完整、再一次性 parse。
② trailer 是流式出现的——可能第一次 read 拿到一半、第二次 read 才拿到 \r\n\r\n。trailers_buf 就是"攒 header 攒到能 parse 为止"的缓冲。
③ h1_max_headers / h1_max_header_size 限制 trailer 的数量和总大小——h1_max_headers 就是 Builder 上 max_headers 的配置,header 和 trailer 共用(没配则取默认 100);h1_max_header_size 在 1.9.0 里还是占位,Conn 构造 Decoder 时恒传 None(hyper-1.9.0/src/proto/h1/conn.rs:312、:320 的 TODO),于是 trailer 总字节数实际由 TRAILER_LIMIT(16KB)兜底。否则 trailer 可以被用作攻击——在 body 发完后塞无限 trailer 让 server OOM。
这就是为什么 chunked 的状态机有 13 个状态——不只是 chunk 本身的格式、还包括 trailer 的读取。Trailer 在 HTTP/1.1 里是 optional 的、但在 HTTP/2 和 gRPC 里是核心——grpc-status 这种元信息只能放 trailer。hyper 的 chunked decoder 正确处理 trailer,才能让这类依赖 trailer 的元信息在 HTTP/1 上也传得下来。
11.4 body Encoder:反向过程
// hyper-1.9.0/src/proto/h1/encode.rs:36-48
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) 的强制约束
// hyper-1.9.0/src/proto/h1/encode.rs:128-160 Encoder::encode 精简
let len = msg.remaining();
let kind = match self.kind {
Kind::Length(ref mut remaining) => {
if len as u64 > *remaining {
let limit = *remaining as usize;
*remaining = 0;
BufKind::Limited(msg.take(limit))
} else {
*remaining -= len as u64;
BufKind::Exact(msg)
}
}
...
};
msg.take(limit) 这一行保证"总共最多写 remaining 字节"——即使上层给了更多的数据,encoder 也只截取前 limit 字节。剩下的被丢弃。数据没超时走 Exact、原样交出去。
为什么这样做?因为 HTTP/1.1 严格规定 Content-Length 声明多少字节就必须有多少字节。如果声明 100 但实际发了 150——对端读到第 100 字节就认为这条消息结束了,后面 50 字节会被当成下一条消息,造成"HTTP desync 攻击"。通过 encoder 层的强制截断,hyper 堵住了"body 比声明的长"这条路。
注意一个例外:如果响应 body 自己的 size_hint 给出了确切长度、又和用户手写的 Content-Length 头不一致,Server::encode_headers 为了性能直接信任 body 的长度来构造 Encoder、头部照写用户的值(hyper-1.9.0/src/proto/h1/role.rs:693-723)——debug 构建会 assert! panic,release 构建则真的会发出"头写 3、body 发 5"的错位响应(hyper@=1.9.0 实测)。手写 Content-Length 时要自己保证它和 body 一致。
11.4.2 msg.take(amt) 后原 msg 还剩什么
msg.take(amt) 返回 "前 amt 字节"——那么后面的字节去哪了?
msg 是 impl Buf(bytes::Buf trait)。Buf 的 take(n) 方法按值接收 msg、把它包成一个 "限制视图" Take<B>——字节不复制、只是对外只暴露前 n 字节。这个视图在 encoder 的 BufKind::Limited 里被 wrap、最后交给底层 IO 做 write。
剩下的字节没有被写——它们随 Take 一起被丢弃,encoder 不报错;remaining 置 0 之后,Conn::write_body 把写状态切走,Dispatcher 发现 can_write_body() 为假就直接丢掉用户 body 的后续帧(hyper-1.9.0/src/proto/h1/dispatch.rs:364-371)——这就是 §11.4.1 讲的 "HTTP desync 防御":宁可静默截断,也不让多出的字节上线。
具体行为 hyper 源码里有分支:
- 如果用户实际写的 == 声明的长度——一切正常、
remaining归零、is_eof()为真、发完就结束 - 如果用户实际写的 > 声明的长度——encoder
take(remaining)截断、多余的字节被丢弃、remaining直接置 0;之后Conn::write_body看到encoder.is_eof()就把写状态切走、后续帧不会再被写 - 如果用户实际写的 < 声明的长度且用户 signal end(
poll_frame → None)——Encoder::end()(hyper-1.9.0/src/proto/h1/encode.rs:116-126)对Kind::Length(n)(n > 0)返回Err(NotEof(n))、Conn::end_body把写状态置为Writing::Closed并抛出Error::new_body_write_aborted()(hyper-1.9.0/src/proto/h1/conn.rs:774-802)——连接关闭、client 读到 EOF 时自己发现 body 不完整
第三种情况最 subtle——用户声明 Content-Length: 100、但只给了 50 字节就说 "我完了"。这是应用 bug、不是协议 bug。hyper 不能帮用户补 50 个空字节(那会污染语义)、也不会悄悄换成 chunked、只能通知 client "这个 response 不完整"——最直接的办法是关连接、让 client 的 Content-Length parser 失败(读到 100 字节前就 EOF)。
hyper 的哲学是 "用户声明了什么就按什么发"、错了就报错、不帮忙隐藏问题——让错误显现。
11.4.3 Chunked 的封装
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 上的 18 字节数组(64-bit 平台、见 §11.4.4)。B:用户的 body 数据(来自Body::poll_frame的 Frame)。StaticBuf:\r\n,两个字节的常量。
当 hyper 要把这段数据写到 socket 时,如果底层 IO 支持 vectored write(Buffered::new 据 is_write_vectored() 选 WriteStrategy::Queue,hyper-1.9.0/src/proto/h1/io.rs:60-64),就用 vectored write(writev 系统调用)一次性提交三个 slice——用户态不必先把它们拼进一块缓冲。不支持时(比如很多 TLS 流)走 Flatten 策略,几段数据会先拷进同一块写缓冲。这就是为什么 chunked encoding 的开销可以接近 Content-Length——额外开销只是 size 字符串(每 chunk 几字节)加一次 writev。
11.4.4 ChunkSize 的 stack-allocated 18 字节
ChunkSize 是 hyper encoder 用来临时构造 chunk size 字符串的工具。源码里它是一个固定大小的 on-stack 结构(hyper-1.9.0/src/proto/h1/encode.rs:331-357):
#[cfg(target_pointer_width = "64")]
const USIZE_BYTES: usize = 8;
// each byte will become 2 hex
const CHUNK_SIZE_MAX_BYTES: usize = USIZE_BYTES * 2; // 64-bit 上是 16
#[derive(Clone, Copy)]
struct ChunkSize {
bytes: [u8; CHUNK_SIZE_MAX_BYTES + 2], // ← 栈上的 18 字节:16 位十六进制 + \r\n
pos: u8,
len: u8,
}
impl ChunkSize {
fn new(len: usize) -> ChunkSize {
let mut size = ChunkSize { bytes: [0; CHUNK_SIZE_MAX_BYTES + 2], pos: 0, len: 0 };
write!(&mut size, "{:X}\r\n", len).expect("CHUNK_SIZE_MAX_BYTES should fit any usize");
size
}
}
18 字节从哪来?——chunk 长度是 usize、最大的 usize 在 16 进制下占 16 个字符(FFFFFFFFFFFFFFFF)、加上 \r\n 两字节——正好 18。32-bit 目标上 USIZE_BYTES = 4、数组就是 10 字节。数组大小按平台字宽推出来、所以 write! 后面那个 expect 永远不会触发——任何 usize 都装得下、hyper 不需要也不会把一个大 chunk 切成多个。
ChunkSize 自己实现了 Buf(remaining / chunk / advance 三个方法直接操作 pos / len)——所以它能和用户数据、尾部 \r\n 一起被 chain 成一个逻辑 Buf。
为什么不直接用 heap-allocated String?——因为 ChunkSize 每个 chunk 用一次、每秒几千个 chunk 就是几千次 malloc/free。栈数组是零分配的——fill 字节、用完释放时什么都不做(就是 stack frame 弹掉)。
这和第 14 章 §14.5.2 讲的 hyper 默认 header 数组栈分配是同一种优化思路——在热路径上消灭不必要的堆分配。
pos: u8 / len: u8——两个游标只需要表示 0..18、用 u8 就够。整个 ChunkSize 是 20 字节、Copy、在 Chain 里按值传递没有任何间接层。
11.4.5 BufKind enum 的设计:把不同 write 路径统一成一个类型
encoder 的 encode / end / encode_trailers 返回的 EncodedBuf<B> 内部是一个 BufKind<B> enum、把几种不同的 write 路径统一:
// hyper-1.9.0/src/proto/h1/encode.rs:52-58
enum BufKind<B> {
Exact(B),
Limited(Take<B>),
Chunked(Chain<Chain<ChunkSize, B>, StaticBuf>),
ChunkedEnd(StaticBuf),
Trailers(Chain<Chain<StaticBuf, Bytes>, StaticBuf>),
}
5 种 variant 对应 5 种写入模式——
- Exact——直接写 user 数据、不限制、不包装(close-delimited、以及 Length(n) 没超限的场景)
- Limited——写 user 数据但截断到 remaining(Length(n) 超限场景)
- Chunked——包装成 chunk(size + data + \r\n)
- ChunkedEnd——发终止 chunk(
0\r\n\r\n、由Encoder::end()产生) - Trailers——发 trailer 块(
0\r\n+ 序列化的 trailer +\r\n、由encode_trailers产生)
"没什么可写"不需要 variant——Encoder::end() 返回 Ok(None) 就表达了。
统一成 enum 的好处——EncodedBuf<B> 整体实现了 Buf(内部 match self.kind 转发 remaining / chunk / chunks_vectored / advance)、上层代码(Conn::write_body、hyper-1.9.0/src/proto/h1/conn.rs:711)只有一行:
self.io.buffer(encoder.encode(chunk));
Buffered::buffer 收到的是一个具体类型 EncodedBuf<B>、把它推进写队列、之后 flush 时(Queue 策略下)靠 chunks_vectored 把内部的几段拼成一组 IoSlice 做 vectored write。相比之下、如果不用 enum、encoder 就得返回 Box<dyn Buf>——每次分配一个 trait object、非常昂贵。
enum 让 vectored write 可以一次完成——因为每个 variant 内部已经是 Chain<Chain<..>> 或 Bytes、都实现了 Buf trait——可以直接传给 write_vectored。
这种 "把分支路径统一成 enum、dispatch 在 write 点集中" 的模式是 closed-type-set dispatch——性能远优于 Box<dyn Trait>、代码可读性也好。React 的 Fiber tag、hyper 的 KA 三态都用同一种思路——能用 enum 就不用 dyn。
11.4.6 Trailer 的编码
/// An Encoder for when Transfer-Encoding includes `chunked`.
Chunked(Option<Vec<HeaderName>>),
Option<Vec<HeaderName>> 是一个白名单——HTTP/1.1(RFC 9110 §6.5.1)要求发送方用 Trailer header 提前声明会出现哪些 trailer 字段。hyper 把用户在响应头里写的 Trailer: a, b 解析成这个 Vec(hyper-1.9.0/src/proto/h1/role.rs:842-880)、encode_trailers 时只放行名单里的字段、名单外的直接丢掉并打一条 debug 日志。
假如把一个 gRPC 风格、靠 trailer 携带状态的响应放到 HTTP/1 chunked 上,大致长这样(示意;gRPC 官方协议定义在 HTTP/2 上):
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\n
hyper 的 Server::encode_headers 在写完响应头之后、如果 encoder 是 chunked 且收集到了 Trailer 名单、就调 encoder.into_chunked_with_trailing_fields(allowed_trailer_fields)(hyper-1.9.0/src/proto/h1/role.rs:969-973)——trailer 白名单被编入 Encoder 的 chunked kind 里。
11.4.7 into_chunked_with_trailing_fields 的 builder 风格
hyper 的 encoder 提供 API into_chunked_with_trailing_fields(trailer_names: Vec<HeaderName>)——把一个普通的 "Chunked(None)" encoder 升级为 "Chunked(Some(whitelist))"。
这是消费 + 重建模式:
// hyper-1.9.0/src/proto/h1/encode.rs:80-88
pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec<HeaderName>) -> Encoder {
match self.kind {
Kind::Chunked(_) => Encoder {
kind: Kind::Chunked(Some(trailers)),
is_last: self.is_last,
},
_ => self,
}
}
self 而不是 &mut self——意味着调用后旧的 encoder 被 move 消费、返回一个新 encoder。这个模式比 &mut self + setter 更 idiomatic:
&mut self——原地修改,"升级前"和"升级后"是同一个变量,调用方在类型和所有权上都看不出这一步有没有做过self → Self——调 API 就必须拿到新 encoder 替换旧的、漏不掉
"消耗所有权 + 返回新实例" 是 Rust builder pattern 的精髓——保证用户必须完成配置才能得到可用的对象。注意非 chunked 的分支不 panic、而是把 self 原样还回去——对 Length / CloseDelimited 的响应来说 trailer 本来就发不出去、白名单无意义、悄悄忽略即可;真正的"拦截"发生在 encode_trailers 里:非 chunked 只打一条 debug 日志、返回 None。
这种模式在 reqwest 的 ClientBuilder、各种 pipeline 构造里普遍存在——让"构造到可用的路径"在类型层可追踪。
11.4.8 Option<Vec<HeaderName>> 作为 trailer 白名单的设计
§11.4.6 提到 Chunked variant 第一个泛型参数是 Option<Vec<HeaderName>>——这是 trailer 白名单。拆开看三层嵌套:
① Option——区分 "没声明 trailer" 和 "声明了但名单为空"——
None→ 响应头里没有Trailer字段、这个响应根本不打算发 trailer;用户事后再write_trailers会被encode_trailers丢掉("attempted to encode trailers, but the trailer header is not set")Some(vec![])→ 有Trailer字段、但里面没有一个能解析成合法HeaderName(hyper-1.9.0/src/proto/h1/role.rs:866-871的filter_map全部过滤掉了)Some(vec!["grpc-status", "grpc-message"])→ 确定的 trailer 名单
None vs Some(empty) 的语义区分让 encoder 能在日志里说清楚"是没声明、还是声明错了"。如果用单一 Vec 表达、无法区分这两种情况——就像 §14.1.2 讲的 Dur 用 Default / Configured 两态扩展 Option、这里是 Option 对单一 Vec 的扩展。
② 存 Vec<HeaderName>、用的时候再转 HashSet——
- 名单在
Server::encode_headers里是一边扫响应头一边extend出来的——Vec 是最自然的累积容器 - 真正查表在
encode_trailers(hyper-1.9.0/src/proto/h1/encode.rs:171):let allowed_set: HashSet<&HeaderName> = allowed_trailer_fields.iter().collect();——一个响应只建一次、之后每个 trailer 字段 O(1) 查 - trailer 白名单通常只有 1-5 个条目(grpc 就 2 个)——这个 HashSet 的构建成本本身也可以忽略
这是 "累积用 Vec、查找时再建索引" 的典型——数据结构按各阶段的访问模式分别选。
③ HeaderName 而不是 String——
HeaderName是 http crate 的类型、构造时就做了 case normalization(全部小写)——Trailer: Grpc-Status和 trailer 里的grpc-status能对上(role.rs注释:"HeaderName normalizes to lowercase for case-insensitive matching")- 直接存
String需要每次比较时 case-insensitive compare、太重 HeaderName比较对标准头是 enum tag 比较、对自定义头是已小写的字节比较——快
三层嵌套 Option<Vec<HeaderName>> 每一层的选择都是针对具体使用模式的最优解。这种精雕细琢的类型组合在 Rust 里非常常见——类型选错会体现在性能、正确性、语义多个维度上。
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 把这两种抽象出来:
// hyper-1.9.0/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 的顶层也是泛型:
// hyper-1.9.0/src/server/conn/http1.rs:24-44
type Http1Dispatcher<T, B, S> = proto::h1::Dispatcher<
proto::h1::dispatch::Server<S, IncomingBody>,
B,
T,
proto::ServerTransaction, // = proto::h1::role::Server
>;
pub struct Connection<T, S> where S: HttpService<IncomingBody> {
conn: Http1Dispatcher<T, S::ResBody, S>,
// T = 底层 IO,S = user service,ServerTransaction = Server impl
}
这种"通过 type state 分离服务端/客户端逻辑"的做法是 Rust 类型系统的典型用法——两条代码路径在类型层被分开,但共享所有实现模板。不用动态 dispatch、不用 if/else branching——编译期单态化完成 "同一段代码服务两个场景" 的工程目标。
11.5.1 pub(crate) enum Server {} 空 enum 作为 type tag
Server 和 Client 在 hyper 里是空 enum:
pub(crate) enum Server {}
pub(crate) enum Client {}
没有 variant、不能实例化。它们唯一的作用是作为 Http1Transaction trait 的类型参数——让编译器在 monomorphize 时选择不同的 impl。
为什么用空 enum 而不是空 struct?——空 struct struct Server; 可以实例化(虽然实例没有任何数据)、可能被人误认为 "有对象语义"。空 enum 根本不能实例化——强制它只能作为 type marker 出现——编译期保证它不承载 runtime 状态。
这种 enum ZeroVariant {} 在 Rust 里叫 uninhabited type(无居民类型)、配合 ! never type 是类型系统里表达"不可能"的原语。如果你写:
fn never_returns() -> Server {
todo!() // panic 或 loop 永远不 return
}
编译器知道这个函数永远不返回——可以做更多优化。
hyper 利用零大小的类型参数实现 trait-based dispatch、单态化后完全消除任何运行时存在——生成的机器码里看不到 Server/Client 的任何痕迹、只有"Server::parse 对应的那段代码"和"Client::parse 对应的那段代码"。
这就是 Rust zero-cost abstraction 的具体体现——你写的 type-level code 在 runtime 是消失的。和 TypeScript 的 enum Foo {}(运行时有对象)、Java 的泛型(擦除但保留 cast)完全不同——Rust 的空 enum 是真的编译后为零。
11.5.2 is_complete_fast:提前判断
// hyper-1.9.0/src/proto/h1/role.rs:94-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.5.3 saturating_sub(3) 在 is_complete_fast 里的边界处理
is_complete_fast 的第一行(hyper-1.9.0/src/proto/h1/role.rs:95):
let start = prev_len.saturating_sub(3);
let bytes = &bytes[start..];
saturating_sub(3) 意思是 "减 3、但不小于 0"。prev_len 如果是 0、1、2——减完变成 negative——普通的 usize - 3 会 underflow(debug 构建直接 panic、release 构建回绕成一个巨大的数)。saturating_sub 把这个 case 处理成 0。
为什么是 3?——因为我们要检查 "最后 4 个字节里有没有 \r\n\r\n"——从最后 4 字节的起点开始扫描、就是 len - 4。但 "新追加的字节" 是 prev_len 之后的部分、要算上 prev_len 之前的 3 个字节(覆盖跨边界情况:上一次 read 结尾是 \r\n\r、这次 read 开头是 \n、四字节组合起来才是 \r\n\r\n)。
所以要检查的范围是 [prev_len - 3, new_len]——回退 3 字节、加上新读到的所有字节。
这种 "跨 chunk 边界的标记检测" 是流式 parser 的通用模式——任何 "N 字节的定长 marker" 都要 回退 N-1 字节才能覆盖所有跨边界情况。hyper 这里处理得很严谨——saturating_sub 保证 prev_len < 3 时也不 underflow。
11.6 Header 编码:两种大小写策略
细节之一:hyper 编码 HTTP/1 header 时,header name 的大小写怎么处理?
HTTP 协议规定大小写不敏感,但有些真实实现对大小写敏感(一些老旧的客户端和服务端)。hyper 提供两种策略:
- LowercaseWriter:所有 name 全小写(和 HTTP/2 要求的小写一致)。
- OrigCaseWriter:按照原始大小写(如果 parser 保留了)或者按照
title-case规则(content-type→Content-Type)。
源码里用 HeaderNameWriter trait 把两种策略抽象:
// role.rs:989-1003(trait)、530-620(两个 impl、分别定义在 Server::encode_headers_with_lower_case / encode_headers_with_original_case 函数体内)
trait HeaderNameWriter {
fn write_full_header_line(&mut self, dst: &mut Vec<u8>, line: &str, name_value_pair: (HeaderName, &str));
fn write_header_name_with_colon(&mut self, dst: &mut Vec<u8>, name_with_colon: &str, name: HeaderName);
fn write_header_name(&mut self, dst: &mut Vec<u8>, name: &HeaderName);
}
struct LowercaseWriter;
impl HeaderNameWriter for LowercaseWriter { ... }
struct OrigCaseWriter<'map> {
map: &'map HeaderCaseMap,
current: Option<(HeaderName, ValueIter<'map, Bytes>)>,
title_case_headers: bool,
}
impl HeaderNameWriter for OrigCaseWriter<'_> { ... }
Builder 配置:
hyper::server::conn::http1::Builder::new()
.preserve_header_case(true) // 解析时记下原始大小写;响应带着这份记录时用 OrigCaseWriter
.serve_connection(io, svc)
生产服务一般不开 preserve_header_case——它要在解析每个 header 时多维护一份原始大小写映射(见下一节)。只有需要和老旧客户端/服务端兼容才开。
11.6.1 OrigCaseWriter 的 per-header-name 查找
OrigCaseWriter 的实现需要"写某个 header 时按原始 case"——但 HeaderName 在内部是统一 lowercased 的。这个信息从哪里找?
答案是 hyper 在 parse 时额外维护一个 "原始 case 映射"(hyper-1.9.0/src/ext/mod.rs:161):
pub(crate) struct HeaderCaseMap(HeaderMap<Bytes>);
// 小写化 key → 每一次出现时的原始字节(Bytes 切片)
它就是一个 HeaderMap<Bytes>——和第 9 章讲的 HeaderMap 是同一个 multimap、只是 value 从 HeaderValue 换成了指向原始缓冲的 Bytes。一个请求里 Content-Type 出现 3 次、原始 case 分别是 "Content-Type"、"content-type"、"CONTENT-TYPE"——parse 时 append 三次、Map 里这个 key 下会有 3 个 Bytes。写回时 OrigCaseWriter 用 get_all_internal 拿到迭代器、按出现顺序依次取用(current: Option<(HeaderName, ValueIter<Bytes>)> 字段就是在跟踪"这个 name 用到第几个了")。
为什么要 multimap 而不是每个 name 一个 Bytes?——因为header 可以重复(比如 Set-Cookie 有多个)、每个实例可能有不同 case。要保持完全 "preserve" 必须记住每一个。
代价是 parse 阶段每个 header 多一次 HeaderCaseMap::append(hyper-1.9.0/src/proto/h1/role.rs:319-320、只在 ctx.preserve_header_case 为真时执行)、以及 Request 的 extensions 里多挂一个对象(hyper-1.9.0/src/proto/h1/role.rs:338-339)。默认关闭 preserve_header_case、就是为了避这个开销。
这个功能的存在让 hyper 能够 proxy 某些对大小写敏感的老旧后端——如果 hyper 作为 proxy 中间层把 header case 归一化了、后端可能认不出来。开 preserve_header_case 就让 proxy 透明——客户端怎么写、upstream 就收到怎么写。
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 chunk
10 步之间每一步都有挂起恢复点——调用方的 Service 慢、网络慢、客户端读得慢,都会让某一步挂在 Pending 上,waker 被正确注册,task 被正确唤醒。整个链条没有一处"轮询检查"、没有一处 busy wait。
这就是 Rust 对"异步 HTTP 服务"这件事最优雅的落地——协议逻辑 + IO 等待在同一个状态机上,通过 Future 的 Poll 模型优雅交织。
11.8 roundtrip 里三个"看不见的 Bytes 流转"
§11.7 那 10 步里、有三次 Bytes 流转值得特别关注、都是零拷贝精髓:
① TCP → BytesMut——kernel 通过 read(fd, buf, len) 把字节拷到用户空间的 BytesMut。这一次拷贝无法避免——kernel/userspace 边界必须过。BytesMut 内部是一块引用计数的共享缓冲、append 是 O(1) amortized。
② BytesMut → Bytes (通过 split_to)——httparse 告诉 hyper "这段字节范围是 header"、hyper buf.split_to(n) 把前 n 字节从 BytesMut 分离成独立 Bytes——零拷贝、只是切分一个 Arc 引用。原 BytesMut 继续指向后面的字节(即将是 body)、新 Bytes 指向被切出的前面。
③ Bytes → HeaderMap——HeaderValue 是 Bytes 的子切片(slice.slice(header.value.0..header.value.1))、标准 HeaderName 直接是 enum 常量、自定义的才拷一份小写化的名字。再次零拷贝——Bytes::slice 只是保存一个 ptr + len、共享同一块引用计数缓冲。
整个从 TCP 字节到 HeaderMap 的变换、header value 只经历 1 次物理 memcpy(TCP → 用户空间);例外是自定义头名,各拷了一份小写副本。之后的 "切分、解析、传递" 基本都是 ref-count 游戏——字节本身一直在原地。
发送路径略有不同——响应头要被序列化进 hyper 的头部写缓冲(一块 Vec<u8>,这里有一次用户态拷贝);body 字节则以 Bytes 的形式(零拷贝、ref-count+1)传给 encoder、在 Queue 写策略下通过 vectored write 和头部缓冲一起交给 kernel——body 又是 1 次物理 memcpy(用户空间 → kernel)。
body 字节在整个 roundtrip 里只在两个 kernel 边界各拷一次。这就是 hyper 吞吐的秘密——数据本身几乎不动、只有 metadata 在 CPU 里流转。
这个成就不是 hyper 一家的——是 bytes crate + http crate + hyper 三者联合 的结果。bytes 提供零拷贝切分、http 提供类型安全的 header 存储、hyper 把它们编织成协议实现。这种"多个小库各司其职、组合成工业级能力"是 Rust 生态的美学——和《Vite 设计与实现》专栏第 15 章讲的 Module Runner / DevEnvironment / fetchModule 三层协作是同样的生态模式。
11.9 关联与对照
这一章读的内容有几个地方值得回溯:
- 《Tokio 源码深度解析》专栏第 8 章(I/O Driver):hyper 的
MemRead抽象最终底层是 tokio 的AsyncRead(经 hyper 的Readtrait 适配)。TCP 字节怎么到 BytesMut 缓冲——依赖那里讲过的 epoll/kqueue 事件机制。 - 《Rust 编译器与运行时揭秘》专栏第 9 章(async 状态机):ChunkedState 的状态机展开原理和
async fn的编译期状态机有同构性——只是 ChunkedState 是手写 enum,async fn 是编译器自动生成。二者都是 "状态 + 输入 → 下一状态 + 输出" 的 finite state machine。
对比 Go 的 net/http:Go 的 HTTP/1 头部解析走 net/textproto 的 Reader.ReadLine / ReadMIMEHeader——一行一行读、阻塞式、没有 SIMD、状态完全由 Go runtime 的 goroutine 栈隐式保存。Go 的优势是代码易读;Rust 的优势是可预测的 zero-copy + CPU 效率。两种哲学各有优劣——Go 的源码读起来快得多,但 Rust 的源码教会你的东西更多。
11.10 本章与《React 19 内核探秘》专栏第 6 章 / 《LangGraph 设计与实现》专栏第 14 章的呼应
与《React 19 内核探秘》专栏第 6 章(Commit 阶段)的呼应——React Commit 的 subtreeFlags 位标记和 hyper Decoder 的 ChunkedState enum 都是在 enum 或位上压缩状态机——React 压在 Fiber 的 flags 里、hyper 压在 ChunkedState 里。两者都追求"状态位本身成为快路径判断的原子单位"——避免 if-else 链的长路径。
与《LangGraph 设计与实现》专栏第 14 章(Runtime)的呼应——LangGraph 的 Runtime 用 execution_info is None 作为"未准备"状态的天然标记、hyper Decoder 用 Length(0) 作为"已完成"的天然标记——两者都不维护独立的 "is_done" 布尔、而是让数据字段的特殊值承担 "完成" 语义。
与《Vite 设计与实现》专栏第 15 章的呼应——vite 的 concurrentModuleNodePromises 去重 + ensureBuiltins 一次性协商、hyper 的 Http1Transaction trait 做单态分发 + 空 enum type-tag——都是 "启动期付成本、稳态零开销" 的实现。跨语言跨领域的同一种直觉。
11.11 本章收束的八条工程原则
① 零拷贝从 kernel 边界开始(§11.2.3, §11.8)——body 字节全链条只有 kernel 边界上 1 次 read + 1 次 write 的物理拷贝。
② SIMD 在热路径上(§11.2.2)——URI 与 header value 扫描用 AVX2 / SSE4.2 / NEON 一次处理 16-32 字节。
③ 栈分配 + MaybeUninit(§11.2.4)——100 个 header 槽位在栈上(inline SmallVec)、parse_request_with_uninit_headers 避免 memset。
④ DecodedLength::ZERO / size==0 fast-path(§11.3.2, §11.3.7)——最常见的 trivial case 必须最短路径。
⑤ 在宽松处主动收紧(§11.3.4)——拒绝 LF-in-extension 防御下游 buggy 实现。
⑥ io::ErrorKind 承载协议语义(§11.3.8)——InvalidInput vs UnexpectedEof 决定上层不同反应。
⑦ 空 enum 做 type tag(§11.5.1)——零大小、零运行时、纯编译期分发。
⑧ 跨 chunk 边界回退 N-1(§11.5.3)——saturating_sub 保证边界检测正确且不 underflow。
这八条加起来就是 hyper HTTP/1 wire 层的密度——每一条都 narrow、合起来就是工业级 HTTP parser 的基线。
11.12 落到你键盘上
本章读完:
- 用
strace/ktrace观察一次 HTTP/1 请求:启动一个 hyper-based server,strace看它做了多少次读写系统调用。用 hyper@=1.9.0 + curl 发一个小请求实测:服务端一次recvfrom读到整个请求、一次writev把响应头和 body 两段一起写出,连接关闭时再有一次返回 0 的recvfrom——这是 buffered I/O + vectored I/O 的收益。 - 读
hyper-1.9.0/src/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 编织在一起。
11.13 从 byte-level 到 state-machine 的抽象阶梯
本章聚焦 字节级解析,下一章会进入 连接级状态机。两者之间有一条清晰的抽象阶梯:
字节流 (TCP)
↓ [httparse SIMD]
HeaderMap + Decoder/Encoder 抽象 ← 本章
↓ [Conn 状态机]
Request<Incoming> / Response<Body> ← 下一章
↓ [Dispatcher]
Service::call(req) / Future<Response>
↓ [user handler]
业务逻辑
每一层把下层的复杂性封装成上层看到的干净接口——
- TCP socket 字节 → httparse 的
Status::Complete/Partialenum - httparse 的结果 →
http::Request<Incoming>类型 - Incoming body →
Frame流 - Frame 流 → 用户看到的
Body::poll_frame
每一层只做一件事、但做得非常细致——本章展示了 "字节流 → Request" 的那一层、揭示了这一层有 13 状态机、两种 case 策略、2 次 kernel 拷贝、三种 io::ErrorKind(InvalidInput / InvalidData / UnexpectedEof)。这种单一层次内的密度是 Rust HTTP 生态的特征——抽象清晰但不廉价——每一层都把自己该做的工程细节做到极致。
这也是阅读 hyper 源码最有收获的地方——你不会被一堆混乱的逻辑淹死(因为层次分得清)、但每一层都够深(因为所有细节都可追踪)。这种深度 vs 广度的平衡是工业级框架和学习用框架的核心区别。
11.14 跨章节重复出现的几个模式
读到这一章、你应该发现本专栏和其他几个专栏里已经反复出现了几个跨章节的设计模式——它们在 hyper HTTP/1 wire 层再次呈现:
① 位标记 + fast-check——《React 19 内核探秘》专栏第 6 章的 Fiber flags、本章 is_complete_fast——都在 "能用位或简单扫描快速判断时、不走复杂路径"。
② 栈分配优于堆——本章 100 个 header 槽位的 inline SmallVec、第 14 章 header 数组、第 16 章 Window(i32) 作为 copy type——热路径绝不分配堆。
③ enum 比 Box<dyn Trait> 轻——本章 Decoder::Kind 和 BufKind、《React 19 内核探秘》专栏第 6 章的 Fiber tag、第 16 章 FlowControl——closed set 用 enum、open set 才用 dyn。
④ typed wrapper 防误用——第 16 章 Window(i32)、本章 ChunkSize(18 字节栈结构)——不让 primitive 直接流转。
⑤ kernel 边界最多一次 memcpy——本章的 TCP → BytesMut——cross-boundary I/O 必须 minimal。
⑥ 基础库 API 极简——本章 httparse 十来个 symbol、第 16 章 FlowControl 269 行、bytes 的 Bytes/BytesMut——复杂不是能力、简洁才是。
这六条在 hyper / tokio / h2 / bytes / httparse / tower 里反复浮现——是 Rust 系统库的共同审美:位标记优于标志位 struct、栈数组优于 Vec、enum 优于 dyn、newtype 优于 primitive、跨 kernel 边界只拷一次、核心 API 极简。下次看到新库时可以用这六条当 checklist——任何一条严重违反,要么是作者有意打破(值得深究原因),要么是这个库还不成熟。