Appearance
第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;
}只有两处差别:
&mut selfvs&self(call 方法)- 有
poll_readyvs 没有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 侧收到
DATAframe 时,要立刻路由到对应 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:让 &S、Box<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:
- 公开 trait
A,要求A: SealedA。 - SealedA 在 private module,用户看不见。
- 给所有"合法实现 A 的类型" blanket impl SealedA——因此用户看不到也写不出自己的实现。
- 这意味着 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: A、B: 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
})
}
}逻辑:
- 进入
call(&self, req)——hyper 的 trait 要求&self。 - 因为要调 tower 的
&mut self+poll_ready,clone 一份 service。 - 在返回的 future 里,先
poll_ready再call。
代价很明显:每次请求 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 写同样的逻辑,不得不:
- 定义一个 struct + Service impl(不能直接用闭包)。
- 在 poll_ready 里 return Ready。
- Service 必须 Clone——每个 stream 持有 clone。
&mut self约束下,可变状态必须通过&mut self访问——要么 Mutex、要么 atomic。
两套做法在运行时行为上几乎一样——但 hyper::Service 的代码 ergonomics 明显更顺滑,尤其对"service_fn + async fn"这种闭包式写法。
13.11 落到你键盘上
这一章的结论:
- hyper::Service 和 tower::Service 差一个字符,但背后是三年讨论。不是随便的风格分歧——是 HTTP/2 多路复用和异步 fn 两个刚需推动的架构决定。
- tower 适合业务组合层,hyper 适合协议连接层。中间由
TowerToHyperService桥接。 - HttpService 是 hyper 的 sealed trait——基于 Service + Request/Response 约束自动 blanket 实现。sealed 给 hyper 保留了未来添加 HttpService 方法的权力。
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 最频繁的边界。