Skip to content

第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 / 你的业务代码。翻译的过程分三步:

  1. 解析 request lineGET /foo HTTP/1.1method=GET, uri=/foo, version=HTTP/1.1
  2. 解析 headers:每行 Name: Value\r\nHeaderMap
  3. 准备 body decoder:根据 Content-LengthTransfer-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 到哪里结束"的约定:

  1. Content-Length: 12345 → 读 12345 字节就结束。
  2. Transfer-Encoding: chunked → 按 chunk 格式读,最后一个 0 长度 chunk 表示结束。
  3. 都没有(仅响应合法)→ 读到连接关闭为止。

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

三段分别是:

  1. ChunkSize:chunk size 的 hex 字符串 + \r\n,写在 stack 上的 15 字节数组。
  2. B:用户的 body 数据(来自 Body::poll_frame 的 Frame)。
  3. StaticBuf\r\n,两个字节的常量。

当 hyper 要把这段数据写到 socket 时,用 vectored writewritev 系统调用)一次性提交三个 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\n

hyper 的 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.1Status 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,两个对称的 implServerClient 是两个空 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 提供两种策略:

  1. LowercaseWriter:所有 name 全小写(HTTP/2 兼容、更紧凑)。
  2. OrigCaseWriter:按照原始大小写(如果 parser 保留了)或者按照 title-case 规则(content-typeContent-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 chunk

10 步之间每一步都有挂起恢复点——调用方的 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 看它做了多少次 readwrite 系统调用。你会看到典型请求就 2-3 次 read、1-2 次 write——这是 buffered I/O + vectored I/O 的收益。
  • proto/h1/decode.rsChunkedState::read_body:关注它如何处理"一次 read 跨多个 chunk"的边界——这是最容易出 bug 的地方。
  • 实验一个 chunked response:用 curl --raw 模式看 hyper 发出的 chunked 字节。你会验证上面讲的 "size\r\n + data + \r\n" 格式。

下一章我们从 byte-level 爬升到 connection-level——读 conn.rsdispatch.rs,看 HTTP/1 连接的顶层状态机如何把 Decoder / Encoder / Service 编织在一起。

基于 VitePress 构建