Hyper 与 Tower:工业级 HTTP 栈

第14章 keep-alive、半关闭与超时矩阵

作者 杨艺韬 · 11,419 字

第14章 keep-alive、半关闭与超时矩阵

14.1 连接的”生存线”

前面两章我们把 HTTP/1 的 wire format 和 Dispatcher 状态机读完了。在理想情况下一切都很干净——客户端发请求、服务端回响应、Dispatcher 循环推动一切。

但真实世界没那么干净。真实世界里:

  • 客户端发了一半的 header 就挂了——TCP 不会主动告诉你,数据永远不来。
  • 客户端发了 Connection: close 但随即又发了一个新请求——你应该按协议关连接,但请求里的数据要不要处理?
  • NAT 中间件 30 分钟没看到流量就静默丢连接——你的 keep-alive 连接看起来正常但下次 write 就 ECONNRESET
  • 客户端慢到每秒只发 1 个字节(slow loris 攻击)——TCP 看来是”正常连接”,但永远读不完 header。
  • 服务器刚发完 response 想复用连接,结果客户端的 socket 已经在对方那边 close 了。

这些边界在 HTTP 协议规范里有零碎的描述,但把它们转成高效、正确、不让生产环境崩的代码,靠的是工业级 HTTP 库的累积经验。Hyper 通过 http1::Builder 暴露的一组配置项,就是这些经验的结晶:

// hyper/src/server/conn/http1.rs:70-86
pub struct Builder {
    h1_parser_config: httparse::ParserConfig,
    timer: Time,
    h1_half_close: bool,
    h1_keep_alive: bool,
    h1_title_case_headers: bool,
    h1_preserve_header_case: bool,
    h1_max_headers: Option<usize>,
    h1_header_read_timeout: Dur,
    h1_writev: Option<bool>,
    max_buf_size: Option<usize>,
    pipeline_flush: bool,
    date_header: bool,
}

这一章我们把每个配置背后的”防什么、怎么防、代价是什么”讲清楚。这些不是”锦上添花”——这是你上线 hyper-based 服务必须理解的生产基线。

14.1.1 Builder::default() 的 12 个字段缺省值

打开 hyper 1.9.0server/conn/http1.rs:234-247、看 Builder::new 里的缺省配置:

Builder {
    h1_parser_config: httparse::ParserConfig::default(),
    timer: Time::Empty,
    h1_half_close: false,
    h1_keep_alive: true,
    h1_title_case_headers: false,
    h1_preserve_header_case: false,
    h1_max_headers: None,
    h1_header_read_timeout: Dur::Default(Some(Duration::from_secs(30))),
    h1_writev: None,
    max_buf_size: None,
    pipeline_flush: false,
    date_header: true,
}

这 12 个字段的缺省值构成了 hyper 的工程哲学心电图——每一个值背后都有一个”为什么是这个而不是那个”的推理:

  • h1_keep_alive: true ——符合 HTTP/1.1 规范。关掉 keep-alive 是可选项、开是默认。
  • h1_half_close: false ——EOF 就是连接终止、只有少数基础设施需要例外。
  • h1_title_case_headers: false ——header name 默认小写、符合 HTTP/2 规范(虽然 HTTP/1 不强制)、不折腾。
  • h1_preserve_header_case: false ——不保留原始 case、统一小写加速 HashMap 查找。
  • h1_max_headers: None ——None 而不是 Some(100) 的原因是 §14.5.1 讲的——None 意味着”走栈分配、100 个槽”;Some(100) 会走堆分配路径、反而慢 5%。语义上相同的数字、在两条路径上性能不同
  • h1_header_read_timeout: 30 秒 ——直接写死一个数、不留 None。这是对 Slowloris 的默认防御
  • h1_writev: None ——auto 模式、运行时探测。
  • max_buf_size: None ——走默认的 ~400KB。Some 才是用户覆盖。
  • pipeline_flush: false ——实验性功能默认关。
  • date_header: true ——RFC 7231 推荐、所以默认开。

这种”大部分保守、只在必要处主动防御”的缺省组合、就是 hyper 能在”开箱即用能跑、深度配置也行”两种用户间平衡的秘密。真实的生产事故教训就压缩在这 12 行里。

14.1.2 Dur 类型的三态表达:Default / Configured / Empty

h1_header_read_timeout 的类型不是 Option<Duration>、而是 Dur——一个自定义的三态枚举。看 hyper 内部定义:

enum Dur {
    Default(Option<Duration>),   // 框架默认值(可能是 None 比如 Time::Empty)
    Configured(Option<Duration>), // 用户显式设置
    // + 隐式的"从未初始化"态由 Default::default() 构造
}

为什么不直接用 Option<Duration>?——因为需要区分:

  1. 用户未显式配置(使用框架默认 30 秒)——Dur::Default(Some(30s))
  2. 用户显式 set None(明确关闭 timeout)——Dur::Configured(None)
  3. 用户显式 set 60s——Dur::Configured(Some(60s))

Option<Duration> 表达不出 1 和 2 的区别——两者都是 None 吗?还是 1 是 Some(30s) / 2 是 None?API 层面的语义模糊会导致用户踩坑。

Dur 三态让框架的 override 策略清晰:如果内层 Conn 已经有 Default 设置、用户的 Configured 会覆盖它;如果用户显式 Configured None(我就是不要 timeout)、框架不应该再填 Default 回去。这在 check 方法里有精确的实现:

// self.timer.check(self.h1_header_read_timeout, "header_read_timeout")

check 内部同时看 timer 是否存在、Dur 是什么状态、返回最终生效的 Duration 或 panic。三态 + 专用 check是 hyper 应对”配置组合爆炸”的工程办法——用类型系统把每种组合的正确行为钉住。

14.2 Keep-Alive:协议 + 工程的重叠

14.2.1 协议层:什么时候允许 keep-alive

HTTP/1.1 规定:默认 keep-alive(连接默认复用)。HTTP/1.0 规定:默认每请求一连接(除非 Connection: keep-alive 明确开启)。

判断一条连接是否应该 keep-alive,hyper 在 role.rs 里做了大量工作:

// 逻辑简化版
let mut keep_alive = version == Version::HTTP_11;   // HTTP/1.1 默认 true
if let Some(conn_hdr) = headers.get(CONNECTION) {
    if conn_hdr.contains("close") {
        keep_alive = false;
    } else if conn_hdr.contains("keep-alive") {
        keep_alive = true;  // HTTP/1.0 显式开启
    }
}

这些决策在 Server / Client 的 parse 函数里做,结果写进 Conn::state::keep_alive: KA——我们在第 12 章讨论过 KA::Busy / Idle / Disabled 三态,以及 &= false 如何永久 disable。

14.2.2 工程层:h1_keep_alive 开关

Builder 上有一个粗暴的开关:

// hyper/src/server/conn/http1.rs:265-268
pub fn keep_alive(&mut self, val: bool) -> &mut Self {
    self.h1_keep_alive = val;
    self
}

默认 true——符合 HTTP/1.1 协议。设为 false 时,Connection::new 会立刻 conn.disable_keep_alive()——整个连接一开始就设为 Disabled,一次请求完就关。

什么时候设为 false?三种场景:

  • 短命代理:反向代理把请求转发给 upstream,一次就完。Hyper 接客户端如果是 short-lived 场景,也许关掉 keep-alive 更简单。
  • 调试:排查 connection-reuse 相关的 bug 时,关掉 keep-alive 让每次请求走新连接,排查条件更干净。
  • 资源受限:每条连接有 struct 开销、有 buffer、还被 tokio 计数——如果你的业务每连接只做一次请求,关 keep-alive 避免无谓的 idle 连接占资源。

一般 99% 的生产服务保持默认 true

14.2.2.5 Connection::newdisable_keep_alive 的初始化时序

serve_connection 里第一步做了什么:

let mut conn = proto::Conn::new(io);
...
if !self.h1_keep_alive {
    conn.disable_keep_alive();
}

Conn::new(io) 先创建一个新连接对象、默认 KA::Busy(允许 keep-alive)——这是 HTTP/1.1 的规范行为。然后在用户关了 keep_alive 时、调 disable_keep_alive() 把 KA 变成 Disabled

为什么不能在 Conn::new 里直接传参数控制 KA?——因为 Conn 是 hyper 内部的核心状态机、客户端 Conn 和服务端 Conn 共用一份代码(第 12 章讲过)。两者在 keep_alive 默认上有微妙差异:

  • 服务端:默认允许、除非配置关
  • 客户端:默认允许、除非对端 Connection: close 或者本地配置

如果 Conn::new(io, config) 接受一个 config 参数、服务端和客户端都要定义自己的 config type、外加给每个字段挑默认值——API 复杂度飙升。Conn::new 保持极简(只接 io)、所有配置都通过后续 setter 调(set_timer / disable_keep_alive / set_max_buf_size)、客户端 / 服务端各自按照自己的语义装配——代码量小、语义清晰。

这种”构造函数极简 + 多个 setter”的模式(而不是”单个接受所有参数的构造函数”)在 Rust 里叫 Builder + Setter(或者叫 Fluent API)——对于有很多可选配置的类型非常合适。alternative 是用 Default::default() + struct update syntax——但那需要所有字段可以从默认值复制、一些资源句柄(如 timer Arc)不适合。

14.2.3 graceful_shutdown:让当前请求跑完再关

// hyper/src/server/conn/http1.rs:138-140
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
    self.conn.disable_keep_alive();
}

graceful_shutdown 的实现只有一行——把连接 KA 标成 Disabled。这意味着:

  1. 当前正在处理的请求继续处理
  2. response 写完后,try_keep_alive 发现 KA 是 Disabled——不再 reset 状态机,走 Closed 路径。
  3. poll_shutdown 被调用,TCP 半关闭(发 FIN)。
  4. Dispatcher 结束。

这是部署场景的基石——滚动发布 / rolling upgrade 里你需要”让 pod 不再接新请求、正在处理的跑完、然后退出”。通过:

  1. 收到 SIGTERM。
  2. 把 server 的 accept 关掉(不再接新 TCP 连接)。
  3. 对所有 live connection 调 graceful_shutdown
  4. 等所有 connection future 结束。
  5. 进程退出。

graceful_shutdown 的实现简单到让人惊讶——一行代码——但它给了上层极强的生命周期控制能力

14.2.4 Pin<&mut Self> 作为 graceful_shutdown 的 receiver

graceful_shutdown 的签名特殊:

pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
    self.conn.disable_keep_alive();
}

self: Pin<&mut Self> 而不是 &mut self——这是因为 Connection 结构体内部持有 future 状态、实现了 Future被 pin 后不能被移动。它的方法签名必须尊重 Pin。

Pin<&mut Self> 是 Rust 手写 Future 的典型 receiver——允许你借用 self 的内部字段、但不允许 move 或 swap。hyper 大量用这个——Connection::poll 就是典型的 Pin<&mut Self> receiver。

disable_keep_alive 内部写 self.conn.state.keep_alive &= KA::Disabled——这是安全的、因为它只修改 state 字段、不 move Conn 本身。Pin 允许 “访问和修改 pinned type 的字段”、只要不 move 整个类型。

用户使用时需要先 pin 住 Connection

let mut conn = pin!(http.serve_connection(io, svc));
// 在别的 task 里发送 SIGTERM
conn.as_mut().graceful_shutdown();

pin! 宏把 Connection pin 到栈上、.as_mut() 产生一个 Pin<&mut Connection>、然后才能调 graceful_shutdown

这种 API 要求对 Rust 新手不友好——“为什么非要 Pin?“是学习 async Rust 的必经之路。但一旦理解了、就会明白这是类型系统强制你写出安全的 self-referential future——不接受这个约束就走不到手写 Future 的程度。

14.3 Half-Close:容易忽视的边界

// hyper/src/server/conn/http1.rs:257-261
pub fn half_close(&mut self, val: bool) -> &mut Self {
    self.h1_half_close = val;
    self
}

默认 false。设为 true 时:

Clients can chose to shutdown their write-side while waiting for the server to respond. Setting this to true will prevent closing the connection immediately if read detects an EOF in the middle of a request.

14.3.1 什么是 half-close

TCP 是全双工连接——读写两个方向独立。调用 shutdown(SHUT_WR) 可以只关闭写方向,保留读方向。客户端可以:

  1. 发请求(写 header + body)。
  2. shutdown(SHUT_WR)——写方向 FIN,告诉服务端”我发完了,但我还想读响应”。
  3. 读响应。
  4. 收到完整响应后关闭 socket。

这是 HTTP/1 的一个合法(但少见)用法。HTTP spec 并没有禁止——但许多 HTTP server 会把 “读到 EOF” 解释成”连接关了,我也关”,即使读到的是半关信号。

14.3.2 h1_half_close 的作用

默认情况下 hyper 读到 EOF 就认为连接结束。但如果 h1_half_close = true,hyper 会知道”这只是对端写完了,我还得把响应写完”——继续处理请求并回响应。

这个选项在大多数 Web 场景下没用——浏览器、curl、reqwest 都不做 half-close。但在某些网关/负载均衡软件(HAProxy 的旧版本、某些自研 proxy)会用 half-close 来优化资源回收——那时候后端必须开 half_close 配合。

默认关的合理性:大多数情况下,读到 EOF 就是对端完全断开,继续处理是浪费。选择性开启应付特定基础设施。

14.3.3 set_allow_half_closepoll_read 的 EOF 处理分叉

h1_half_close=true 后、Conn 内部的 poll_read 遇到 EOF 的处理会走另一条分支(第 12 章讨论过 poll_read 的整体结构):

