Skip to content

第13章 hyper 的 Service trait:为什么 1.0 不复用 tower::Service

13.1 一个看起来不合理的决定

读到这里你可能产生一个疑问:

Tower 已经把 Service 这个抽象做成了整个 Rust 异步生态的事实标准——Tonic、Axum、reqwest、Linkerd 全部用它。hyper 1.0 居然不用这个 trait,而是自己定义了一个几乎一模一样但不一样hyper::service::Service?这不是在浪费精力、分裂生态吗?

表面看确实。两个 trait 摆在一起:

rust
// tower-service/src/lib.rs
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>)
        -> Poll<Result<(), Self::Error>>;

    fn call(&mut self, req: Request) -> Self::Future;
}

// hyper/src/service/service.rs
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn call(&self, req: Request) -> Self::Future;
}

只有两处差别:

  1. &mut self vs &self(call 方法)
  2. poll_ready vs 没有 poll_ready

两处差别看起来都很小。但 hyper 团队花了三年时间、三十多条长讨论、一次社区投票,最终决定在 hyper 1.0 里用 tower::Service——而是定义了自己的版本。

这一章我们把这个决定的背后完整展开——不是"谁对谁错",而是**"为什么两者都对,只是服务不同场景"**。读完你对 Rust 类型系统和协议设计的理解会更深一层。

13.2 先看结论:两个 trait 的分工

一句话:

  • tower::Service 面向业务组合层——它要支持中间件堆叠、背压传递、容量控制。poll_ready 是它的灵魂。
  • hyper::Service 面向协议连接层——它要支持 HTTP/2 多路复用、并发调用、异步 fn 原地使用。&self 是它的灵魂。

中间由 hyper_util::service::TowerToHyperService 适配器桥接。任何 tower 世界里的 Service<Request> 都能变成 hyper 能接的 hyper::Service<Request>——只是在桥接点,tower 的 poll_ready吞掉

要理解这个分工为什么是对的,我们要从最具体的技术困境讲起。

13.3 困境一:HTTP/2 的并发调用

想象一个 HTTP/2 server 的场景:

  • 一条 TCP 连接上复用着 50 个 stream(HTTP/2 默认最多 100 个并发 stream)。
  • 每个 stream 是一个独立的 HTTP 请求-响应。
  • server 侧收到 DATA frame 时,要立刻路由到对应 stream 的 Request 处理器
  • 这些处理器同时进行——一个 stream 在处理 100KB 的 body upload,另一个 stream 在等数据库查询,它们应该并发。

所以 server 需要在同一个 Service 实例上同时调用多次 call——这就是 HTTP/2 多路复用的本质。

13.3.1 &mut self 强制串行

现在看 tower::Service::call(&mut self, req)&mut self 意味着"独占访问"——同一时间只有一个 call 能跑。

如果我们强行让 hyper HTTP/2 server 接受 tower Service,50 个并发 stream 必须排队调用 call

rust
// 错误的幻想:
// stream 1 进来,svc.call(req1) 被调用,svc 被独占
// stream 2 进来,等 svc 空闲
// stream 3 进来,等
// ...

结果:HTTP/2 多路复用退化成串行。你买了一条 10Gbps 的光纤、开了 HTTP/2、做了所有 TLS 优化——但 server 一次只能处理一个请求。这和 HTTP/1.0 没有区别。

13.3.2 Clone 不是万能解药

有人会说:"Service 不是通常 Clone 吗?可以给每个 stream 一个 clone。"

对,也不对。

  • 如果 Service: Clone,确实可以给每个 stream 预分配一个 clone——但谁来 clone、什么时候 clone?每次 accept 新 stream 时 clone?那 clone 本身可能很重(Axum 的 Router clone 很便宜,但如果 Service 里有一个 Arc<Config>,clone 是 Arc::clone,OK;但如果 Service 里有一个 HashMap<String, Handler>——clone 就是深拷贝,昂贵)。
  • 更糟糕的是:如果 Service 不 Clone(例如它持有一个 tokio::sync::Mutex、一个 Receiver<T>、或者一个不可 Clone 的 DB 连接),HTTP/2 server 根本建立不起来——类型系统直接拒绝。
  • 即使 Service 能 Clone,每个 clone 的 poll_ready 状态独立(第 4 章讨论过 ConcurrencyLimit 的 Clone 实现)——意味着 50 个 stream 各自 clone 各自 poll_ready,丧失"一份统一资源管理"的意义。

这是一个架构问题,不是 clone 成本问题。&mut self 的语义就是"独占"——强行在多 stream 场景下使用,要么串行化(丢掉 HTTP/2 的意义),要么 clone(丢掉共享状态的意义)。两条路都错。

