Hyper 与 Tower:工业级 HTTP 栈

第2章 Service trait:`async fn(Req) -> Res` 的协议无关抽象

作者 杨艺韬 · 6,870 字

第2章 Service trait:async fn(Req) -> Res 的协议无关抽象

2.1 先看一眼"答案"

这一章我们要拆开 Rust 异步服务生态里最重要的十行代码。为了避免把"工程史诗"讲得太虚,先把代码摆出来:

// tower-service/src/lib.rs:322-367
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>>;

    #[must_use = "futures do nothing unless you `.await` or poll them"]
    fn call(&mut self, req: Request) -> Self::Future;
}

就这些。

没有 default 方法,没有隐藏的 supertrait,没有 Send/Sync 的强制绑定,甚至没有 async 关键字——就一个 trait、三个关联类型、两个方法。但在这不到 400 字节的源码背后,栖身着整整一代 Rust 异步中间件的共识。

Tonic、Axum、Warp、Linkerd、Tower、reqwest、tower-http、twirp-rust、aws-smithy——全部建立在这十行之上。你每写一行 .await,都在间接地通过这几个符号。

那我们的任务就清楚了:把这十行一个字一个字读懂

2.2 为什么是三个关联类型

先看三个关联类型:

type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;

为什么不是泛型参数?为什么不是 Box<dyn Future>?为什么不是 async fn?这三个问题每一个都值得展开。

2.2.1 关联类型 vs 泛型参数

对比一下两个写法:

// 现实:Tower 用关联类型
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;
}

// 另一种可能性:全部泛型
pub trait Service<Request, Response, Error, Future: ...> {
}

差别:关联类型对某个具体的 impl唯一的——一个 Timeout<S> 只能有一个 Response 类型;而泛型参数允许一个类型对不同的 <Response, Error> 有多份 impl

对一个 Service 来说,关联类型是正确的选择:给定 MyService,它只会产出一种 Response。如果它能产出多种,那说明其实是两个服务。用关联类型迫使 API 使用者选择具体的响应类型,而不是让调用方自己推导。

这还带来一个语义收益:bound 可以直接写。你会经常在 Tower 生态里看到这样的 where 子句:

S: Service<Req, Response = Response<Incoming>>

读起来就是"S 是一个接受 Req、返回 Response<Incoming> 的服务"。如果 Response 是泛型参数,你就得写 S: Service<Req, SomeResp> 再另外约束 SomeResp = Response<Incoming>——丑得多。

Request 之所以是泛型参数而不是关联类型,是因为一个服务可以同时实现多种请求类型。比如一个 Redis 客户端可以同时 impl Service<GetCmd>impl Service<SetCmd>,用同一个连接处理两种命令。)

2.2.2 type Future vs Pin<Box<dyn Future>>

type Future 是一个关联类型:在编译期决定每个 Service 具体的 Future 形状。另一个常见的做法是:

// 另一个可能性:用 trait object
pub trait Service<Request> {
    fn call(&mut self, req: Request)
        -> Pin<Box<dyn Future<Output = Result<Response, Error>> + Send>>;
}

后者每一次 call 都会在堆上分配一个 Box、绕一层 vtable 查找。前者在编译期就知道具体类型——单态化之后连一次间接跳转都没有。

代价当然是:类型签名会非常复杂。看一下 tower::Timeout<S> 真实的 Future 类型:

// tower/src/timeout/future.rs 简化版
pin_project! {
    pub struct ResponseFuture<T> {
        #[pin]
        response: T,
        #[pin]
        sleep: tokio::time::Sleep,
    }
}

当你把 Timeout<Retry<RateLimit<MyService>>> 堆起来,最终的 Future 类型是层层嵌套的结构体。编译期无忧、运行时零开销;代价是类型签名能绕地球三圈。Tower 为此在 utility 里提供了 BoxServiceBoxCloneService 当逃生舱——要类型擦除的时候显式装盒。