// 概念简化
match self.io.read() {
    Ok(0) => {
        // EOF
        if self.state.allow_half_close && self.state.is_mid_request() {
            // 半关场景:正在读 body、对端写方向关了、我们继续处理
            self.state.mark_peer_half_closed();
            Poll::Ready(None)  // body 读完、不关连接
        } else {
            // 默认场景:EOF 就是连接结束
            self.state.mark_closed();
            Poll::Ready(None)
        }
    }
    // ...
}

两个分支的差异仅仅是 “要不要把连接本身标 closed”——

  • 默认:EOF → connection closed → 响应写完就 bye
  • half_close:EOF → connection half-closed → 响应写完才整个 close

这个分支为什么要用户显式开?——因为默认情况下 “EOF = 对端全关” 的假设是 95% 正确的、省了判断开销。半关是个罕见特性、用户如果没信心遇到它、不开可以避免”错把半关当全关放走合法请求”的难以调试 bug。

state.is_mid_request() 的检查很关键——如果 EOF 发生在 请求之间(一次请求已完、下一次没来、对端先关)、那么不管 allow_half_close 如何、都是合法的关连接——不会走半关分支。半关只在 “正在读请求中间” 的 EOF 触发。这是 HTTP 语义和 TCP 语义的对齐:HTTP 请求是以 header + body 为单位、半关只在 body 阶段发生才有意义。

14.4 header_read_timeout:抵御 Slowloris

14.4.1 Slowloris 攻击

2009 年披露的经典 DoS 技术——客户端建立 TCP 连接,非常慢地发送请求头(每秒 1 字节、每 10 秒 1 字节)。server 认为这是一个”正在发请求的 client”,维持连接和 buffer 直到 request 完整。

攻击者开 1000 个这样的慢连接,很快占光 server 的连接池。Apache 2.x 在这方面曾经惨败(单台攻击 100 个慢连接就能打垮默认配置的 Apache)。Nginx 和 hyper 都有防御。

14.4.1.5 serve_connection 里的 “配置落盘” 顺序

打开 http1.rs:444-489、看 serve_connection 如何把 Builder 上的配置翻译成 Conn 的状态变更

pub fn serve_connection<I, S>(&self, io: I, service: S) -> Connection<I, S> {
    let mut conn = proto::Conn::new(io);
    conn.set_h1_parser_config(self.h1_parser_config.clone());
    conn.set_timer(self.timer.clone());
    if !self.h1_keep_alive {
        conn.disable_keep_alive();
    }
    if self.h1_half_close {
        conn.set_allow_half_close();
    }
    if self.h1_title_case_headers {
        conn.set_title_case_headers();
    }
    if self.h1_preserve_header_case {
        conn.set_preserve_header_case();
    }
    if let Some(max_headers) = self.h1_max_headers {
        conn.set_http1_max_headers(max_headers);
    }
    if let Some(dur) = self.timer.check(
        self.h1_header_read_timeout, "header_read_timeout",
    ) {
        conn.set_http1_header_read_timeout(dur);
    };
    if let Some(writev) = self.h1_writev {
        if writev { conn.set_write_strategy_queue(); }
        else { conn.set_write_strategy_flatten(); }
    }
    conn.set_flush_pipeline(self.pipeline_flush);
    if let Some(max) = self.max_buf_size {
        conn.set_max_buf_size(max);
    }
    if !self.date_header {
        conn.disable_date_header();
    }
    // ...
}

这段有三个设计上的讲究:

① “Some-then-apply” 模式——几乎所有配置都是 if self.field_is_not_default { conn.set_xxx(value) }。Conn 本身有内建默认、只在用户显式覆盖时调 setter。这就让 Conn 的 new() 函数保持简单、不需要接受 10+ 个可选参数——Builder 是配置的收集器、Conn 是状态的承载者、通过 “设置/默认” 的二段进行桥接。

timer.check(...) 的 panic 前置——check 方法在用户设置了 timeout 但没设置 timer 时直接 panic。这个检查serve_connection 调用时立即爆出、而不是等到 30 秒后 timer 要 fire 才发现没 timer——早失败原则的贯彻。错误信息会包含 "header_read_timeout" 这个字符串、告诉用户具体是哪个 timeout 缺 timer。

③ 顺序里的一条隐藏规则——disable_keep_alive 必须在 set_allow_half_close 之前?实际上无所谓顺序——每个 setter 都操作 Conn 上独立字段、互不影响。hyper 按 “keep_alive → half_close → header case → max_headers → timeout → writev → pipeline_flush → max_buf_size → date_header” 这个顺序排列、没有硬要求但有可读性意图——从”最基础的连接行为”到”最细的格式化细节”、按重要性递减展开。

14.4.2 hyper 的 header_read_timeout

// hyper/src/server/conn/http1.rs:338-347
pub fn header_read_timeout(&mut self, read_timeout: impl Into<Option<Duration>>) -> &mut Self {
    self.h1_header_read_timeout = Dur::Configured(read_timeout.into());
    self
}

默认 30 秒Duration::from_secs(30))。语义:客户端必须在 30 秒内发完整个请求头,否则连接被强制关闭。

注意这个超时只针对 header——一旦 header 读完,这个 timer 被重置。body 的读取由业务逻辑自己设超时(通过 Timeout 中间件)。

这个区分有道理——header 长度有上限(一般几 KB),正常客户端不可能花 30 秒发不完;body 可能是大文件上传,用户侧真的可能几分钟。把 header 和 body 的 timeout 分开让防御更精确。

14.4.2.5 set_http1_header_read_timeout 里 Conn 状态机的 timer 集成

header_read_timeout 生效后、Conn 会在读 header 的整个过程里持有一个 timer。伪代码:

// 进入读 header 阶段
let deadline = timer.sleep_until(Instant::now() + dur);
tokio::pin!(deadline);

loop {
    select! {
        // 读 header 数据
        result = self.io.read() => { ... }
        // timer 触发
        _ = deadline.as_mut() => {
            return Poll::Ready(Err(TimedOutError::HeaderRead));
        }
    }
}
// header 读完、timer drop

实际 hyper 实现不用 select!(太重)而是手动在 poll 里检查两个 future——但语义等价。关键点:

① Timer 是 per-connection 的——每条连接自己有一个 timer、不是全局计时。一条连接的 header 读完了、它的 timer 就被 drop、不再占资源。

② Timer 在 header 读完后被 drop——而不是 “挂着但暂停”。这样 body 阶段完全没有定时开销、符合 14.4.2 讲的 “header timeout != body timeout” 的原则。

③ Timer fire 时返回的 Error是一个特殊类型——告诉上层”这不是正常的 IO 错误、是超时触发的关闭”——便于 access log 里记录”X.X.X.X - slowloris attempt”。

timer 的集成没有全局调度、没有共享计时线程——完全依赖 tokio runtime 的 delay queue(或者用户注入的 Timer trait 实现)。这让 hyper 在非 tokio runtime(比如 monoio、smol)也能工作——前提是用户提供 Timer impl。