13.3.3 &self 直接支持并发

hyper::Service::call(&self, req) 只要 &self——不独占。同一个 Service 实例可以被多个并发 call 同时引用。

这意味着:

  • HTTP/2 server 只需要一个 Service 实例。所有 stream 都借用 &self 发起 call。
  • Service 内部如果有可变状态,放 Arc<Mutex<T>>AtomicXxx、或者 RwLock<T>——Rust 自然会提醒你"这里要加同步"。
  • 不强迫 Clone。不 Clone 也能跑。

代价当然是:你在 call(&self, ...) 里不能直接 self.counter += 1。必须用 Atomic::fetch_add 或者 Mutex::lock()。但这正是"并发调用"需要的——借用检查器强制你显式处理并发

所以 &self 是 HTTP/2 场景的必选,不是一个喜好问题。

13.4 困境二:poll_ready 在连接层没有语义

Tower 的 poll_ready 是整个 backpressure 协议的灵魂(第 4 章完整讲过)。但把它放到 HTTP 连接层,问的问题没答案

13.4.1 "这个连接能不能接新请求?"

Tower Service 的 poll_ready 在问"这个服务能不能接新请求"。把它套到 hyper 的 Connection 上,问题变成:"这条 HTTP 连接能不能接新请求"?

HTTP/1 上这问题无意义——HTTP/1 一次只能处理一个请求,"能不能接"由协议本身决定。

HTTP/2 上更无意义——每个 stream 独立,连接本身永远能接(直到 MAX_CONCURRENT_STREAMS 达到,但那是 h2 层面的事,不是 Service 层面)。

用户真正关心的背压——"CPU 满了、数据库连接池满了、上游服务满了"——这些全是业务层问题,应该由业务代码里的 ConcurrencyLimit / Buffer 等中间件处理,不应该由连接层的 Service::poll_ready 回答。

13.4.2 连接层的 poll_ready 只能透传

即使勉强让连接层有 poll_ready,它能做的只是透传给业务 Service 的 poll_ready

rust
// 幻想版
impl hyper::Service<Request> for ConnectionService<S: tower::Service<...>> {
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)  // 透传
    }
}

这 100% 是废代码——连接层不加任何东西。然后这个透传还要处理 HTTP/2 多路复用问题:50 个 stream 并发都调 poll_ready?预扣 50 次资源?第一个 stream 的 call 消耗许可后其他 stream 怎么办?

这就是 hyperium/hyper#3040 里长达 100+ 条评论讨论的核心——poll_ready 的语义在连接层根本无法一致落地

13.4.3 hyper 的决定:干脆不要

hyper 团队最终决定:poll_ready 从 hyper::Service 里彻底去掉

不是不需要背压——而是把背压责任留给业务 Service。hyper 连接层只是把请求原样搬到 Service,Service 自己要不要做背压、怎么做、跟中间件怎么配合——hyper 不管。

这个决定让 hyper 的协议层代码大大简化——conn.rs 里没有任何 "poll_ready then call" 的两阶段协议、没有"多个 clone 的许可是否共享"的困惑、没有"HTTP/2 多流如何共享 ready 状态"的痛苦讨论。hyper 只做协议,业务用 tower 在 hyper 外面做背压——责任分离得干净。

13.5 源码对比:hyper::Service 的极简

rust
// hyper/src/service/service.rs:32-57
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    /// Process the request and return the response asynchronously.
    /// `call` takes `&self` instead of `mut &self` because:
    /// - It prepares the way for async fn,
    ///   since then the future only borrows `&self`, and thus a Service can concurrently handle
    ///   multiple outstanding requests at once.
    /// - It's clearer that Services can likely be cloned.
    /// - To share state across clones, you generally need `Arc<Mutex<_>>`
    ///   That means you're not really using the `&mut self` and could do with a `&self`.
    ///   The discussion on this is here: <https://github.com/hyperium/hyper/issues/3040>
    fn call(&self, req: Request) -> Self::Future;
}

整个 trait 三个关联类型 + 一个方法。没了