这是一个经典的 Rust 风格:"默认零成本,显式付费":不想看巨型类型?好,Box::new(service) as Box<dyn Service>——然后你就要付一次虚方法调用的代价。

2.2.3 为什么不是 async fn

到 2026 年,Rust 已经支持 trait 里的 async fn。一个很自然的问题是:既然 Rust 现在有了 async fn in trait(AFIT),Tower 为什么还不把 Service::call 改成 async fn call

答案分两层。

第一层,向后兼容Service 这个 trait 被整个 Rust 生态上万个 crate 依赖,tower-service 这个 crate 从 2016 年发布以来 API 没有发生过 breaking change。改一个方法签名会让所有依赖方同时升级——代价巨大。

第二层,async fn in trait 还没到"完全替代 type Future"的程度。到 2026 年,AFIT 仍有以下工程限制:

所以现状是:Tower 的稳定版保持 type Future,同时 hyper 1.0 的 hyper::Service(第 13 章)已经开始尝试用 type Future + &self 做一个更现代的组合。这是一场未完成的迁移,Tower 大概率会在 1.0 以后跟进。

2.3 poll_ready 的灵魂

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

这是 Tower 全书最容易被初学者跳过、最值得花时间读懂的方法。

2.3.1 它在问什么

表面看,poll_ready 在问:"这个服务准备好接受一个请求了吗?"更准确的语义是:

如果我现在调用 call(req),这个服务能立即接收这个请求,而不需要拒绝排队吗?

关键字是"立即"。call 总是同步返回一个 Future(它只是构造 future,不 await)——所以"立即"和 Future 会不会很快完成无关。poll_ready 问的是"call 能不能成功发出"。

2.3.2 为什么不在 call 里判断

既然中间件要能判断"满不满",为什么不在 call 里面写?比如:

// 错误的设计
async fn call(&mut self, req: Request) -> Result<Response, Error> {
    if self.is_full() {
        return Err(Overloaded);
    }
    ...
}

问题在于:这没法传递背压。假设你在写一个连接池 client:

理想的行为是:让调用方等待,直到有连接可用再调用 call。这种"等待容量"的能力,必须发生在 call 之前,不是之后。这就是 poll_ready 存在的意义:

// 正确的协议
loop {
    // 异步等待服务准备就绪
    futures::future::poll_fn(|cx| svc.poll_ready(cx)).await?;
    // 获得许可之后再 call
    let response = svc.call(req).await?;
    break response;
}

或者更惯用的写法:svc.ready().await?.call(req).await?readyServiceExt 上的便利方法)。

这里的关键在于:poll_ready 返回 Poll::Pending 时会注册 waker,意味着调用方可以把当前 task 挂起,等服务有余量了自己唤醒。这是一个纯粹的异步背压信号,没有任何阻塞、没有任何轮询、没有 retry storm

这个设计来自 Finagle 的经验——Carl Lerche 把它翻译到 Rust 类型系统里。可以理解成:poll_ready 是"许可发放",call 是"使用许可"。两步协议让容量管理和业务调用在时间上分离。

2.3.3 poll_ready 的几条铁律

Tower 文档里关于 poll_ready 的约束读起来像法律条文,每一条都是血的教训。摘录一下(源码注释在 tower-service/src/lib.rs:332-367):

  1. "Once poll_ready returns Poll::Ready(Ok(())), a request may be dispatched"——一旦报告就绪,你必须能接一个请求。
  2. "Until a request is dispatched, repeated calls to poll_ready must return either Poll::Ready(Ok(())) or Poll::Ready(Err(_))"——就绪状态是持久的,不会自动失效(除非出错)。
  3. "poll_ready may reserve shared resources"——如果你 poll_ready 返回 Ready,你可能已经预扣了某些资源(比如信号量许可、连接池里的一条连接)。
  4. "It is critical for implementations to not assume that call will always be invoked"——调用方可能在 poll_ready 之后不调用 call 就把 Service 扔了。你必须在 Drop 时释放预扣资源。
  5. "Implementations are permitted to panic if call is invoked without obtaining Poll::Ready(Ok(()))"——没 poll_readycall,后果自负。