14.4.3 需要 Timer

/// Requires a [`Timer`] set by [`Builder::timer`] to take effect. Panics if `header_read_timeout`
/// is configured without a [`Timer`].

hyper 1.x 的设计原则之一是”runtime 无关”(第 1 章讨论过)——hyper 本身不绑定 tokio,不直接调 tokio::time::sleep。它通过 hyper::rt::Timer trait 抽象时钟能力,让用户注入具体实现:

use hyper_util::rt::TokioTimer;

http1::Builder::new()
    .timer(TokioTimer::new())
    .header_read_timeout(Duration::from_secs(30))
    .serve_connection(io, svc)
    .await

如果设了 header_read_timeout 但没设 timer——运行时 panic。这个”配置需要先决条件”的模式是 hyper 对”runtime 无关”的代价——用户得自己负责把 timer 注入。

在 hyper-util crate 里,hyper_util::server::conn::auto::Builder(第 19 章主角)会默认帮你注入 TokioTimer,省去这一步。但如果你直接用 http1::Builder,必须自己注入。

14.4.4 Timer trait 的三个方法

hyper 的 hyper::rt::Timer trait 设计:

pub trait Timer {
    fn sleep(&self, duration: Duration) -> Pin<Box<dyn Sleep>>;
    fn sleep_until(&self, deadline: Instant) -> Pin<Box<dyn Sleep>>;
    fn reset(&self, sleep: &mut Pin<Box<dyn Sleep>>, new_deadline: Instant);
}

pub trait Sleep: Future<Output = ()> + Send + Sync + 'static {}

三个方法对应 timer 的三种用法:

  • sleep(dur) ——从现在起经过 dur
  • sleep_until(deadline) ——到某个具体时刻
  • reset(sleep, new_deadline) ——重置已有 timer 的 deadline、不重新分配

reset 方法是性能关键——因为 hyper 在 keep_alive_idle 之类场景(未来可能加)要反复重置同一个 timer 的 deadline。如果每次都 drop 旧 timer、创建新 timer、会触发 delay queue 的 O(log n) 插入+删除。reset 允许原地修改 deadline、在 tokio 里对应 Sleep::reset 的 O(1) 操作。

Pin<Box<dyn Sleep>> 的类型选择——Sleep 是动态分发的(Box dyn)、因为 hyper 不想 monomorphize timer 类型(会让代码体积膨胀)。Box 的代价是一次堆分配每 timer——但 timer 创建频率不高、这个代价可接受。

hyper-util 的 TokioTimer 实现

impl Timer for TokioTimer {
    fn sleep(&self, duration: Duration) -> Pin<Box<dyn Sleep>> {
        Box::pin(TokioSleep {
            inner: tokio::time::sleep(duration),
        })
    }
    // ...
}

tokio::time::sleep 包一层、满足 trait。用户如果要用 monoio、smol——自己写类似的 wrapper 即可。这种”小 trait + 多种实现”是 Rust 抽象异构 runtime 的惯用手法——和 AsyncRead/AsyncWrite、Service/Runnable 一脉相承。

14.5 max_headers:内存 + 安全

// hyper/src/server/conn/http1.rs:335-338
pub fn max_headers(&mut self, val: usize) -> &mut Self {
    self.h1_max_headers = Some(val);
    self
}

默认 100。超过返回 431 Request Header Fields Too Large

14.5.1 栈分配 vs 堆分配

文档里说得清楚:

Note that headers is allocated on the stack by default, which has higher performance. After setting this value, headers will be allocated in heap memory, that is, heap memory allocation will occur for each request, and there will be a performance drop of about 5%.

默认的 100 个 header slot 是栈分配——[httparse::EMPTY_HEADER; 100]。用户 override 后就变成动态大小——必须堆分配(Vec::with_capacity)。

所以 max_headers(50) 不一定比默认 100 快——反而可能慢 5%,因为切换到堆分配路径。真的想省内存,是降低 max_buf_size(控制整条连接的 buffer),不是降 max_headers。

14.5.1.5 [httparse::EMPTY_HEADER; 100] 为什么能栈分配

§14.5.1 提到默认 100 个 header slot 是栈分配、用户 override 会切到堆。根本原因在于 Rust 数组类型的大小必须在编译期已知:

// 能栈分配:大小固定
let headers: [Header; 100] = [httparse::EMPTY_HEADER; 100];

// 不能栈分配:大小运行时决定
fn parse(max: usize) {
    let headers: [Header; max] = ...;  // ❌ 编译错
    let headers: Vec<Header> = Vec::with_capacity(max);  // ✓ 堆分配
}

hyper 源码里这段代码的分叉

// 伪代码
if let Some(max_headers) = self.h1_max_headers {
    // 用户覆盖 → 运行时决定 → 只能走 Vec
    let mut headers = Vec::with_capacity(max_headers);
    ...
} else {
    // 用户不覆盖 → 100 是编译期常量 → 栈数组
    let mut headers = [httparse::EMPTY_HEADER; 100];
    ...
}

栈数组的优势是零分配、零 free、cache-local;Vec 的代价是一次 malloc + 一次 free + 一次堆分配的 cache miss。在 parse 一个 HTTP 请求这种每连接每请求都做一次的热路径、这个差异累积起来不可忽视——文档里的 “5% 性能下降” 就是这么来的。

用户如果真的想省内存(从 100 降到 50)怎么办?答案是接受 5% 的性能损耗——因为 Rust 的类型系统不允许”运行时决定的数组也走栈分配”。如果未来 Rust 支持 [T; const N: usize]带参数栈数组、这个限制可能消失、但现在没办法。

这个设计细节告诉我们:Rust 编译期类型系统对性能有深远影响——看起来只是配置一个数字、背后却牵涉”栈 vs 堆”的路径分叉。hyper 的文档把这个事实写出来、让用户做有知情的权衡——比 nginx/apache 这种”配就完了、底层自己搞定”的 C 生态透明得多。

14.5.2 攻击场景

攻击者发一个有 10000 个 X-Evil-N: value\r\n 头的请求。没上限的话服务端会分配 10000 * 32 bytes = 320KB 的 header 数组(加上 Bytes refs 更多)。几千个这样的连接——内存就吃爆了。

max_headers = 100 在这里当了硬限——第 101 个 header 直接拒绝返回 431。配合后面讲的 max_buf_size 形成两层防线。

14.5.3 set_http1_max_headers 对 parser 行为的传导

conn.set_http1_max_headers(max_headers) 最终把这个值传给 httparse parser——在 parse 函数调用时分配 headers: &mut [httparse::EMPTY_HEADER; max] 数组:

// httparse::Request::parse 的大致签名
pub fn parse<'headers>(
    &mut self,
    buf: &'b [u8],
    headers: &'headers mut [httparse::Header<'b>],
) -> Result<Status<usize>>;