看那段注释——hyper 作者把做出这个决定的理由就写在 trait 定义上,并给了 issue 链接(#3040)。这是一种很克制的文档风格——把设计决策写进代码旁的注释,让读者能随时追溯为什么这里长这样。源码注释里直接引 issue 编号是工业级开源的美德。

13.5.1 额外的 impl:让 &SBox<S>Arc<S> 也是 Service

rust
// hyper/src/service/service.rs:59-112
impl<Request, S: Service<Request> + ?Sized> Service<Request> for &'_ S { ... }
impl<Request, S: Service<Request> + ?Sized> Service<Request> for &'_ mut S { ... }
impl<Request, S: Service<Request> + ?Sized> Service<Request> for Box<S> { ... }
impl<Request, S: Service<Request> + ?Sized> Service<Request> for std::rc::Rc<S> { ... }
impl<Request, S: Service<Request> + ?Sized> Service<Request> for std::sync::Arc<S> { ... }

5 个 blanket impl——任何对 Service 的"引用、指针、智能指针"都自动也是 Service。这让 Arc<MyService> 可以直接传给 hyper 的 serve_connection,不需要自己写 wrapper。

特别注意 &mut S 也是——这看起来自相矛盾:hyper::Service::call&self,而这个 impl 里我们通过 &mut S 调到 (**self).call(req)——本质还是 &S。只是允许用户传 &mut MyService 也能适配。

13.6 HttpService:sealed trait 的美学

rust
// hyper/src/service/http.rs:20-38
pub trait HttpService<ReqBody>: sealed::Sealed<ReqBody> {
    type ResBody: Body;
    type Error: Into<Box<dyn StdError + Send + Sync>>;
    type Future: Future<Output = Result<Response<Self::ResBody>, Self::Error>>;

    #[doc(hidden)]
    fn call(&mut self, req: Request<ReqBody>) -> Self::Future;
}

// :40-54
impl<T, B1, B2> HttpService<B1> for T
where
    T: Service<Request<B1>, Response = Response<B2>>,
    B2: Body,
    T::Error: Into<Box<dyn StdError + Send + Sync>>,
{
    type ResBody = B2;
    type Error = T::Error;
    type Future = T::Future;

    fn call(&mut self, req: Request<B1>) -> Self::Future {
        Service::call(self, req)
    }
}

mod sealed {
    pub trait Sealed<T> {}
}

impl<T, B1, B2> sealed::Sealed<B1> for T
where T: Service<Request<B1>, Response = Response<B2>>, B2: Body,
{
}

这段代码演示了一个绝妙的设计技巧:sealed trait

13.6.1 HttpService 是什么

HttpService<B1> 是一个更具体的 Service——它规定:

  • 请求类型必须是 Request<B1>
  • 响应类型必须是 Response<B2> 其中 B2: Body
  • Error 必须能转成 BoxError

也就是说,HttpService 把 "这是一个 HTTP 服务"的约束塞到 trait 签名里——hyper 的 Connection<T, S: HttpService<IncomingBody>> 只接受 HttpService,而不是任意 Service。

13.6.2 为什么要 sealed

HttpService 的定义是 sealed trait——用户不能直接实现它:

rust
pub trait HttpService<ReqBody>: sealed::Sealed<ReqBody> { ... }

mod sealed {
    pub trait Sealed<T> {}
}

Sealed 是 private module 里的 trait,外部看不见。HttpService 的 supertrait 约束要求 Self: Sealed——但 Sealed 只被 impl<T: Service<...>> Sealed for T blanket 实现。所以:

  • 用户写 impl HttpService<MyBody> for MyThing 会编译失败,因为 MyThing 没有 Sealed 实现。
  • 用户写 impl Service<Request<MyBody>> for MyThing,hyper 通过 blanket impl 自动把它变成 HttpService<MyBody>——这是唯一的路径。

这是 Rust 社区的经典 sealed pattern

  1. 公开 trait A,要求 A: SealedA
  2. SealedA 在 private module,用户看不见。
  3. 给所有"合法实现 A 的类型" blanket impl SealedA——因此用户看不到也写不出自己的实现。
  4. 这意味着 trait A 的实现者集合完全由库作者控制——库作者可以未来添加 A 的新方法、改签名,不会 break 下游(因为下游不可能直接实现 A)。

13.6.3 一个具体的 sealed 好处

假设 hyper 未来想给 HttpService 加一个方法:

rust
pub trait HttpService<ReqBody>: sealed::Sealed<ReqBody> {
    ...
    fn push_promise(&self, ...) -> ...;  // 假设加一个方法
}

如果 HttpService 不是 sealed,这是破坏性变更——下游所有 impl HttpService for MyThing 都要改。

因为它是 sealed——下游没法直接 impl。新方法可以通过 #[doc(hidden)] + blanket impl 加默认实现——所有现有 Service 自动获得新方法,兼容性完好。

Sealed trait 是在"开放实现"和"保留演进权"之间的精妙平衡。API 设计里用得好的地方非常多,下次看到 pub trait X: Sealed 你就知道为什么。

13.7 service_fn:闭包适配器

hyper::service::service_fn 让你用一个普通的 async fn 构造 hyper Service,不需要自己写 struct + impl:

rust
// hyper/src/service/util.rs:30-39
pub fn service_fn<F, R, S>(f: F) -> ServiceFn<F, R>
where F: Fn(Request<R>) -> S, S: Future,
{
    ServiceFn { f, _req: PhantomData }
}

// :42-45
pub struct ServiceFn<F, R> {
    f: F,
    _req: PhantomData<fn(R)>,
}

// :47-62
impl<F, ReqBody, Ret, ResBody, E> Service<Request<ReqBody>> for ServiceFn<F, ReqBody>
where
    F: Fn(Request<ReqBody>) -> Ret,
    ReqBody: Body,
    Ret: Future<Output = Result<Response<ResBody>, E>>,
    E: Into<Box<dyn StdError + Send + Sync>>,
    ResBody: Body,
{
    type Response = crate::Response<ResBody>;
    type Error = E;
    type Future = Ret;

    fn call(&self, req: Request<ReqBody>) -> Self::Future {
        (self.f)(req)
    }
}

用法:

rust
let svc = service_fn(|req: Request<Incoming>| async move {
    Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("hello"))))
});

