Hyper 与 Tower:工业级 HTTP 栈

第9章 http crate:Request / Response / HeaderMap 的少分配设计

作者 杨艺韬 · 7,420 字 · 发布于 · 更新于

9.1 整个 Rust HTTP 生态的"公共语言"

如果你打开任何一个 Rust 后端项目的 Cargo.lock——无论它是 reqwest 的客户端、是 Axum 的服务端、是 Tonic 的 gRPC、是 tower-http 的中间件——你都会看到同一个包:

[[package]]
name = "http"
version = "1.4.0"

这是 Rust HTTP 世界里最基础的一块砖:一个不包含任何网络 I/O 的纯数据模型库。它定义的东西只有:

  • Request<T> / Response<T> / Parts / Builder
  • HeaderMap<HeaderValue> / HeaderName / HeaderValue
  • Method / StatusCode / Uri / Version
  • Extensions

src/ 下加起来一万四千多行源码(含测试),没有任何异步逻辑、没有任何 TCP 代码、没有任何解析器——它就是HTTP 的"类型语言"。hyper、reqwest、tonic、axum、tower-http 都以这套类型为基础交换数据(actix-web 是例外:它有自己的请求 / 响应类型,依赖的还是 http 0.2)。

这种"data model 独立 crate"的生态结构,在 Rust 之外并不常见:

  • Node.js 里的 HTTP 请求是 http 模块自己的 IncomingMessage,没有独立的类型库。
  • Go 的 net/http.Request 是和服务器实现强绑定的——它放在 net/http 标准库里。
  • Java 里有 Servlet 规范定义 HttpServletRequest,但它和容器绑定。

Rust 把数据模型彻底独立出来——同一个 http::Request<B>,可以从 hyper 收到、用 tower 过一遍中间件、交给 axum handler 处理。每一层操作的都是同一个类型,head 不需要逐字段转换、不需要克隆,最多用 map 换一下 body 类型。

这一章我们把 http 1.4.0(commit 29dd307)逐层拆开——看这套"公共类型"是怎么被设计成既正确、又少分配、又可扩展的。

9.2 Request<T>:body 为什么是泛型

// http-1.4.0/src/request.rs:151-155
#[derive(Clone)]
pub struct Request<T> {
    head: Parts,
    body: T,
}

// 同文件 :161-179
#[derive(Clone)]
pub struct Parts {
    pub method: Method,
    pub uri: Uri,
    pub version: Version,
    pub headers: HeaderMap<HeaderValue>,
    pub extensions: Extensions,
    _priv: (),
}

先注意一个最显眼的决定:Request<T> 中的 T 是 body 的类型。

T 不是约束到任何 trait——可以是 String、Vec<u8>、Bytes、()、甚至 Incoming(hyper 接收到的 body 流)、BoxBody(类型擦除的 body 流)。http crate 本身没有 Body trait——Body trait 在独立的 http-body crate 里(下一章讲)。

这个看似怪异的设计是有意的解耦:

  • http crate 表达"HTTP 请求的形状"——方法、URI、版本、头部。body 是什么类型与 HTTP 协议没关系——它是调用方决定的东西。
  • http-body crate 表达"body 如何作为 frame 流被读出来"。

这种拆分让不同的场景可以自由用同一个 Request 类型,body 类型只是一个标签:

// 构造时 body 是字符串
let req: Request<String> = Request::builder()
    .uri("/foo")
    .body("hello".to_string())
    .unwrap();

// 过中间件时 body 变成 hyper 的流式 Incoming
let req: Request<Incoming> = ...;

// 发送前 body 变成类型擦除的 BoxBody
let req: Request<BoxBody<Bytes, Error>> = req.map(|b| BoxBody::new(b));

注意 req.map(fn) 这个方法——Request<T> 提供了一个 .map(),能在不改变 head 的情况下变换 body 类型。这也是为什么 Parts 是一个独立 struct:head 可以独立于 body 存在(into_parts() / from_parts()),让中间件在重组 body 的同时完整保留所有 header / method / uri / extensions。

9.2.1 _priv: () 的作用

看这一行:

_priv: (),

零大小、私有、看起来没用——但它是 API 稳定性的守护者。

Rust 规则:一个 struct 如果所有字段都是 pub,下游可以用 struct literal Parts { method, uri, ... } 直接构造。但 http 的作者想保留"未来新增 Parts 字段"的权力——如果他们真的把 method / uri / version / headers / extensions 全设 pub,之后添加一个 is_secure: bool 字段,下游所有 Parts { method, uri, version, headers, extensions } 都会编译失败——破坏性变更。