注意 parse 接受的是 &mut [Header]——切片、长度固定。hyper 在调用前先分配数组:

// 默认路径
let mut headers = [httparse::EMPTY_HEADER; 100];
req.parse(&buf, &mut headers)?;

// 用户覆盖路径
let mut headers = vec![httparse::EMPTY_HEADER; user_max];
req.parse(&buf, &mut headers)?;

httparse 本身零分配——它只接受已有的 &mut [Header] 切片、从用户分配的内存里填。这是 httparse 能这么快的根本原因——所有工作都在用户提供的 buffer 里做、parser 自己不碰堆。

hyper 的 h1_max_headers 配置只是改变 hyper 这一层的数组分配方式、httparse 层无感知。用户调 100 还是 50、对 httparse 是透明的——它只知道”这里有 N 个 slot”。

这种 “parser 零分配 + caller 管内存” 是 Rust 库设计的重要模式——和 serde 的 DeserializeSeed、nom 的 nom_str 都一脉相承。把堆分配的决定权交给 caller、让高级用户可以用栈、普通用户可以用 Vec、都不改 parser 自己。

14.6 max_buf_size:连接级 buffer 上限

// hyper/src/server/conn/http1.rs:366-380
pub fn max_buf_size(&mut self, max: usize) -> &mut Self {
    assert!(
        max >= proto::h1::MINIMUM_MAX_BUFFER_SIZE,
        "the max_buf_size cannot be smaller than the minimum that h1 specifies."
    );
    self.max_buf_size = Some(max);
    self
}

默认约 400 KB。最小不能小于 MINIMUM_MAX_BUFFER_SIZE8192 bytes)——小了 HTTP 基本功能会崩(单个大 header 塞不下)。

这个值限制整条连接的读写 buffer 上限。当 buffer 超过此值:

  • 读:hyper 停止从 socket 读——造成 TCP flow control 向客户端反压。
  • 写:hyper 停止从 body 里拉 frame——造成 Body::poll_frame 挂起。

这是连接级的背压阀门。没有这个阀门,恶意 client 可以:

  • 发一个带 10MB chunked header 的请求(或者每行 header 99KB、总共 200 行)——buffer 被迫膨胀到几十 MB。
  • 开几千条这种连接——内存爆掉。

400KB 是一个 sensible default——正常 header 撑死几十 KB,给应用的写 buffer 也够。生产服务通常不需要调,除非你需要支持极大单个 header(罕见)。

14.6.1 assert!(max >= MINIMUM_MAX_BUFFER_SIZE) 的硬下限

http1.rs:373-380max_buf_size 有一个编译时无法验证、但运行时立即 panic的下限:

pub fn max_buf_size(&mut self, max: usize) -> &mut Self {
    assert!(
        max >= proto::h1::MINIMUM_MAX_BUFFER_SIZE,
        "the max_buf_size cannot be smaller than the minimum that h1 specifies."
    );
    self.max_buf_size = Some(max);
    self
}

MINIMUM_MAX_BUFFER_SIZE = 8192。用户如果调 max_buf_size(4096)——立即 panic、还在 Builder 构造阶段、早到不能再早。

为什么选择 assert 而不是 Result?——因为这是显而易见的编程错误、不是运行时意外。HTTP/1 的单个请求行(request line + single header)本身可能几百字节、如果 buffer 不到 8KB、parser 根本无法正确工作——这是逻辑错误、不是”业务逻辑可以处理的失败”。

Rust 的惯例是:bug 用 panic、业务失败用 Result——这里符合 bug 的定性。用户写 max_buf_size(100) 是不可能合理的、直接让程序不能启动比让它跑起来然后无法处理请求好得多。

assert! 的 message 里说清楚了原因——“cannot be smaller than the minimum that h1 specifies”——让错误信息足够自解释。这和 §14.1.1 讨论的 Dur 三态一样、是 hyper 对”配置错误要在早期大声报出”的一以贯之的态度。

这种硬检查在 Rust 生态里很常见——类似 tokio 的 Builder::worker_threads(0) panic、rustls 的 ClientConfig 空 trust store panic。拒绝让配置明显错误的程序启动、比让它跑起来再以模糊错误消息挂掉强得多。

14.6.2 set_max_buf_size 的双向生效:读和写的同一个阀门

max_buf_size 虽然是单一字段、但在 Conn 内部同时约束读和写两个方向:

// 读侧
fn poll_read(&mut self, cx: &mut Context) -> Poll<io::Result<()>> {
    if self.io.read_buf().len() >= self.max_buf_size {
        return Poll::Pending;  // 缓冲区满、停止读、TCP 反压
    }
    // ... 正常 read
}

// 写侧
fn poll_flush(&mut self, cx: &mut Context) -> Poll<io::Result<()>> {
    if self.io.write_buf().len() >= self.max_buf_size {
        return Poll::Pending;  // 写缓冲满、暂停从 body 拉数据
    }
    // ... 正常 flush
}

一个阀门同时控制两个方向、让用户不需要分别设 max_read_bufmax_write_buf。配置体验简单、但也有代价——

代价是不能对读 / 写单独调参。如果一个服务典型请求头小(不需要大 read buf)但响应大(需要大 write buf 缓冲 body)、用户只能把 max_buf_size 调到写侧需要的大小、读侧被迫跟着宽。

这个简化的合理性——在 HTTP/1 场景、读 / 写 buf 的量级差不多(都在几十 KB 到几百 KB)。拆成两个参数增加复杂度但很少有真实收益。hyper 选了简单。HTTP/2(第 15-17 章)就不同了——流控窗口、帧 buffer、header 压缩 table 各自有独立上限——那是另一个复杂度等级的协议

14.7 writev:vectored I/O 的取舍

// hyper/src/server/conn/http1.rs:358-362
pub fn writev(&mut self, val: bool) -> &mut Self {
    self.h1_writev = Some(val);
    self
}

三态:Some(true)(强制 vectored)、Some(false)(禁用)、None(auto,默认)。

14.7.1 vectored 是什么

writev / write_vectored 是一个系统调用——一次提交多个不连续的 buffer,内核把它们 scatter-gather 写到 socket,无需用户层先 memcpy 成一个连续 buffer。

hyper 大量用这个——response header、chunked size、body 数据、\r\n 分隔符常常是四个独立 buffer,用 writev 一次提交。

14.7.2 为什么要选 auto?TLS 的副作用

TLS(rustls、native-tls)在上层包一层加密——它收到 vectored write 后要把所有 buffer 拷贝到一个连续 buffer 里加密,然后 write 加密数据。等于 hyper 避免的 copy 被 TLS 重新做了一次——还多了一层 encrypted copy。

对 plain TCP,vectored 是纯净收益。对 TLS,vectored 往往反而更慢(因为 TLS 层不得不先合并)。