第 5 条看起来很夸张,但它是整个协议的强制约束。你在 RateLimit::poll_ready 里耗费许可,在 call 里假设许可已发。如果有人不遵守协议直接 call,许可会被透支,程序就错了——panic! 是最坏但合理的反应。

第 3 条的"预扣语义"引申出第 4 条的"要释放资源"。合在一起告诉你:poll_readycall 是一对"开 - 闭"事务,你必须保证这对事务在所有代码路径(包括异常、drop)上都被正确处理。

2.3.4 真实场景:Buffer 中间件

为了把 poll_ready 的协议落到具体代码,我们看一眼 tower::Buffer——一个给任意 Service 加排队能力的中间件。

// tower/src/buffer/service.rs 概念摘录
impl<T, Request> Service<Request> for Buffer<T, Request>
where T: Service<Request> + Send + 'static, ...
{
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<...> {
        // 问 mpsc channel 有没有位置
        self.tx.poll_ready(cx).map_err(...)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        // 把 (request, response_tx) 塞进 channel
        self.tx.send(...).unwrap();
        // 等 response_tx
        ResponseFuture { ... }
    }
}

Buffer 自己就是一个 poll_readycall 协议的典型:

这是把背压传递给调用方的最小例子。第 6 章会完整读 Buffer 的源码。

2.4 call 的边界条件

#[must_use = "futures do nothing unless you `.await` or poll them"]
fn call(&mut self, req: Request) -> Self::Future;

三件事值得注意。

2.4.1 #[must_use] 标注

返回值是一个 Future——如果你不 await 或者不 poll,什么都不会发生。在 Rust 里,futures are lazy#[must_use] 让编译器在你丢弃返回值时发出警告,避免"我 call 了怎么没反应"这种 bug。

这不是 Tower 独创的,整个 Rust async 生态都在用——tokio::spawn 返回的 JoinHandlefutures::future::ready 返回的 Ready——每一个都有 #[must_use]

2.4.2 &mut self 的坚持

tower::Service::call 签名里的 &mut self 是一个长期争议点。

&mut self 的意义是:同一时间只能有一个 call 在进行。你不能拿到一个 &mut MyService 然后并发地调用两次 call——borrow checker 会拦。如果你想"一个服务同时处理多个并发请求",你有两条路:

  1. Clonelet mut s1 = svc.clone(); 然后让每个并发任务持有自己的 clone。这是最常见的做法——Axum 的 RouterClone
  2. Arc + Mutex:共享一个 Service,用锁串行化访问。罕见。

&mut self 的"隐式串行化"带来了一个副作用:它强制 call 持有者有独占权,这让一些场景很自然。比如你在 call 里修改 service 内部的统计计数,不用 AtomicU64、不用 Mutex,直接写 self.count += 1 就行——因为 &mut self 保证你是唯一的 writer。

&mut self 也有代价:和 Rust 的 async fn 配合糟糕。如果你想写

async fn call(&mut self, req: Request) -> Result<Response, Error> {
    let lock = self.some_lock.lock().await;  // 持锁跨越 await 点
    // ...
}

这里的 &mut self 会跨越 .await 存活——这让 borrow checker 和异步调度器难以协作。实践中 Tower 的中间件往往在 call不持有 &mut selfcall 只是同步构造一个 future,真正的 await 都发生在返回的 future 里(future 的状态由 pin_project! 管理,不涉及 self 的借用)。

这就是为什么 Tower 的中间件源码有那么多"构造 future"的模式——每个中间件定义自己的 type Futurecall 只做"初始化 future 的字段",所有 await 都在 future 的 poll 里发生。详见下一节的 Timeout 例子。