解决方案:加一个 private 的零大小字段。下游无法用 struct literal 构造(因为构造时必须给所有字段赋值,而 _priv 是 private)——只能先得到一个 Request(Request::new(...) 或 Request::builder()...body(...)),再用 into_parts() 拿到 Parts。这样作者未来添加字段就不算破坏性变更。

这个技巧在 Rust 生态很常见——http 自己的错误类型、tokio-util 的 FramedParts(_priv)、tower 的 Identity 等(_p)都用这招;hyper 则更多改用 #[non_exhaustive] 达到同样目的。如果你在自己的 crate 里想"导出一个可读取字段的 struct,但控制构造入口",这就是标准做法。

9.2.2 Builder 模式与 Result 短路

// http-1.4.0/src/request.rs:186-188
pub struct Builder {
    inner: Result<Parts>,
}

Builder 内部存的是 Result<Parts>。为什么?

因为 builder 链式调用里每一步都可能失败——.uri("not a valid uri") 会解析失败、.header("bad name", "value") 会校验失败。如果每步都返回 Result,你就得这样写:

// 丑的版本
let req = Request::builder()?
    .uri("...")?
    .header("X-Foo", "bar")?
    .body("..");

http crate 的作者选了另一条路——把 Result 藏在 Builder 内部。链上任何一步失败,Result 变成 Err,后续所有操作变成"no-op"(因为 Err 上再 set_header 仍是 Err)。最终 .body(b) 返回 Result<Request<T>>——错误在这里一次性返回。

// 美的版本
let req = Request::builder()
    .uri("...")
    .header("X-Foo", "bar")
    .body("..")?;    // 一次 ? 处理全部

这是 Rust 中 builder 模式处理错误的惯用形式——把中间状态的 Result 延迟到 terminal 方法统一返回。《Tokio 源码深度解析》专栏第 16 章讨论的 spawn_blocking 的 JoinHandle——Tokio 里也有类似的"延迟错误到 await 点"设计。

9.3 HeaderMap:专为 HTTP 优化的 multimap

HeaderMap<HeaderValue> 是 http crate 里最复杂也最有工程味的结构。整个文件(http-1.4.0/src/header/map.rs)接近 4000 行——几乎是一个完整的哈希表实现。

9.3.1 为什么不用 HashMap

朴素想法:header 就是 "name → value" 映射,用 HashMap<HeaderName, HeaderValue> 不就行了?答案是不行,原因有四条。

第一,HTTP 允许同一个 name 对应多个 value。最常见的例子是 Set-Cookie——一个响应可以有多个 Set-Cookie 头。HashMap 需要用 HashMap<HeaderName, Vec<HeaderValue>> 来表达,但这样即使只有一个值也要分配一个 Vec——内存浪费。

第二,header 数量一般很小(通常是几个到几十个)。标准 HashMap 默认用 SipHash-1-3,抗碰撞但对这种短键偏慢;HeaderMap 默认用更快的 FNV 哈希,哈希表 indices 的每个槽位只存 u16 索引加 u16 哈希(4 字节),一条 cache line 能装下更多槽位。

第三,需要抵御哈希碰撞攻击。默认的 FNV 没有随机种子,攻击者可以构造出大量哈希到同一 bucket 的 header name——HeaderMap 实现了自适应哈希:插入时一旦位移或探测距离超过阈值(DISPLACEMENT_THRESHOLD = 128、FORWARD_SHIFT_THRESHOLD = 512),danger 从 Green 转成 Yellow;下一次插入前若负载率不低于 1/5(LOAD_FACTOR_THRESHOLD = 5)就把容量翻倍并回到 Green,否则转成 Red(RandomState),改用带随机种子的 SipHash 把整张表重建(http-1.4.0/src/header/map.rs:352-374、:1738-1763、:3654-3675)。

第四,需要支持"按 Name 查任意一个 value" 和 "按 Name 迭代所有 value"两种接口,HashMap 的 Vec<V> 方案意味着查单个值都要先拿 Vec 再取首元素——多一次间接。

源码注释讲了第二条里的 Robin Hood 哈希:

// http-1.4.0/src/header/map.rs:26-30
/// The internal implementation is optimized for common usage patterns in HTTP,
/// and may change across versions. For example, the current implementation uses
/// [Robin Hood hashing](...) to store entries compactly and enable high load
/// factors with good performance.

以及自适应哈希:

// http-1.4.0/src/header/map.rs:45-49
/// `HeaderMap` uses an adaptive strategy for hashing to maintain fast lookups
/// while resisting hash collision attacks. The default hash function
/// prioritizes performance. In scenarios where high collision rates are
/// detected—typically indicative of denial-of-service attacks—the
/// implementation switches to a more secure, collision-resistant hash function.