hyper 的 auto 模式:运行时探测 IO 对象是否支持 “real vectored write”——不支持就切成 flatten 写。这是通过 AsyncWrite::is_write_vectored() 的 tokio API 实现。rustls 的 AsyncWrite 正确返回 false——让 hyper 不走 vectored 路径。

一般不用手动设。只有当你用自研 TLS 库、或者底层是非标准 IO,并且 benchmarks 证明 vectored 有问题时才考虑。

14.7.3 set_write_strategy_queue vs set_write_strategy_flatten

h1_writev 的 Some(true)/Some(false)/None 在 serve_connection 里的分发(http1.rs:476-482):

if let Some(writev) = self.h1_writev {
    if writev {
        conn.set_write_strategy_queue();        // 强制 vectored
    } else {
        conn.set_write_strategy_flatten();      // 强制 flatten
    }
}
// None 时不调用、Conn 内部自己决定

两种策略对应 Conn 里的两种 write 路径:

  • Queue strategy:维护一个 VecDeque<Chunk>、每次 poll_flush 调用 write_vectored(&[slice1, slice2, slice3]) 一次提交所有 Chunk。
  • Flatten strategy:把所有 Chunk extend_from_slice 到一个 BytesMut、然后 write(&single_buf)

Auto 模式(None)在 Conn 内部走一条运行时探测分支——调 self.io.is_write_vectored()、如果返回 true 就用 queue、返回 false 就用 flatten。

为什么 is_write_vectored() 是 trait 方法而不是 runtime 检测?——因为判定标准不是”系统有 writev 系统调用”(所有主流 OS 都有)、而是”这个 IO 对象是否能把 vectored 高效地传下去”。rustls 的 TlsStream 虽然底层包了 TcpStream(支持 vectored)、但它自己的实现会把 buffer 合并加密——所以它的 is_write_vectored() 返回 false、告诉上层”别给我发 vectored、反正我要合并”。

这就是 Rust 的 trait system 精妙之处——实现者自己知道自己能做什么、通过 trait 方法把这个知识传给上层、上层据此选最优路径。不需要中心化的 capability 检测、不需要硬编码什么库走什么分支。

这个设计思路和 §15.4.6.5 讲的 vite ensureBuiltins 跨进程协商本质相似——都是”信息的归属原则”:每个组件应该只回答自己最有权威的问题。TLS 层最知道 “我能不能 vectored”、vite 服务端最知道”哪些算 builtin”——把这些知识以 trait 方法 / RPC 暴露给需要的人。

14.8 超时矩阵:生产服务必备

一个生产级 HTTP 服务的超时不是一个数字——是一张矩阵。以下是完整版:

层级保护对象典型配置失效后的症状
TCP已建立连接的网络可达性SO_KEEPALIVETCP_USER_TIMEOUT连接半死不活、文件描述符长期占用
HTTP 连接请求头读取与连接生命周期header_read_timeout、外部 idle watchdogSlowloris、idle 连接堆积
请求单次业务调用耗时tower::timeout::Timeout慢后端拖住 worker、p99 被放大
Body请求/响应体读写http_body_util::Limited、应用层 timeout大 body OOM、流式响应占住资源

14.8.1 TCP 层

  • TCP_USER_TIMEOUT:socket 发出数据后多久没 ACK 就放弃(Linux 专用)。默认跟系统 TCP retries,可能 15-20 分钟。生产建议设 10-30 秒。
  • SO_KEEPALIVE + TCP_KEEPIDLE/INTVL/CNT:NAT 连接保活。TCP_KEEPIDLE=60 + KEEPINTVL=30 + KEEPCNT=3 大约 2 分半检测到死连接。
  • SO_RCVTIMEO/SNDTIMEO:read/write 系统调用超时。Tokio runtime 一般不需要——自己用 timeout future 包。

14.8.2 HTTP 连接层(hyper 配置)

  • header_read_timeout:30 秒(默认)。防 Slowloris。
  • keep_alive_idle_timeout:hyper 没有直接的配置!idle 连接不会自动关。客户端实现(第 20 章的 connection pool)有自己的 idle timeout,服务端要靠 外部 watchdog(你的业务代码或者 reverse proxy)关 idle 连接。

为什么 hyper server 不自带 idle timeout?因为”idle timeout 是部署策略问题”——proxy 层(Nginx / HAProxy / Envoy)已经有自己的 idle 策略,hyper 做会重复。自建 hyper server 直接暴露到公网的用户可以用 tower 中间件或自己轮询 live connections 来实现。

14.8.3 请求层(tower 中间件)

  • tower::timeout::Timeout:每请求的 wall-clock 超时。第 5 章读过源码——tokio::time::sleep + business future 赛跑。
  • tower::limit::rate::RateLimit:单位时间请求数。
  • tower::limit::concurrency::ConcurrencyLimit:并发 in-flight 数。

14.8.4 Body 层(应用代码)

  • 读 body 超时:应用代码里 tokio::time::timeout(dur, body.collect()).await?
  • 写 body 超时:基本同上,但写超时往往由 TCP 层的 write timeout 触发。
  • 体积限制http_body_util::Limited(第 10 章)。

14.8.5 典型配置实录

一个生产配置的示例:

use std::time::Duration;
use hyper_util::rt::TokioTimer;

let http = hyper::server::conn::http1::Builder::new();
http.timer(TokioTimer::new())
    .header_read_timeout(Duration::from_secs(15))    // Slowloris 保护
    .max_headers(50)                                  // 降低而不改默认的理由?略
    .max_buf_size(256 * 1024)                         // 256KB,节约
    .keep_alive(true);                                // 默认

加上 tower 栈:

let svc = tower::ServiceBuilder::new()
    .timeout(Duration::from_secs(60))                // 每请求 60s
    .layer(http_body_util::Limited::layer(10 * 1024 * 1024))  // body 最大 10MB
    .service(router);

再加上 tokio 的 TCP 层:

let socket = tokio::net::TcpSocket::new_v4()?;
socket.set_keepalive_params(..)?;   // TCP keepalive

这三层合起来,构成 “连接级 → 请求级 → body 级” 三重防护。一个合格的生产 Rust HTTP server 的超时矩阵就长这样。

14.8.6 pipeline_flush 的实验性质

14.1 节列出的 Builder 字段里、有一个被标 “Experimental, may have bugs” 的:

/// Aggregates flushes to better support pipelined responses.
///
/// Experimental, may have bugs.
///
/// Default is `false`.
pub fn pipeline_flush(&mut self, enabled: bool) -> &mut Self {
    self.pipeline_flush = enabled;
    self
}

HTTP/1.1 pipelining 允许客户端在上一个请求响应回来前连续发多个请求——服务端按顺序处理并回响应。2020 年前的浏览器基本放弃了这个特性(HTTP/2 替代它)、但某些 REST 客户端库和内网服务还在用。