2.4.3 hyper 选择不用 &mut self

剧透一下第 13 章:hyper 1.0 的 Service trait 故意不用 &mut self

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

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

原因是 HTTP/2 的多路复用:在同一个 Connection<T, S> 上可能同时处理几十个 stream,每个 stream 都要调用同一个 Servicecall&mut self 会把这些调用强制串行化——整个 HTTP/2 的多路复用变得毫无意义。&self 让并发调用在类型系统层面被允许,对 HTTP/2 server 至关重要。

这个选择的代价是:你不能在 call 里修改 self。所有状态必须通过 Arc<Mutex> 或 atomic 共享。hyper::Servicetower::Service 之间这个"一个字符的区别",就是第 13 章的全部故事。

2.5 从零手写一个 Service

理论讲够了,我们实际手写一个 Service 把上面的协议串起来。目标:一个 HelloService,收到 http::Request<Incoming>,返回 http::Response<Full<Bytes>>

use std::future::{Ready, ready};
use std::task::{Context, Poll};
use tower::Service;
use http::{Request, Response};
use http_body_util::Full;
use bytes::Bytes;
use hyper::body::Incoming;

#[derive(Clone)]
struct HelloService;

impl Service<Request<Incoming>> for HelloService {
    type Response = Response<Full<Bytes>>;
    type Error = std::convert::Infallible;
    type Future = Ready<Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))  // 没有容量限制,永远就绪
    }

    fn call(&mut self, _req: Request<Incoming>) -> Self::Future {
        ready(Ok(Response::new(Full::new(Bytes::from("Hello")))))
    }
}

这段代码能跑起来,一个完整的 Tower Service 就完成了。注意几个实现细节:

把这个 Service 用在 hyper 上需要一步"桥接"(因为 hyper 1.x 的 Service trait 和 tower 的不一样,详见第 13 / 19 章),这里先不展开。

2.6 一个典型的中间件:Timeout 如何实现

现在我们读一段真实的 Tower 中间件源码:tower::Timeout。完整源码在 tower/src/timeout/mod.rsfuture.rs,版本 0.5.3。

2.6.1 结构定义

// tower/src/timeout/mod.rs 关键片段
pub struct Timeout<T> {
    inner: T,
    timeout: Duration,
}

impl<T> Timeout<T> {
    pub const fn new(inner: T, timeout: Duration) -> Self {
        Timeout { inner, timeout }
    }
}

Timeout<T> 是一个 wrapper——持有被包裹的 Service T 和超时时长。T 是一个泛型参数,意味着 Timeout 不绑定任何具体协议:

// 用在 HTTP 服务端
Timeout<HelloService>

// 用在 gRPC 客户端
Timeout<tonic::client::Grpc<Channel>>

// 用在 Redis 客户端
Timeout<redis::MultiplexedConnection>  // 如果它实现了 Service

2.6.2 Service impl

impl<S, Request> Service<Request> for Timeout<S>
where
    S: Service<Request>,
    S::Error: Into<BoxError>,
{
    type Response = S::Response;
    type Error = BoxError;
    type Future = ResponseFuture<S::Future>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        match self.inner.poll_ready(cx) {
            Poll::Pending => Poll::Pending,
            Poll::Ready(r) => Poll::Ready(r.map_err(Into::into)),
        }
    }

    fn call(&mut self, request: Request) -> Self::Future {
        let response = self.inner.call(request);
        let sleep = tokio::time::sleep(self.timeout);
        ResponseFuture::new(response, sleep)
    }
}

