Skip to content

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

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

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

http = "1"

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

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

加起来大约一万行源码,没有任何异步逻辑、没有任何 TCP 代码、没有任何解析器——它就是HTTP 的"类型语言"。hyper、reqwest、tonic、axum、actix-web、tower-http 全部以这套类型为基础交换数据。

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

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

Rust 把数据模型彻底独立出来——同一个 http::Request<Bytes>,可以从 hyper 收到、用 tower 过一遍中间件、交给 axum handler 处理、最后序列化给 reqwest 发给远端。每一层都操作同一个类型,不需要转换、不需要克隆、不需要重组

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

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

rust
// http/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——可以是 StringVec<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 类型只是一个标签:

rust
// 构造时 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: () 的作用

看这一行:

rust
_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::into_parts() 或者 Request::builder().body(...) 获得 Parts。这样作者未来添加 / 删除字段都是兼容的。

这个技巧在 Rust 生态到处可见——tokio、hyper、tower 的几十个 struct 都用这招。如果你在自己的 crate 里想"导出一个可读取字段的 struct,但控制构造入口",这就是标准做法。

9.2.2 Builder 模式与 Result 短路

rust
// http/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,你就得这样写:

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

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

rust
// 美的版本
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 里最复杂也最有工程味的结构。整个文件(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 数量一般很小(10-50 个)。标准 HashMap 的默认 load factor 和哈希函数针对数千条以上条目优化。对于十几个条目,开放寻址 + Robin Hood hashing 在 cache locality 上快得多。

第三,需要抵御哈希碰撞攻击。攻击者可以构造出大量哈希到同一 bucket 的 header name(SipHash 能抵御到某种程度,但不完美)——HeaderMap 实现了自适应哈希,在检测到高冲突率时切换到更强的哈希函数。

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

源码里的注释把第一、二条讲得很清楚:

rust
// http/src/header/map.rs:16-22
/// 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.

以及自适应哈希:

rust
// :39-44
/// `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 个条目

rust
// :46-50
/// 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 作为 entry 的索引,以节省每个 entry 的内存(相比 u32usize)。上限 2^15 是因为 Robin Hood 实现需要一些 bit 做 metadata。

工程感受:HTTP 请求/响应里永远不可能有 32768 个 header——常见的值是 10-50 个。用 u16 索引 hashmap 是一个"用平台上限换显著内存节省"的典型工程决定。

9.3.3 与 indexmap / HashMap 的取舍

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

答:indexmap 是 "保持插入顺序的 HashMap",开销比原生 HashMap 大 30%(需要额外的索引数组)。HTTP header 的顺序一般不重要(Set-Cookie 之外),所以 HeaderMap 不追求 "ordered"——它把能省的都省了。唯一保留的是 HTTP 允许的"多值"——这通过内部的 extra-value 链表实现,不占 entry 空间。

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

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

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

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

  1. 标准 header 常量:Content-Type、User-Agent、Accept 等所有 IANA 注册的标准 header 都有一个 pub const fn from_static() 对应的常量。这些常量在编译期生成,完全不占堆。
  2. 标准 header 的快速匹配:parse 一个传入的 HeaderName 时,先用 phf(perfect hash function)快速匹配标准名——匹配到就返回内部 enum 的对应 variant,不分配字符串。
  3. 自定义 header:匹配不到标准名时,校验字符(必须是 ASCII tchar 范围)、转小写(HTTP header 名不区分大小写,normalize 到小写)、存储为 Bytes

这套机制让 99% 的 header name(都是标准名)完全无分配——常量直接复用。剩下 1% 的自定义 header 才走 Bytes 路径。

9.4.1 lowercase 规范化是个陷阱

HTTP 协议规定 header name 大小写不敏感——Content-Typecontent-typeCONTENT-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 HeaderValueBytes + 一个标记位

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

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

9.5.1 Bytes 的零拷贝威力

bytes::Bytesbytes crate 最核心的类型——它是一个"引用计数的不可变字节切片"。Bytes::clone() 只是 Arc::clone——零拷贝、几纳秒完成。这意味着:

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

这对 HTTP 栈的性能至关重要——hyper 从 network buffer 解析出一个 header value 后直接把那段 Bytes 存进 HeaderMap,整条链路没有任何 copy。

9.5.2 from_static:编译期校验 + 零分配

rust
// http/src/header/value.rs:61-75
#[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——可以在编译期运行。用 HeaderValue::from_static("value") 构造时:

  1. 编译期循环遍历每个字节,检查是 visible ASCII(32-127 范围)。
  2. 有任何字符不合法——编译失败(panic! in const context 直接 error)。
  3. 全部合法——构造一个 Bytes::from_static(bytes),不分配任何堆内存。

这是 Rust 对"编译期校验"能力的典型运用。写 HeaderValue::from_static("my-value") 和写一个字符串字面量的代价完全一样——不是运行时函数调用。对于"HTTP header value 里大部分是常量"的场景,这是一个零成本抽象的完美例子。

9.5.3 is_sensitive:给 HPACK 的提示

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

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

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

这种"把安全语义作为类型级 flag 暴露"的设计,是 Rust 生态里常见的做法。比起在运行时检查"这个 header 是不是 Authorization",让调用方显式标记更快、更可靠、更灵活(任何 header 都可以被标记敏感)。

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

rust
// http/src/extensions.rs:1-40
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 上——中间件之间传递自定义信息。

典型用场景:

rust
// 中间件 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 的巧思

rust
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 }
}