9.3.2 为什么限 32768 个条目

// http-1.4.0/src/header/map.rs:53-54
/// A `HeaderMap` can store at most 32,768 entries (header name/value pairs).
/// Attempting to exceed this limit will result in a panic.

32768 = 2^15。这不是随便选的数字——HeaderMap 内部用 u16(type Size = u16)作为 entry 的索引,源码注释的说法是"2 字节而不是 64 位平台上的 8 字节",主要收益在 indices 哈希表上(http-1.4.0/src/header/map.rs:264-279)。u16 本可以表示到 65535、上限只取 2^15 是因为源码注释说"32,768 should be plenty and it allows us to reserve the top bit for future usage"——把最高位留给未来。

工程感受:正常的 HTTP 请求/响应远到不了 32768 个 header。用 u16 索引 hashmap 是一个"用容量上限换内存节省"的典型工程决定。注意超限时 insert / append 会 panic(try_insert / try_append 则返回 MaxSizeReached,见 http-1.4.0/src/header/map.rs:1310、:1453),所以它不是防御手段——真正限制请求头个数的是解析层,比如 hyper HTTP/1 默认最多 100 个(hyper-1.9.0/src/proto/h1/role.rs:31)。

9.3.3 与 indexmap / HashMap 的取舍

你可能会想:为什么不用 indexmap(第 7 章讲 Balance 时用过)?

答:HeaderMap 的布局其实和 indexmap 很像——一张只存"索引 + 哈希"的 indices 表,加一个装真实数据的 entries Vec。区别在取舍:不同 header name 之间的顺序在 HTTP 里一般不重要,所以 HeaderMap 不承诺迭代顺序(remove 用 swap_remove,http-1.4.0/src/header/map.rs:1601);indices 用 u16 压缩;再内建 HTTP 需要的"多值"——同名的第二个及以后的值放在 extra_values 里、用双向链表串起来,不占 entries 的槽位,同名多值之间的先后顺序也就保住了。

HeaderMap 是 http crate 里最能体现"为一个专有用例写的专有数据结构"的部分。它不能直接复用到别处——它的所有 trade-off 都是为 HTTP 量身定制的。这就是好的基础库:不追求通用,追求适用场景下的极致。

9.4 HeaderName:小字符串 + 标准名内联

HTTP header name 有一个特殊性质:同一个名字会反复出现。Content-Type 在每个请求里都有、User-Agent 在每个请求里都有、Accept 也是。这意味着 HeaderName 的设计必须考虑"频繁创建相同字符串"的场景。

看 HeaderName 的源码组织(http-1.4.0/src/header/name.rs,1895 行):

  1. 标准 header 常量:Content-Type、User-Agent、Accept 等常用的标准 header 都由 standard_headers! 宏生成一个 pub const CONTENT_TYPE: HeaderName 这样的常量(内部是 Repr::Standard(StandardHeader::ContentType))。这些常量是编译期值,完全不占堆。
  2. 标准 header 的快速匹配:parse 一个传入的 HeaderName 时(parse_hdr),长度不超过 64 字节的名字先在栈上的临时缓冲里用 HEADER_CHARS 查表转小写,再交给 StandardHeader::from_bytes——它是同一个宏生成的、对各标准名字节串字面量的 match——匹配到就返回 Repr::Standard 的对应 variant,不分配字符串。
  3. 自定义 header:匹配不到标准名时,校验字符(必须是 ASCII tchar 范围)、转小写(HTTP header 名不区分大小写,normalize 到小写)、复制进一块新的 Bytes,存储为 Repr::Custom(Custom(ByteStr))(ByteStr 是包了一层 Bytes 的 UTF-8 字符串)。三个入口的小写规则不同:from_bytes 会帮你转小写,from_lowercase 遇到大写直接报错,from_static 遇到大写则 panic。

这套机制让标准名完全无分配——无论是直接用常量,还是从字节解析出来。自定义名从字节解析时会复制一份到堆上(一次分配,实测 HeaderName::from_bytes(b"X-Custom") 计 1 次分配、from_bytes(b"Content-Type") 计 0 次);只有 HeaderName::from_static("x-custom") 这种静态自定义名不分配。

9.4.1 lowercase 规范化是个陷阱

HTTP 协议规定 header name 大小写不敏感——Content-Type、content-type、CONTENT-TYPE 等价。但真实线路上你会看到五花八门的大小写风格:

  • HTTP/1 时代:传统上首字母大写,每个 hyphen 后的字母大写(Content-Type)。
  • HTTP/2 规定:所有 header name 必须小写,否则协议错误。