关键细节:

  1. Response = S::Response:响应类型不变——Timeout 不修改响应的形状,只可能在时间上拦截。
  2. Error = BoxError:错误类型被"擦除"成 Box<dyn Error + Send + Sync + 'static>。原因是 Timeout 自身可能产生 Elapsed 错误,又要把内层 S::Error 透传。两种不同类型的错误要合并成一种,最通用的做法是装盒。
  3. Future = ResponseFuture<S::Future>:这里是 Tower 的另一个典型模式——每个中间件定义自己的 Future 类型。不是 Pin<Box<dyn Future>>,不是 impl Future,而是一个命名的 struct。这是为了和稳定 trait + 关联类型配合(第 2.2.3 节讨论过)。
  4. poll_ready 透传:Timeout 本身不限制容量,直接把内层 S::poll_ready 的结果转发。map_err(Into::into) 是为了把 S::Error 装箱。
  5. call 里构造 future:构造一个 ResponseFuture,把内层响应 future 和 sleep future 绑在一起。注意:tokio::time::sleep 这一行不 await,它只是返回一个 Sleep future。

2.6.3 ResponseFuture 的 poll

// tower/src/timeout/future.rs
pin_project! {
    pub struct ResponseFuture<T> {
        #[pin] response: T,
        #[pin] sleep: Sleep,
    }
}

impl<F, T, E> Future for ResponseFuture<F>
where F: Future<Output = Result<T, E>>, E: Into<BoxError>,
{
    type Output = Result<T, BoxError>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();

        // 1. 先 poll 业务 future
        match this.response.poll(cx) {
            Poll::Ready(v) => return Poll::Ready(v.map_err(Into::into)),
            Poll::Pending => {}
        }

        // 2. 业务没就绪,poll sleep
        match this.sleep.poll(cx) {
            Poll::Ready(_) => Poll::Ready(Err(Box::new(Elapsed(())))),
            Poll::Pending => Poll::Pending,
        }
    }
}

整个超时逻辑就这么几行:

  1. 先 poll 内层响应 future——如果业务已完成,立刻返回。
  2. 业务没完成,poll sleep——如果超时到了,返回 Elapsed 错误;否则挂起。

pin_project! 是一个关键细节——它让 ResponseFuture 安全地处理内部 !Unpin 的字段(Sleep!Unpin 的)。原理在卷三《Rust 编译器与运行时揭秘》第 10 章(Pin / Waker / Future)里讲过。

这段代码的美在于:没有一个 async/await,没有任何堆分配,没有任何运行时类型查询。整个 Timeout 就是 trait impl + 状态机,单态化后生成的机器码就是"手写一个 select(response, sleep)"。

2.7 Service 为什么能跨协议

到这里你应该能理解一个核心问题:为什么 Tower 的中间件能跨协议

答案是:中间件只看 Service<Request>,不关心 Request 是什么

我们来看 Timeout 的约束:

impl<S, Request> Service<Request> for Timeout<S>
where S: Service<Request>, S::Error: Into<BoxError>,
{ ... }

整个 Timeout 的代码里,你找不到任何一处对 RequestResponse 具体类型的假设。它只需要:

这是一个完全通用的 impl。因此你既可以这样用:

// HTTP 服务端
let svc: Timeout<AxumRouter> = Timeout::new(router, Duration::from_secs(30));

也可以这样:

// gRPC 客户端
let client: Timeout<GrpcClient> = Timeout::new(grpc, Duration::from_secs(5));

或这样:

// 纯业务 Service
let handler: Timeout<HelloService> = Timeout::new(hello, Duration::from_millis(100));

这是 Service trait 最强的一个特性:它消除了"协议"这个维度。只要你能把一次请求-响应交互装进 call(Request) -> Future<Response> 这个形状,Tower 生态所有的中间件都是免费的。

反过来,这也给中间件作者提了一个要求:不要假设 Request 类型。不要写 req.headers()——那只对 HTTP 成立。不要假设 Clone——要就显式 bound。不要假设 Send——需要时再加。越是少假设,中间件的适用面越广。tower-http crate 存在的意义就是:那些确实需要 HTTP 特性的中间件(比如 CORS、Compression、Authz)被单独放一个 crate,保证 tower 核心 crate 是"协议无关"的纯抽象。