一个闭包就是一个 hyper Service。

注意 F: Fn(...) 而不是 FnMut——闭包必须是 Fn(只借不改)。如果你在闭包里捕获 &mut 变量(比如 move |req| { self.counter += 1; ... }),编译失败——因为 Service::call 拿的是 &self,闭包跟着也只能 &Fn

这又一次印证 &self 的选择——它从 trait 层面强制你显式处理共享状态。如果你需要 counter,用 AtomicU64::fetch_add,或者把它包进 Arc<Mutex> 再 clone 进闭包。

13.7.1 PhantomData<fn(R)> 的方差

rust
pub struct ServiceFn<F, R> {
    f: F,
    _req: PhantomData<fn(R)>,
}

PhantomData<fn(R)> 不是 PhantomData<R>——为什么?

PhantomData<T> 让类型"表现得像持有 T"——决定了 variance。PhantomData<R> 会让 R 成为协变(covariant);PhantomData<fn(R)> 让 R 成为逆变(contravariant)。

对 ServiceFn 来说,R 是请求类型——Service 消费请求,所以对 R 逆变:如果 R: AB: A(B 是 A 的子类型),那么消费 A 的 Service 也能消费 B(因为 B 也是 A)。逆变是正确的。

PhantomData<fn(R)> 表达"我是个消费者"——不持有 R 的生命周期,但承担 R 的逆变语义。这是 Rust PhantomData 最 subtle 的一个用法。不了解方差的读者可能完全看不出差别,但它在复杂泛型场景会决定能不能通过 borrow checker。卷三《Rust 编译器与运行时揭秘》第 4 章(生命周期与区域分析)里讨论过方差——这里是一个具体应用。

13.8 TowerToHyperService:桥接的秘密

hyper 自己定义了 Service trait,但不意味着 tower 的用户要迁移到 hyper Service。hyper-util crate 提供 TowerToHyperService 适配器:

rust
// hyper-util(源码在独立 crate,这里简化)
pub struct TowerToHyperService<S> {
    service: S,
}

impl<S, Req> hyper::service::Service<Req> for TowerToHyperService<S>
where
    S: tower::Service<Req> + Clone,
    ...
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = TowerToHyperServiceFuture<S, Req>;

    fn call(&self, req: Req) -> Self::Future {
        let mut svc = self.service.clone();   // 关键:clone
        Box::pin(async move {
            // 先 poll_ready,再 call
            futures::future::poll_fn(|cx| svc.poll_ready(cx)).await?;
            svc.call(req).await
        })
    }
}

逻辑:

  1. 进入 call(&self, req)——hyper 的 trait 要求 &self
  2. 因为要调 tower 的 &mut self + poll_readyclone 一份 service
  3. 在返回的 future 里,先 poll_readycall

代价很明显:每次请求 clone 一次 tower Service。对 Axum 这种 Service 本质是 Arc<Inner> 的场景,clone 是 Arc::clone——几纳秒、无分配。对其他场景——视具体 Service 而定。

背压被吞掉:tower 栈的 poll_ready 在桥接器里被立刻 await ——任何 Pending 变成异步等待,对 hyper 连接层完全不可见。hyper 看到的是一个"永远 ready"的 Service——这是 hyper::Service 不带 poll_ready 的自然结果。

这是 hyper 1.0 分裂的唯一代价——但这个代价几乎只在连接层之上发生(TowerToHyperService 把 tower 适配成 hyper Service 时),业务层的 tower 中间件栈内部 poll_ready 链条完好无损。