http crate 的 HeaderName 存储时统一转小写——这简化了哈希和比较。但这引出一个兼容性问题:HTTP/1 的客户端和服务端可能对大小写敏感(虽然不应该,但历史代码有)。

解决办法:hyper 提供了一个 preserve_header_case 选项(在 HTTP/1 Builder 里),在序列化时保留原始大小写。这个后续会在第 11 章读到 httparse 和 HTTP/1 encoder 时展开。

9.5 HeaderValue:Bytes + 一个标记位

// http-1.4.0/src/header/value.rs:21-25
#[derive(Clone)]
pub struct HeaderValue {
    inner: Bytes,
    is_sensitive: bool,
}

两个字段——Bytes 存字节值,一个 bool 标记"是否敏感"。

9.5.1 Bytes 的零拷贝威力

bytes::Bytes 是 bytes crate 最核心的类型——它是一个"引用计数的不可变字节切片"。Bytes::clone() 共享同一块底层存储、不复制字节。这意味着:

  • 把 HeaderValue 插入 HeaderMap 时不复制数据;
  • 读取 header 拿到 &HeaderValue 再 clone 出去时不复制数据;
  • 把 HeaderValue 从一个 Response 搬到另一个 Response 时不复制数据。

这对 HTTP 栈的性能至关重要——hyper 从 network buffer 解析出一个 header value 后,直接用 slice.slice(..) 切出那段 Bytes 存进 HeaderMap(hyper-1.9.0/src/proto/h1/role.rs:254-255),value 的字节不再复制。header name 则走 HeaderName::from_bytes:标准名不分配,自定义名会复制一份。

9.5.2 from_static:const fn 校验 + 零分配

// http-1.4.0/src/header/value.rs:61-76
#[inline]
pub const fn from_static(src: &'static str) -> HeaderValue {
    let bytes = src.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if !is_visible_ascii(bytes[i]) {
            panic!("HeaderValue::from_static with invalid bytes")
        }
        i += 1;
    }

    HeaderValue {
        inner: Bytes::from_static(bytes),
        is_sensitive: false,
    }
}

这是一个 const fn——可以在编译期运行,但只有在 const 上下文里(const / static 的初始化表达式等)才一定在编译期求值。构造时:

  1. 循环遍历每个字节,检查是 visible ASCII(is_visible_ascii:32-126 范围、外加 \t)。
  2. 有任何字符不合法就 panic。写成 const V: HeaderValue = HeaderValue::from_static("bad\nvalue"); 时这是编译失败(实测报 E0080);写成普通的 let v = HeaderValue::from_static("bad\nvalue"); 则能编译通过,运行到这一行才 panic。
  3. 全部合法——构造一个 Bytes::from_static(bytes),不分配任何堆内存(实测 0 次分配;对比 HeaderValue::from_str 会复制一份,1 次分配)。

这是 Rust 对"编译期校验"能力的典型运用:想要编译期保证,就把常用值声明成 const,这样校验只在编译期做一次,运行时只剩一个现成的值。对于"HTTP header value 里大部分是常量"的场景,这是一个零成本抽象的好例子。

9.5.3 is_sensitive:给 HPACK 的提示

is_sensitive: bool 是一个奇怪的字段——它不影响值的内容,只是一个标记。

作用:HTTP/2 用 HPACK 压缩 header——HPACK 会把 header 加到一个压缩字典里,下次出现同样的 header 就用一个短索引引用。但敏感 header(比如 Authorization、Set-Cookie)不应该被加到字典里——否则攻击者可以用 HPACK 的字典泄露(CRIME-style 攻击)推测出敏感 header 的内容。

当你 value.set_sensitive(true) 之后,HPACK 编码时会给这个 header 打上 "never indexed" 标志,保证它不进入压缩字典。这是一个跨 crate 的协议约定——http crate 暴露这个标记,h2 crate 在编码时读这个标记执行对应行为。整个链路是完全透明的,但需要你用对 API。

这种"把安全语义作为类型级 flag 暴露"的设计,是 Rust 生态里常见的做法。h2 自己也按名字挡了一层:Authorization、Cookie、Set-Cookie 等几个名字的值默认不进动态表(h2-0.4.7/src/hpack/header.rs:189-208 的 skip_value_index),但"never indexed"标志只看 is_sensitive。让调用方显式标记更灵活——任何 header 都可以被标记敏感。

9.6 Extensions:类型索引的 key-value store

Extensions 定义在 http-1.4.0/src/extensions.rs:

// http-1.4.0/src/extensions.rs:6-39(节选,省略注释)
type AnyMap = HashMap<TypeId, Box<dyn AnyClone + Send + Sync>, BuildHasherDefault<IdHasher>>;