2.8 与 Serde 的 Serializer 对照

读过卷四《Serde 元编程》第 3 章(Serializer trait)的读者,会注意到 ServiceSerializer 之间一个深层的相似:

两者的共同设计哲学是:用一个中立的 trait 定义"接触面",让两边独立演进。Serializer 是数据结构和格式的接触面,Service 是协议和中间件的接触面。任何一边的改变都不影响另一边。

更深一层:这两个 trait 都提供了"关联类型 + 泛型参数"的混合形式。Serializer 有 type Oktype Errortype SerializeSeq 等一系列关联类型,配合泛型的 serialize_i32 等方法;Service 有 type Responsetype Errortype Future 三个关联类型,配合泛型的 Request。这是 Rust trait 设计里的一个成熟模式:关联类型定义"这个实现会产出什么",泛型参数定义"这个实现接受什么"

每当你在 Rust 生态里看到一个 trait 使用这种"关联类型 + 泛型"混搭,大概率它也在解决某个 M×N 问题。这是 Rust 生态几大核心抽象(Serde、Tower、Futures)共享的工程美学。

2.8.1 实测一行:tower-service 0.3.3 整 crate 只有 390 行、其中 85% 是文档

打开 ~/.cargo/registry/src/.../tower-service-0.3.3/——整个 crate 只有一个文件 src/lib.rs + 顶层 Cargo.toml / CHANGELOG.md / README.md / LICENSE——没有任何子模块

lib.rs 行数账本——