pipeline_flush=true 改变了 hyper 在 pipelined 场景下的 write 行为:不是每个响应都立即 flush、而是聚合多个响应一次 flush——减少 syscall 数。理论上节约 TCP 发送的 overhead。

为什么标实验性?——因为聚合 flush 在超时/半关闭边界有一些复杂交互:

  • 第一个响应写完想 flush、但 hyper 说”等等、看看第二个请求的响应能不能一起来”——这时客户端的 request timeout 可能已经开始计时。
  • 下一个请求还没到、但上一个响应被扣着没发——客户端等超时。

解决这些问题需要很细致的状态机工程——hyper 团队没把这个特性投入生产级打磨、就标成 experimental 让胆大的用户开。

这种 “把实验性功能暴露出来但标注清楚” 的做法是 Rust 生态的典型——比默默合入或者偷偷砍掉都好。用户可以在需要时开起来、同时知道要承担的风险。

14.8.7 auto_date_header 的 RFC vs 性能取舍

默认 true

/// Set whether the `date` header should be included in HTTP responses.
///
/// Note that including the `date` header is recommended by RFC 7231.
///
/// Default is `true`.
pub fn auto_date_header(&mut self, enabled: bool) -> &mut Self {
    self.date_header = enabled;
    self
}

RFC 7231 第 7.1.1.2 节要求 server 在大多数响应里带 Date header(除非 5xx 错误或 1xx 信息性响应)。hyper 默认开、合规。

关掉有什么场景?——两种:

  1. 极端性能追求:生成 Date 字符串需要 SystemTime::now() + 格式化、每次响应一次——对纳秒级延迟的服务(HFT 转发、低延迟代理)这是开销。
  2. 代理场景:前面有 L7 proxy(CloudFront、Cloudflare、Nginx),proxy 会加自己的 Date、hyper 再加就是重复——关掉避免冗余。

hyper 在 Conn 内部对 Date 做了秒级缓存——同一秒内多个响应复用同一个字符串、避免重复格式化。实际性能开销低于直觉——但对极致场景仍然可见、所以提供 toggle。

这和 §14.1.1 讲的”缺省选 RFC、选项留给例外”是一个模式——规范遵循 + 工程逃生舱。默认别人说你合规、专业用户想关时你给他关。

14.8.8 为什么 hyper 不内建 keep_alive_idle_timeout

§14.8.2 提过 hyper server 没有 idle timeout、要求用户在 proxy 或业务代码里做。这个设计选择值得再深挖——因为很多用户第一眼就会问”为什么不加一个?十行代码的事”。

hyper 不做的理由有三层:

① 定义”idle”本身就有分歧——是”TCP 上 0 字节流动”?还是”没有 in-flight 请求”?还是”keep-alive 连接等下一个请求超过 N 秒”?不同 use case 的定义不同、hyper 选任一个都会让另一部分用户不爽。

② idle 策略和部署架构强相关——有 Nginx 在前、Nginx 已经管 idle;直连到内部服务、可能不需要太严的 idle;SSE/WebSocket 的连接本来就是”长 idle”、强制关会破坏功能。hyper 作为底层库、不应该替用户做这种”取决于场景”的决策

③ 用户实现 idle 监控并不难——维护一个 Arc<Mutex<HashMap<ConnId, LastActive>>>、每处理一个请求更新 timestamp、另一个 task 定时扫 map 关掉过期的。几十行代码、完全可控——比把逻辑塞进 hyper 更灵活。

这三条加起来就是 hyper 作为 “Rust HTTP 协议层” 的定位——它是 protocol parser + state machine、不是 full-fledged web server。full-fledged 的事(TLS 终止、idle 管理、流量限制、auth)都由上层栈(axum、tower-http、proxy)完成。hyper 坚持自己的边界、让生态可以在其上自由组合。

对比 Go net/httpIdleTimeout——Go 标准库要承担”一把梭”的角色、所有特性都得有。Rust 的哲学不一样——每一层只做一层的事。这也解释了为什么 Rust 生态的”完整方案”要多个 crate 组合(hyper + hyper-util + axum + tower)——对 Rust 新手门槛高、但对老手灵活度无敌。

14.9 一次真实事故:idle 连接堆积

一个生产事故的匿名复盘:

某团队的 Rust gRPC 网关部署在 K8s 上,每周三凌晨 2 点监控会报 FD 数量上涨。不是流量涨——是 ESTABLISHED 状态的 TCP 连接数从 200 上涨到 10000。客户端是 Android 设备,每个设备建一个长连接发心跳。

根本原因是 NAT timeout:客户端位于移动运营商的 NAT 后面,运营商的 NAT 表 30 分钟无流量就丢 entry。客户端从 NAT 角度”消失”了,但 hyper server 不知道——它还持有这个 TCP 连接,以为是空闲 keep-alive。

解决方案有三个层次:

  1. Linux TCP keepalive:socket 级 keepalive 会在 2 小时后触发——太慢,NAT 表早丢了。
  2. HTTP 层心跳:业务层的 gRPC PING(HTTP/2 专属,第 17 章讲)——这是最可靠的方法,但实施要修改协议。
  3. 短 idle timeout:在 server 侧每 5 分钟扫 live connections,关掉过期的——补救方案,但是 hyper server 没内建,团队最终自己写了个 watchdog。

这个事故的教训:hyper server 不自带 idle connection 清理——你得自己想这件事。而客户端(如 reqwest)有 idle pool cleanup——第 20 章会读。

14.9.5 title_case_headerspreserve_header_case:两个看似多余的选项

Builder 有两个关于 header 大小写的选项:

h1_title_case_headers: false,     // 写出时转换成 Title-Case (Content-Type)
h1_preserve_header_case: false,   // 保留原始请求的 case

HTTP/1.1 规范:header name case-insensitive(不区分大小写)——按规范、content-typeContent-Type 等价。hyper 内部统一 lower case 存储、性能最优(HashMap 查找可以按小写 key 走)。

为什么还有这两个选项?——因为真实世界里有些奇葩的基础设施不遵守规范

  • Windows IIS 的旧版本(2003 Server 时代)对某些 header 的大小写敏感——小写 header 会被视为无效。hyper 作为客户端连这类老 server、必须开 title_case_headers
  • 某些遗留的 B2B 集成(银行/电信的老系统)签名验证会把 header 大小写计入 hash——必须保留原始 case。
  • 日志/审计需求:某些合规场景下需要把 header 按原样存档——preserve_header_case 允许这种保真。

这两个选项默认关——因为 99.9% 的 HTTP 服务都符合规范、开了白白花 CPU 做 case 转换。只有遇到老系统时才开。

这体现了 hyper 对”不丢弃兼容性”的执着——即使某个需求只有 0.1% 的用户遇到、也提供逃生舱。这种长尾兼容性是基础库能被广泛采用的关键——没有人喜欢一个”能做 99% 的事、但剩下 1% 让你完全卡住”的库。