#[derive(Default)]
struct IdHasher(u64);

impl Hasher for IdHasher {
    fn write(&mut self, _: &[u8]) {
        unreachable!("TypeId calls write_u64");
    }
    #[inline]
    fn write_u64(&mut self, id: u64) {
        self.0 = id;
    }
    #[inline]
    fn finish(&self) -> u64 {
        self.0
    }
}

#[derive(Clone, Default)]
pub struct Extensions {
    map: Option<Box<AnyMap>>,
}

Extensions 允许把任意类型的值挂在 Request / Response 上——中间件之间传递自定义信息。

典型使用场景:

// 中间件 A 解析 JWT,把结果存进 extensions
req.extensions_mut().insert(AuthInfo { user_id: 42 });

// 中间件 B / handler 读出来
let auth = req.extensions().get::<AuthInfo>();

9.6.1 TypeId 即 key

HashMap<TypeId, Box<dyn AnyClone>>——用 TypeId::of::<T>() 做 key,value 是 type-erased box。get::<T>() 根据 TypeId::of::<T>() 查找,查到之后 downcast 回具体类型。

每种类型最多存一个值——因为 key 是 TypeId。如果你 insert 同一类型两次,后者覆盖前者(和 HashMap::insert 一致)。如果你想存"多个 AuthInfo",需要包一个 newtype——struct AuthInfoList(Vec<AuthInfo>)。

9.6.2 IdHasher 的巧思

impl Hasher for IdHasher {
    fn write(&mut self, _: &[u8]) { unreachable!(); }
    fn write_u64(&mut self, id: u64) { self.0 = id; }
    fn finish(&self) -> u64 { self.0 }
}

观察:TypeId 本身就是编译器算好的哈希值、它的 Hash impl 只会调用一次 write_u64(源码注释:"They are already hashes themselves, coming from the compiler")。再对它算一遍 SipHash 是浪费。于是 IdHasher 干脆不做任何哈希——它的 write_u64 直接存下来,finish 直接返回。

标准 Hasher 接口要求能吸收任意字节,但 TypeId 只会调 write_u64。所以 write 实现直接 unreachable!()——如果有人尝试用 Extensions 的 AnyMap 存非 TypeId 的 key,程序会 panic(不会发生,因为类型系统已经限定)。

这种"当你知道你的具体输入时,丢掉通用的慢路径"是 Rust 的精髓。对比 JS 的 Map 或 Python 的 dict——它们不可能做这种优化,因为键类型是动态的。

9.6.3 Option<Box<AnyMap>>:懒分配

pub struct Extensions {
    // Extensions might never be used and carrying an empty HashMap around is
    // inefficient (because it's 3 words). This is only 1 word instead.
    map: Option<Box<AnyMap>>,
}

真正的商业价值在这条注释里。

按源码注释的说法、一个空 HashMap 本体是 3 个 word;这条注释已经过时——在 Rust 1.96.0 上实测同类 HashMap(键 TypeId、值 Box<dyn ...>、零大小 hasher)是 32 字节,即 4 个 word。Option<Box<AnyMap>> 只有 1 个 word(8 字节)——None 时什么都没分配。

没有任何一层插入过扩展的请求,就一直是这 8 字节的 None;第一次 insert 才分配(实测一次 insert(5u32) 计 3 次分配:Box<AnyMap>、哈希表的桶、装值的 Box)。要注意的是在 axum 里这块通常会被用上——Router 默认(original-uri feature)会给每个请求插入 OriginalUri(axum 仓库 axum/src/routing/path_router.rs:330-338)。这是典型的"为最常见路径优化"的代码:不用就不付钱,每个 head 省下 24 字节。

9.7 一个完整请求的内存占用

把上面所有分析合起来,我们看一个典型请求的内存画像:

GET /api/users HTTP/1.1
Host: example.com
Accept: application/json
Authorization: Bearer abc123
User-Agent: my-client/1.0

内存布局(64-bit 平台、按源码定义推,size_of 为 http 1.4.0 + Rust 1.96.0 实测):