13.9 哲学对照:Sync vs Async、严格 vs 宽松

深一层看,这场 hyper Service vs tower Service 的"分裂",其实是 Rust 异步社区长期以来的一个争论的缩影:

tower 代表的哲学是 "严格 + 显式":

  • &mut self 表达独占。
  • poll_ready 表达背压。
  • 用户写中间件要精确处理每一种状态转移。
  • 错了会 panic(poll_ready 未调用就 call)。

hyper 1.0 代表的哲学是 "宽松 + 易用":

  • &self 允许并发。
  • 没有 poll_ready——协议层不管业务容量。
  • 用户写 Service 可以直接 async fn call(&self) 一行。
  • 错了也不会 panic,因为 trait 没有这个协议预期。

两者都对——只是在不同的抽象层级应该不同的哲学。

一个程序应该同时受益于两者:

  • 业务中间件层用 tower——享受背压协议、享受组合能力。
  • 连接层用 hyper::Service——享受 HTTP/2 并发、享受 async fn 原生支持。
  • 桥接点(TowerToHyperService)用 clone + poll_ready await 吞掉 tower 背压——这是 unavoidable 的一次牺牲。

理解这一层之后你再读 Axum 代码、reqwest 代码,会知道为什么它们都要在某一处做 "tower → hyper" 的桥接——不是设计失败,而是两种哲学妥协的标志

13.10 HTTP/2 Service 的并发演示

用代码说话。一个支持真正并发的 hyper HTTP/2 server:

rust
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use hyper::{service::service_fn, Response, Request};
use hyper::body::Incoming;
use http_body_util::Full;
use bytes::Bytes;

#[derive(Clone)]
struct State {
    counter: Arc<AtomicU64>,
}

let state = State { counter: Arc::new(AtomicU64::new(0)) };

let service = service_fn(move |req: Request<Incoming>| {
    let state = state.clone();
    async move {
        let n = state.counter.fetch_add(1, Ordering::Relaxed);
        Ok::<_, hyper::Error>(
            Response::new(Full::new(Bytes::from(format!("Request #{}", n))))
        )
    }
});

看这个 Service:

  • state 里的 Arc<AtomicU64> 可以在多个 stream 并发访问。
  • service_fn(move |req| ...) 构造的 ServiceFn 实现 hyper::Service——call&self,满足并发要求。
  • 闭包 capture 了 state ——clone 进每次 call 的 future(let state = state.clone(); async move { ... })——避免跨 await 借用闭包 self。

如果用 tower::Service 写同样的逻辑,不得不:

  1. 定义一个 struct + Service impl(不能直接用闭包)。
  2. 在 poll_ready 里 return Ready。
  3. Service 必须 Clone——每个 stream 持有 clone。
  4. &mut self 约束下,可变状态必须通过 &mut self 访问——要么 Mutex、要么 atomic。

两套做法在运行时行为上几乎一样——但 hyper::Service 的代码 ergonomics 明显更顺滑,尤其对"service_fn + async fn"这种闭包式写法。

13.11 落到你键盘上

这一章的结论:

  1. hyper::Service 和 tower::Service 差一个字符,但背后是三年讨论。不是随便的风格分歧——是 HTTP/2 多路复用和异步 fn 两个刚需推动的架构决定。
  2. tower 适合业务组合层,hyper 适合协议连接层。中间由 TowerToHyperService 桥接。
  3. HttpService 是 hyper 的 sealed trait——基于 Service + Request/Response 约束自动 blanket 实现。sealed 给 hyper 保留了未来添加 HttpService 方法的权力。
  4. service_fn 让一个闭包直接变 Service——这是 hyper ergonomics 的精华。

落到你键盘上:

  • 读 hyperium/hyper#3040 issue——从头到尾看 Sean McArthur 和其他贡献者如何辩论这个决定。这是一份 Rust 社区里少有的"设计对话",读完你对工业级 trait 设计的直觉会大幅提升。
  • 写一个比较实验:同一个业务 Service,一次用 tower Service 配 hyper_util::service::TowerToHyperService、一次用 hyper Service 直接写。跑 HTTP/2 benchmark,看两种方式下 p99 延迟和 QPS 差距。
  • 试试在 hyper::Service 实现里做一次 self.counter += 1——编译失败。然后用 AtomicU64 / Mutex 两种方式改对。这是掌握"何时该用 &self 而非 &mut self"最直接的方法。

下一章讲 HTTP/1 连接的生存线——keep-alive 的超时策略、半关闭、header read timeout——这些生产环境里 debug 最频繁的边界。

基于 VitePress 构建