这是整个 http crate 里最精巧的 10 行代码之一。

观察:TypeId 内部已经是一个 u64——它本身就是一个哈希值(Rust 编译器保证)。再对它算一遍 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>>:懒分配

rust
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>>,
}

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

一个默认的 HashMap3 个 usize 的大小(24 字节 on 64-bit)——Vec<Bucket> 是 3 个字段(ptr, cap, len),加上 HashMap 外面的 wrapper 实际上更大。Option<Box<HashMap>> 只有 1 个 usize(8 字节)——当 None 时什么都没分配。

99% 的 Request 根本没用过 extensions。HTTP 服务在 happy path 上每秒处理几千上万个请求,每个省 16 字节和一次潜在的 HashMap 初始化——累加下来非常显著。这是典型的"为最常见路径优化"的代码——为什么工业级库在这种细节上花功夫,就是这种收益。

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

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

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

内存占用(粗略估算,64-bit 平台):

部分字节数说明
Request<T> 外层8Box<Parts> 的指针(body 在外)
Parts::method8Method 是 enum + small inline
Parts::uri~40Uri 含 scheme/authority/path 引用
Parts::version1Version 是一个小 enum
Parts::headers~2004 个 header + HeaderMap 结构
Parts::extensions8Option<Box<AnyMap>> 未用
body (T)取决于类型如果 T=Incoming,是 8 字节的 Kind enum tag + 一个 ptr

粗略 300 字节——而 HTTP 报文本身 ~200 字节。内存效率接近 1:1,几乎没有元数据开销。

对比 JS 里 Express 的 req 对象——一个普通 request 会分配至少十几个对象(headers 一个、cookies 一个、query 一个、params 一个、session 一个……每个都是独立的 JS object),堆占用远大于报文本身。Rust 的紧凑内存布局在每秒数万请求的场景下是实打实的收益——无需 GC 扫描、cache 命中率高、内存带宽浪费少

9.8 和 Serde 的一次巧合

细心的读者可能注意到:Parts 字段上没有 #[derive(Serialize, Deserialize)]——这似乎和 Rust 生态"所有结构都支持 serde"的一贯传统不符。

原因: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 MethodStatusCode 的内部优化

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

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

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

enum Inner {
    Options, Get, Post, Put, Delete, Head, Trace, Connect, Patch,
    ExtensionInline(Extension),   // 短扩展方法
    ExtensionAllocated(Box<str>), // 长扩展方法
}
  • 9 个 HTTP 标准方法:用 enum variant,不占堆。
  • 扩展方法(WebDAV 的 PROPFIND、自定义 RPC 方法):根据长度选择内联或堆分配。

Method::GET 不过是一个 tag——== 比较、哈希、克隆都极快。

9.9.2 StatusCode:一个 u16

rust
// http/src/status.rs:22
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub struct StatusCode(NonZeroU16);

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

这是一个微小但"赢在细节"的工程选择。HTTP status code 有效范围是 100-999——永远不是 0,所以用 NonZeroU16 完全合理。

9.10 小结与落到你键盘上

本章穿越的要点:

  1. Request<T> 的 body 泛型让中间件可以在不重建整个请求的前提下改变 body 类型;Parts_priv: () 是一个常见的未来兼容 trick。
  2. HeaderMap 是为 HTTP 量身打造的 multimap——Robin Hood 哈希、u16 索引、自适应哈希抗 DoS、不保序但极致紧凑。
  3. HeaderName 通过 phf + 编译期常量把标准 header 做到零分配;HeaderValueBytes 做零拷贝 + from_static 的 const fn 编译期校验。
  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/src/header/name.rs——重点看那个 pub enum HeaderName 内部的 Repr::Standard / Repr::Custom 两路分支。理解 phf 怎么实现 "标准 header 名的完美哈希映射"。
  • 试试 Extensions:在你自己的 Axum handler 里,用 request.extensions_mut().insert(my_struct) 把信息传给下游。感受 "一条 TypeId-indexed 通道" 如何在中间件链里替代全局 context。

下一章我们读 http-body——Body trait 的设计,以及 hyper 如何把 TCP 字节流翻译成一个 Body 实例。

基于 VitePress 构建