部分 布局
Request<T> 外层 head: Parts 和 body: T 直接内联在同一个 struct 里,没有额外的 Box;Parts 与 Request<()> 都是 224 字节
Parts::method Method(Inner),24 字节——9 个标准方法只是一个 enum tag;不超过 15 字节的扩展方法内联在 InlineExtension([u8; 15], u8) 里、更长的才 Box<[u8]>
Parts::uri Scheme + Authority + PathAndQuery,共 88 字节;后两者各持一个 ByteStr(内含 4 个 word 的 Bytes),从同一块 Bytes 解析时指向同一段缓冲
Parts::version 一个单字节 enum
Parts::headers HeaderMap 本体 96 字节:mask + 三个堆容器句柄 + danger;HeaderMap::new() 不分配,第一次插入时分配 8 槽的 indices 和容量 6 的 entries(实测 2 次分配),4 个 header 不会再扩容
Parts::extensions Option<Box<AnyMap>>,没用过就是 1 个 word 的 None
body (T) 取决于类型;T = Incoming 时是 hyper 的 Kind enum、数据仍在 Bytes 里

整个 head 的元数据就是 224 字节的一块连续 struct,外加 header 的少量堆分配——而且 hyper 解析出的 header value 是从读缓冲切出来的 Bytes、这部分字节不再被复制一遍。

对比 JS 里 Express 的 req 对象——headers、query、params 等各是独立的 JS object,都要交给 GC 管理。Rust 的紧凑内存布局在高并发场景下是实打实的收益——无需 GC 扫描、cache 命中率高、内存带宽浪费少。

9.8 和 Serde 的一次巧合

细心的读者可能注意到:Parts 字段上没有 #[derive(Serialize, Deserialize)],http 也没有提供 serde feature(serde 只出现在 Cargo.toml 的 dev-dependencies 里)——而很多 Rust 库都会提供一个可选的 serde feature。

一个合理的解释是:HTTP 协议有自己的二进制线路格式,不是 JSON。把 Method::GET 序列化成什么?字符串 "GET"?enum discriminant 0?没有标准答案。Headers 序列化成什么?一个 JSON 对象?一个 array of [name, value]?任何选择都会偏离某种期待。

http crate 的作者们做了一个明智的决定:不提供 serde impl,让用户自己决定。下游如果需要(比如存日志、存 redis),可以自己写转换函数——用自己的 JSON 结构表达 method / headers。这避免了 "库作者替你选了 serialize 方案,但你的方案不同" 的尴尬。

这里延伸一下和《Serde 元编程》专栏的关系:Serde 解决的是"数据结构 → N 种格式"的映射——但那假设数据结构本身和"它如何在线路上跑"没关系。HTTP 的情况是:数据结构本身就定义了线路格式(method 是 "GET",headers 是 "Key: Value\r\n")。所以 HTTP 的数据模型不走 serde,走自己的 encoder(hyper 里的 HTTP/1 wire encoder,第 11 章会读)。

9.9 Method 和 StatusCode 的内部优化

最后两个小型优化,既优雅又能让你从中学到模式。

9.9.1 Method:标准方法 enum + 扩展支持

// http-1.4.0/src/method.rs:44-67 精简
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Method(Inner);

enum Inner {
    Options, Get, Post, Put, Delete, Head, Trace, Connect, Patch,
    ExtensionInline(InlineExtension),       // 短扩展方法(≤ 15 字节):[u8; 15] + 长度
    ExtensionAllocated(AllocatedExtension), // 长扩展方法:Box<[u8]>
}
  • 9 个 HTTP 标准方法:用 enum variant,不占堆。
  • 扩展方法(WebDAV 的 PROPFIND、自定义 RPC 方法):根据长度选择内联或堆分配(实测 PROPFIND 0 次分配,17 字节的方法名 1 次)。

Method::GET 不过是一个 tag——== 比较、哈希、克隆都极快。不过 Method 整体是 24 字节(要容纳内联扩展),并没有 Copy。

9.9.2 StatusCode:一个 u16

// http-1.4.0/src/status.rs:44-45
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct StatusCode(NonZeroU16);

就一个 u16——但包在 NonZeroU16 里以利用 Rust 的 niche optimization:Option<StatusCode> 的大小和 StatusCode 一样(实测都是 2 字节),因为 Option 可以用 0 表示 None。

这是一个微小但"赢在细节"的工程选择。StatusCode::from_u16 只接受 100-999(http-1.4.0/src/status.rs:73-80)——永远不是 0,所以用 NonZeroU16 完全合理。

9.9.3 http 1.4.0 整 crate 14382 行的真实尺寸表

把 http 仓库 29dd307(即本专栏声明的 1.4.0)的 src/ 按文件大小排序——全部 21 个 .rs 合计 14382 行:

文件 行 占比 角色
http-1.4.0/src/header/map.rs 3973 27.6% HeaderMap——本章 §9.3 主角、Robin Hood + u16 索引 + 自适应哈希
http-1.4.0/src/header/name.rs 1895 13.2% HeaderName——standard_headers! 宏生成的标准名常量 + 扩展名兜底(§9.4)
http-1.4.0/src/uri/mod.rs 1126 7.8% URI 解析主类——本章未深入
http-1.4.0/src/request.rs 1068 7.4% Request<T> + Builder(§9.2)
http-1.4.0/src/response.rs 781 5.4% Response<T> + Builder
http-1.4.0/src/header/value.rs 770 5.4% HeaderValue(§9.5)
http-1.4.0/src/uri/authority.rs 725 5.0% host:port 解析
http-1.4.0/src/uri/path.rs 653 4.5% path + query 切分
http-1.4.0/src/status.rs 596 4.1% StatusCode——NonZeroU16 niche(§9.9.2)
http-1.4.0/src/uri/tests.rs 519 3.6% URI 解析的 tests
http-1.4.0/src/method.rs 500 3.5% Method enum + 扩展(§9.9.1)
http-1.4.0/src/uri/scheme.rs 361 2.5% http:// https:// 等 scheme 类型
http-1.4.0/src/extensions.rs 358 2.5% Extensions(§9.6)
http-1.4.0/src/uri/builder.rs / http-1.4.0/src/lib.rs / http-1.4.0/src/error.rs / http-1.4.0/src/uri/port.rs / http-1.4.0/src/header/mod.rs / http-1.4.0/src/byte_str.rs / http-1.4.0/src/version.rs / http-1.4.0/src/convert.rs(其余 8 文件合计 1057 行) 17~211 7.3% 顶层 + 小工具

两块汇总——

  • header/ 4 文件 6780 行(map + name + value + mod)= 47% of crate——HTTP header 占了将近一半的工程投入
  • uri/ 7 文件 3746 行 = 26%——这是一个本章完全没有讲的板块、其实和 header 一样是 http crate 的核心

三条值得记住的事实——

  1. HeaderMap 单文件 3973 行——是整个 tower-layer crate(699 行)的 5.7 倍——一个看似简单的 multimap、为了 HTTP 性能写出 4000 行代码——印证 §9.3 那一节"为什么不用 HashMap"的工程深度
  2. URI 模块比 Request + Response 加起来还重(3746 vs 1849)——因为 URI 解析涉及 RFC 3986 + RFC 6874(IPv6 zone identifier)+ percent-encoding——是一份独立的小协议引擎(http-1.4.0/src/uri/authority.rs:503-535 处理 IPv6 zone identifier 的注释直接引了这两份 RFC)
  3. extensions.rs 仅 358 行——TypeId-indexed 的 anymap + IdHasher 的整个魔法只用 358 行实现(§9.6)——是 http crate 里"小代码、大概念"的代表

9.9.4 从五个结构体看 http crate 的零成本边界

http crate 的设计价值不在“提供 Request/Response 类型”这么简单,而在它把 HTTP 语义拆成了几个可组合、可独立优化的结构体。http-1.4.0/src/request.rs:151-178 的 Request<T> 只有 head: Parts 和 body: T 两个字段;Parts 包含 method、uri、version、headers、extensions,并用 _priv: () 阻止外部随意构造未来不兼容的结构。这个布局解释了 body 泛型为什么重要:中间件可以在不复制头部的情况下改变 body 类型,协议层也可以把 body 换成流式实现。

Builder 的错误模型也很克制。http-1.4.0/src/request.rs:181-188 的 Builder 内部是 Result<Parts>,这意味着链式设置 method、uri、header 时,一旦某一步解析失败,错误会留在 builder 内部,直到 .body(...) 才统一返回。它避免了每一步都返回 Result<Builder> 造成的噪音,也避免了 panic 式构造。这个模式在 Response builder、Uri builder 里反复出现,是 Rust API 设计里“链式易用”和“错误可追踪”的折中。

HeaderMap 是最能体现“为 HTTP 专门设计”的结构。http-1.4.0/src/header/map.rs:81-88 的字段不是一个通用 HashMap,而是 mask、indices、entries、extra_values 和 danger;http-1.4.0/src/header/map.rs:90-105 的实现注释明确说核心哈希表基于 Robin Hood hashing,但因为 HeaderMap 是 multimap 且要利用 HTTP header 特征,所以和标准 HashMap 的细节不同。多值 header 不是边角场景,Set-Cookie 这类字段天然需要一个 header name 对多个 value,专门结构比 HashMap<String, String> 更符合协议。

http-1.4.0/src/header/map.rs:264-279 的 Size = u16 和 MAX_SIZE = 1 << 15 也不是任意限制。注释说明 32768 足够使用,并预留顶位给未来用途;同时 u16 索引让 indices 更 cache friendly。换句话说,HeaderMap 把“HTTP header 数量不会无限增长”这个常识变成了数据结构边界。但这条边界超限时是 panic,不是防护;挡住异常多 header 的请求是解析层的事(见 §9.3.2)。