title_case_headers 实现上还有细节:hyper 不是每次请求都做 case 转换、而是在 Conn 上标记一次、之后所有 outgoing header 都走 title-case 编码路径。这又是”启动期一次性决定、稳态零开销”的同样优化思路——和 §15.4.6.5 vite 的 ensureBuiltins、§8.5.0.5 vllm 的 bisect 是同一脉工程直觉。

14.9.6 与第 12 章 Dispatcher、第 13 章 http-body 的呼应

与第 12 章(Dispatcher)的呼应——本章讨论的 header_read_timeout 落到 Dispatcher 里就是 poll_read_keep_alive 中的 timer 检查(第 12 章展开)。本章讲配置、第 12 章讲执行——两者合起来才是完整的”slowloris 防御链”。

与第 13 章(http-body)的呼应——本章 §14.8.3 提到的 Limited::layer(10MB)http_body_util 的 body 限流中间件(第 10 章深入)。**连接级超时(本章)+ body 级限流(第 10 章)+ 请求级 Timeout(第 5 章)**构成三层防护、每一层职责清晰、在不同粒度上防御不同攻击。

与 vite 第 15 章的呼应——hyper 的 h1_header_read_timeout 和 vite 的 debug?.(...) 2s 都是”时间驱动的诊断/防御”——前者是硬防御(超时就关)、后者是软诊断(超时就警告)。两种使用方式不同、但都把 timer 作为观测 + 干预的一等工具。

14.10 与 Nginx / Go 的对照

Nginx:

  • keepalive_timeout 65; ——连接 idle 65 秒后关。
  • client_header_timeout 10s; ——请求头超时。
  • client_body_timeout 10s; ——body 读超时。
  • send_timeout 60s; ——write 超时。
  • 全部在 nginx.conf 里,一键配置。

Go net/http:

  • server.IdleTimeout = 120*time.Second ——idle 关。
  • server.ReadHeaderTimeout = 10*time.Second
  • server.ReadTimeout = 30*time.Second
  • server.WriteTimeout = 30*time.Second

hyper:

  • 除了 header_read_timeout,其他全部靠外部 Tower 中间件或业务代码
  • 更灵活(任意组合)、学习曲线更陡(不知道的人会漏掉)。

这三者的取舍体现不同语言生态的气质——Nginx 是配置至上、Go 是内建至上、Rust 是组合至上。没有谁更好——用 Rust 做 HTTP 服务的人必须显式知道超时矩阵的存在。这也是为什么本书值得读——很多 Rust 生产事故源于”默认很宽松,没人主动去改”。

14.10.5 h1_parser_config: httparse::ParserConfig 的来源

Builder 第一个字段:

pub struct Builder {
    h1_parser_config: httparse::ParserConfig,
    ...
}

httparse 是 hyper 用的 HTTP/1 解析器(第 11 章深入过)——零拷贝、极高性能。httparse::ParserConfig 允许用户调整一些严格性开关:

pub struct ParserConfig {
    /// 是否允许 URI 里有 `\n` / `\r`(通常该拒绝、作为 HTTP smuggling 防御)
    ignore_invalid_headers_in_responses: bool,
    /// 是否允许 obsolete line folding(RFC 7230 标注为 deprecated)
    allow_spaces_after_header_name_in_responses: bool,
    // ...
}

hyper 把这个 config 原样塞进 Builder、允许高级用户微调 parser 的严格性。为什么要这么开放?——因为 HTTP/1 规范有一些”该拒绝的 malformed input” vs “必须兼容的历史畸形 input”的张力:

  • 遇到 Google 某些内部服务返回的非标响应、严格 parser 会拒绝、连不上
  • 遇到恶意 smuggling 攻击、宽松 parser 会放行、有安全风险

hyper 自己不替用户选边——把 httparse 的配置直接暴露、让用户根据自己的场景调。如果你连的是现代服务、默认严格;如果你连的是祖传 NFS 网关、可能需要开某些宽松选项。

这种”把上游库的配置原样传递”是 Rust 生态一个常见模式——Builder 不重新设计一层 “hyper 的解析配置”、直接 expose httparse::ParserConfig。好处是保持一致的文档和语义——用户查 httparse 文档就能看懂、不需要学两遍。代价是绑定了上游 API——httparse 升级可能要求 hyper 跟进。这是工程上的常见权衡。

14.11 落到你键盘上

  • 现在就检查你的 hyper-based 服务的配置header_read_timeout 有没有设?tower::timeout 有没有套?Limited::layer 有没有保护 body?
  • 压一次 Slowloris 测试:用 slowhttptest 工具对你的服务发慢请求——看 header_read_timeout 是否真的生效。
  • hyper_util::server::conn::auto::Builder——它把 http1 + http2 Builder 包在一起,帮你自动注入 TokioTimer / TokioExecutor。实际生产代码应该用这个 Builder 而不是直接用 http1::Builder。

下一章我们开始讲 HTTP/2——从 h2 crate 和 HPACK 开始,理解二进制 frame 和流控窗口如何改变整个协议模型。

14.12 配置项背后的八条工程直觉

把本章所有源码观察压缩成 hyper 做 Builder 配置的八条工程直觉:

① 缺省值是工程经验的压缩(§14.1.1)——每个默认值都有一个”为什么不是别的”的理由。

② 三态枚举优于 Option(§14.1.2)——Dur::Default/Configured/Empty 表达了 Option 不能表达的用户意图。

③ 早失败胜过晚失败(§14.4.1.5)——timer 缺失、buffer 过小、立即 panic 而不是等到运行时 30 秒后爆。

④ 类型系统决定性能路径(§14.5.1.5)——“100 个栈数组 vs N 个 Vec”是 Rust 编译期常量 vs 运行时大小的自然分叉。

⑤ assert 拦截逻辑错误(§14.6.1)——max < 8192 是 bug 不是业务失败、panic 合理。

⑥ trait 方法传递实现者知识(§14.7.3)——is_write_vectored() 让 TLS 层告诉 hyper “别给我 vectored”。

⑦ 实验性 + 文档警示(§14.8.6 pipeline_flush)——暴露但标注、让用户做有知情的选择。

⑧ 原样 expose 上游 config(§14.10.5)——httparse::ParserConfig 直接塞进 Builder、不重新包装。

这八条原则跨越”默认策略、类型设计、错误处理、性能优化、向下兼容、实验管理、接口设计”七个维度——每一条单独看都平淡、合起来就是 hyper 作为 Rust HTTP 生态基石令人信任的根源。

这就是工业级库和玩具项目的区别——不是功能更多、而是每个功能的每个 knob 都被工程师反复权衡过。你把 hyper 的 Builder 和你自己写过的”配置类”对比——多半会发现几个你漏掉的考虑。读这一章、就是把这些考虑吸收到自己的设计本能里——下次你设计一个带超时、带 buffer 限制、带运行时无关性的库时、心里就有一张清单可对照。