类别 行数 占比
文档注释(// / /// / //! 332 85.1%
非空非注释代码 45 11.5%
空行 13 3.4%
总计 390 100%

45 行实际代码的分布——

两条值得记住的工程结论——

  1. tower-service 整个 crate 仅 45 行代码——比本章 §2.5"从零手写一个 Service"的示例(约 30 行)只多 15 行——因为协议层抽象本就该极简——增加任何概念(更多关联类型、更多方法、helper 函数)都会让数千个下游 crate(hyper/axum/tonic/linkerd/...)受牵连——少即是多在协议设计里不是口号、是经济学
  2. 85% 是 rustdoc——332 行文档解释 45 行代码——约 7.4 倍的注释/代码比——是 Rust 标准库平均水平(约 1:1)的 7 倍——印证 Tower 把"API 文档当一等公民"——这也解释了为什么本章 §2.2.3 提到的"为什么不是 async fn"这样的设计取舍能在源码 docstring 里直接读到决策理由

和 ch03 §3.10.1 实测的 tower-layer 0.3.3(655 行总、tuple.rs 330 行手展开 16 个 impl)对比——tower-servicetower-layer 还要薄 2/3——这是 Rust 异步生态"核心抽象 < 100 行、扩展层数百到数千行"的典型分层规律。下一章 §3.10.1 已展示 tower-layer 的 5 文件;本章揭示 tower-service 只有 1 文件 1 trait——是这条规律的极简端点。

2.8.2 从源码注释读懂 poll_ready 的事务语义

本章前面已经解释了 poll_ready -> call 的两步协议,但 tower-service-0.3.3/src/lib.rs:321-339 的文档把这个协议说得更严格:poll_ready 返回 Pending 时,调用方必须等待 waker;返回 Ready(Err) 时,调用方应丢弃这个 service 实例;一旦 poll_ready 返回 Ready(Ok),在请求真正交给 call 之前,重复调用 poll_ready 只能继续 Ready 或 Ready(Err)。最重要的是,文档还提醒 poll_ready 可能预留共享资源,而这些资源会在随后的 call 中被消费。

这句话决定了 Tower 中间件的正确性边界。ConcurrencyLimitpoll_ready 里拿 semaphore permit,RateLimitcall 里扣 token,Bufferpoll_ready 里检查 channel 容量——这些都不是实现细节,而是对这条事务语义的不同落地。调用方如果跳过 poll_ready,不是“少了一次健康检查”,而是绕过了服务预留资源的协议;实现方如果在 poll_ready 里扣了资源,却在 clone 或 drop 时不释放,就是破坏了协议。

lib.rs:342-355call 的约束也很关键:call 期望可以在 task 之外被调用,所以实现者不应该在 call 内部偷偷调用 poll_ready;并且源码给 call 加了 #[must_use],提醒返回的 Future 不被 poll 就不会发生任何事。这个设计把“准备接收请求”和“异步处理请求”拆开:前者是容量与背压,后者是业务执行。许多初学者想把 ready 检查塞进 call,看似 API 更简单,实际会让 backpressure 无法提前传播。

和 Hyper 的差异也要从这里理解。Hyper 1.9.0 的 hyper/src/service/service.rs:47-56 只保留 call(&self, req),注释说明这样可以支持同一个 Service 并发处理多个 outstanding request;hyper-util-0.1.20/src/service/oneshot.rs:44-51 在桥接 Tower 时又主动先 poll_ready 再 call。换言之,Hyper 没有否定 Tower 的 ready 协议,而是把它从 Hyper 的主 trait 中移到适配器里。原因是 Hyper 面对 HTTP/2 多路复用时,连接层无法自然表达“某一个 stream 预扣了哪一个 Tower 许可”;Tower 面对中间件组合时,却必须显式表达容量。

所以,写 Tower Service 时要遵守四条更具体的规则:第一,poll_ready 只能预留和“下一次 call”直接相关的资源;第二,预留资源要能在 clone、drop 或 future drop 时释放;第三,call 不能假装自己会等待 ready,除非文档明确说明;第四,中间件透传 poll_ready 时不能随便吞掉内层错误。把这四条做到,Service 才能在任意 Layer 栈里组合,而不是只在你的 demo 顺序里碰巧工作。

还有一条常被忽略的推论:ServiceFuture 关联类型是抽象的一部分,而不是实现细节。它让 Timeout 可以返回包装过的 ResponseFuture,Retry 可以返回状态机,RateLimit 可以直接返回内层 future,ConcurrencyLimit 可以把 permit 放进 future。若所有中间件都被迫返回 Pin<Box<dyn Future>>,这些差异仍然能表达,但每层都会多一次堆分配和动态派发;Tower 选择关联类型,就是把“中间件如何完成异步工作”留给编译器单态化。这也是 Service trait 虽短,却能承载复杂生态的原因。

从调用方角度看,ready().await?.call(req) 不是样板,而是在告诉编译器和运行时两件事:我愿意等待容量,我拿到容量后会立刻发起一次请求。这个约定让不同团队写出的中间件能互相组合。如果你写的 Service 只能在“调用方一定先调用我自己的 init 方法”时工作,它就不是一个合格的 Tower Service;它只是借用了 Service 的外形。

这也是本章最该带走的判断标准。

2.9 小结:落到你键盘上

我们本章做了什么:

  1. Service trait 的十行源码逐字读了一遍——三个关联类型、两个方法、每一个的含义和边界条件。
  2. 深入 poll_ready 的设计哲学——它为什么存在、它和 call 之间的事务语义、它如何传递背压。
  3. 对比了 &mut self&self 的选择——Tower 为什么坚持 &mut self,hyper 1.0 为什么又选了 &self(预告第 13 章)。
  4. 手写了一个最小的 HelloService,读了真实的 tower::Timeout 源码。
  5. 理解了"Service 为什么能跨协议"——中间件只看 trait,不看具体 Request。

下一步落到你键盘上的三件事:

物理事实:tower-service 0.3.3 整 crate 单文件 390 行、85% 是 rustdoc、只 45 行实际代码——印证'核心抽象 < 100 行、扩展层数百到数千行'的 Rust 异步生态分层规律;2 个 blanket impl(&mut S / Box<S>)让借用与堆装箱透明化是 trait 设计的关键完整性。