HeaderValue 则把“字符串”和“字节”分清。http-1.4.0/src/header/value.rs:12-25 说明 header value 通常是 ASCII,但规范允许 opaque bytes,所以不能总是当作字符串;结构体内部用 Bytes 和 is_sensitive,http-1.4.0/src/header/value.rs:61-76 的 from_static 是 const fn,会检查可见 ASCII 并且不复制静态字符串。这个设计让常见静态 header(比如 content-type)走零分配路径,同时保留对非 UTF-8 header value 的表达能力。

最后是 Extensions。http-1.4.0/src/extensions.rs:30-39 把它定义成协议扩展的 type map,并用 Option<Box<AnyMap>> 做懒分配;http-1.4.0/src/extensions.rs:62-86 的 insert/get 以 TypeId 为 key,只接受 Clone + Send + Sync + 'static 的值。对中间件链来说,这是一条类型安全的旁路通道:认证层可以插入用户信息,tracing 层可以插入 request id,业务层再按类型取出。它没有把这些字段塞进 Request 的固定结构里,因为扩展点本来就应该由下游生态自由定义。

把这五个结构体放在一起看,http crate 的边界很清楚:它不实现网络 I/O,不实现 HTTP/1 parser,不实现 HTTP/2 frame,也不替用户决定 serde 格式;它只定义一组足够稳定、足够紧凑、能被 hyper、tower-http、axum、tonic 同时共享的数据模型。这个层次越薄,上层生态越容易互操作;这个层次越严谨,协议层越不容易被业务代码拖垮。

这一点在内存模型上尤其重要。一个 Request<T> 的头部可以在中间件之间移动、拆分、重组,而 body 仍然保持泛型;HeaderMap 的 value 可以 clone Bytes 而不是复制字节;Extensions 默认不分配,只有真正插入扩展时才建 map。这些选择共同服务一个目标:HTTP 请求在框架栈里会被很多层读写,如果每一层都复制 header、字符串化 value、把扩展塞进普通 HashMap,吞吐下降不是来自协议解析,而是来自业务栈内部的数据搬运。

http crate 也刻意避免替上层做“聪明事”。它不会自动规范化所有 header value 为 UTF-8 字符串,不会假设 extensions 里一定有某种上下文,不会给 Request 绑定某个 body trait。这样看起来少了便利,但换来的是长期稳定:Hyper 可以用它表达 Incoming body,Axum 可以把 body 换成 BoxBody,tower-http 可以只改 header,Tonic 可以在 Extensions 里挂自己的扩展数据。公共语言越少下判断,生态越能复用。

9.10 小结与落到你键盘上

本章穿越的要点:

  1. Request<T> 的 body 泛型让中间件可以在不重建整个请求的前提下改变 body 类型;Parts 的 _priv: () 是一个常见的未来兼容 trick。
  2. HeaderMap 是为 HTTP 量身打造的 multimap——Robin Hood 哈希、u16 索引、自适应哈希抗 DoS、不保序但极致紧凑。
  3. HeaderName 通过 standard_headers! 宏生成的编译期常量 + StandardHeader::from_bytes 的 match 表把标准 header 做到零分配(没有用 phf),自定义名解析时才复制一份;HeaderValue 用 Bytes 做零拷贝 + from_static 的 const fn 校验(放进 const 才在编译期做)。
  4. Extensions 用 TypeId → AnyBox 的巧妙设计实现类型索引的 key-value;IdHasher 的"不哈希"和 Option<Box<AnyMap>> 的懒分配都是为了省 bytes。
  5. Method + StatusCode 在内部用 enum + niche 优化做到紧凑。

落到你键盘上:

  • 写一个 benchmark:构造 10000 个 Request<()>,每个带 10 个 header;测量 HashMap<String, Vec<String>> 和 HeaderMap 的内存占用与构造时间。你会直观感受到"为用场量身设计的数据结构"的差距。
  • 读 http-1.4.0/src/header/name.rs——重点看 pub struct HeaderName { inner: Repr<Custom> } 里 Repr::Standard / Repr::Custom 两路分支,以及 standard_headers! 宏怎么一次生成常量、StandardHeader::from_bytes 的 match 表和测试用例。
  • 试试 Extensions:在你自己的 Axum 中间件(如 middleware::from_fn)里,用 request.extensions_mut().insert(my_struct) 把信息传给下游 handler。感受 "一条 TypeId-indexed 通道" 如何在中间件链里替代全局 context。