Skip to content

第24章 设计哲学:从 Tower 到 async trait 的演进

24.0 从这里回看

前二十三章一直在源码里钻——数过 proto/h2/ping.rs 的行号、拆过 dispatch::Dispatcher 的状态机、追过 h2::codec::FramedRead 的每一个 byte。这一章把镜头拉远,看这套抽象从哪里来、演到哪里、下一步会动什么。

路从 Service trait 开始。两个方法、一百来行定义,撑起了 Rust 一整代的网络生态——hyper、tonic、axum、linkerd、reqwest、vector、cloudflare 的 pingora(早期版本)都围着它转。Service 的成功不在于 API 优雅,而在于它把"一个异步网络调用应该有哪些可抽象的形状"问得非常清楚——清楚到你只要接受它的问题,就不得不接受它的答案。

但它不是完美的。2016 年 Finagle 启发的原始设计、2018 年 tower-rs 落到 Rust、2019 年 Pin 稳定、2023 年底 async fn in trait 稳定——每一次 Rust 本身的演化都在给 Service 施压。poll_ready 要不要还留着?&mut self vs &self?关联类型 Future 要不要换成 -> impl Future?GATs 落地之后 Layer 能不能写得更简洁?这些问题社区吵了六七年、还在吵。

hyper 1.0 在 2023 年 11 月发布,第一次和 tower::Service 不完全对齐——hyper 自己维护了一个 hyper::service::Service,方法签名用 &self 不用 &mut self,也不要 poll_ready。这是一次刻意的分叉,代表了 Rust 社区对"trait 稳定性 vs 生态依赖"的一次成熟权衡。

本章会:

  • 回到 2016 年的 Twitter,看 Service 这个抽象是从哪里来的;
  • poll_readyLayercall 三个签名放到显微镜下,看它们为什么长成今天这样;
  • 解释 hyper 1.0 为什么自己做一个 Service trait、不直接复用 tower;
  • 追踪 #[async_trait]async fn in trait 的演进,以及 Service 为什么还没切换
  • 把前二十三章串起来:每一段源码都是"小 trait + 单态化"这条设计线上的一个节点。

24.1 Service trait 的历史:从 Finagle 到 Tower

24.1.1 2016 年,Twitter 的 Finagle

故事的第一个现场不在 Rust 世界,在 Scala 世界。

2010 年前后,Twitter 的内部 RPC 栈叫 Finagle。Finagle 的核心抽象是一个 trait(Scala 里叫 trait,意义和 Rust 的 trait 相同):

scala
trait Service[-Req, +Rep] extends (Req => Future[Rep])

翻译成 Rust 味儿的伪代码:

rust
trait Service<Req> {
    fn apply(&self, req: Req) -> Future<Rep>;
}

一个函数,从 Request 到 Future&lt;Response&gt;。就这么一个 API,统一了 Twitter 内部所有 RPC 客户端和服务端。Marius Eriksen(Finagle 的主要作者)在 2014 年的论文《Your Server as a Function》里把这个思路讲得很清楚:服务是函数,中间件是"函数组合"。这篇论文是很多 Rust 网络库作者的共同起点。

Finagle 的第二个抽象叫 Filter(后来在 tower 里改名叫 Layer):

scala
abstract class Filter[-ReqIn, +RepOut, +ReqOut, -RepIn]
    extends ((ReqIn, Service[ReqOut, RepIn]) => Future[RepOut])

Filter 接一个 Service、返回一个新 Service——类型级的函数组合。超时、重试、限流、日志、认证,在 Finagle 里全都是 Filter,统一抽象,统一组合。这套模型在 Twitter 内部跑了十几年,证明了两件事:

  1. "网络调用 = 函数"这个抽象足够通用——RPC、HTTP、gRPC、DB 客户端、缓存客户端,都能套进去。
  2. "中间件 = 函数组合"的好处是模块性彻底彻底地类型级可验证——编译期就能知道中间件能不能套在服务上。

24.1.2 2018 年,tower-rs 登陆 Rust

2017 年秋天,Tokio 的作者 Carl Lerche 开始做一个 Rust 版的 Finagle——起名就叫 tower。最初的 commit 里 Service 长这样(凭记忆,细节和现在略有出入):

rust
pub trait Service {
    type Request;
    type Response;
    type Error;
    type Future: Future<Item = Self::Response, Error = Self::Error>;

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

这时候 Rust 的 async/await 还没稳定(要等到 2019 年 11 月),Pin 也还没稳定(2019 年 3 月)。Rust 这边做异步的唯一途径是手写 Future——实现 poll() 方法、自己管理状态机、自己处理 waker。在这个语境下,Service 里关联一个 type Future唯一自然的选择——你不能写 async fn call(&mut self, ...),因为 async fn in trait 根本不存在。

tower-rs 继承了 Finagle 的设计哲学,但做了一个关键改动:poll_ready 拆出来。Finagle 的 Scala 里没有显式的 poll_ready——Scala 的 Future 是 eager 的,背压通过捕获异常或者 queue-level 信号传递。Rust 的 Future 是 lazy 的,状态机驱动;这个差异让 poll_ready 从"可选"变成"必需"——第 4 章我们用 unbounded queue collapse 那个事故讲过这个故事。

让我们看今天最终稳定下来的定义,来自 tower-service/src/lib.rs:322-367

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

对照 2018 年最初的那版,今天的稳定版本做了三处微调:

  • type Request 变成了泛型参数 Service<Request>——允许同一个类型对不同 Request 实现多次(例如一个 RPC 客户端对 GetUserUpdateUser 都实现 Service)。
  • poll_ready 带上了 &mut Context<'_>——Pin/Waker API 稳定后 Context 成了标准参数。
  • Future 的 Item/Error 换成了 Output = Result<...>——跟着 Rust 标准库 Future trait 走。

API 表面的改动不大,核心形状自 2018 年起没变过。这种稳定性是这个 trait 能扛起整个 Rust 网络生态的根本——它没变,所以你 2018 年写的 Service 实现今天还能编译,今天写的 Service 实现可以被 2018 年的中间件包进去。

24.1.3 "小 trait" 的胜利

这里有一个值得停下来想的事实:tower 的核心 crate 叫 tower-service,整个 crate 除了文档和几个 blanket impl 之外,只定义了一个 trait、三个关联类型、两个方法tower-service/src/lib.rs 整个文件一共才 402 行——大部分还是文档。Layer 另外分在 tower-layer crate 里,也是同样的量级——一个 trait,一个 layer 方法,完。

这是 Rust 社区一个一直被反复验证的设计经验:trait 要小。标准库的 Iterator 只有一个必须方法 nextFuture 只有一个必须方法 pollRead 一个 readWrite 两个(write + flush)——全都是小 trait。tower 继承了这个传统。小 trait 的好处是:

  • 易于实现:用户想写一个 Service 只需要填三个关联类型 + 两个方法;门槛低。
  • 易于组合:小 trait 才能 blanket impl、才能做 Layer 这种"trait-to-trait"的变换——trait 越大,组合的类型约束越爆炸。
  • 易于稳定:小 trait 的表面积小,长期演进的成本小;tower-service 能保持十年稳定,就是因为它表面积够小。

我们会在 24.8 节再回到这个主题——"小 trait + 单态化"是这本书从头到尾反复出现的设计线索。

24.2 poll_ready 的语义独特性

24.2.1 它不是 Future::poll

读 Rust 异步代码的时候,一眼看过去 poll_readyFuture::poll——都返回 Poll<T>、都接 &mut Context、都可能返回 Pending。第一次读的读者(包括我自己 2019 年第一次读)会本能地把它们归成同一个东西。

但它们的语义完全不一样

Future::poll 回答的是:"你这个计算推进到 Ready 了吗?"——Future 是一次性的,一旦 Ready 就结束。

poll_ready 回答的是:"你有没有空间接一个新的 Request?"——Service 是多次的,poll_ready 之后 call 一次,下次还要再 poll_ready。这是两个完全不同的问题。tower-service 的文档(lib.rs:342-344)讲得非常直接:

Once `poll_ready` returns `Poll::Ready(Ok(()))`, a request may be dispatched to the
service using `call`. Until a request is dispatched, repeated calls to
`poll_ready` must return either `Poll::Ready(Ok(()))` or `Poll::Ready(Err(_))`.

翻成人话——一旦 poll_ready 返回 Ready,在你 call 之前,再 poll 它也必须返回 Ready。Service 不是 Future——它是一个持续存在的"有能力接请求"的对象。Ready 状态是一个"容量预留",不是"计算推进"。

24.2.2 它不是 Iterator::next / Stream::poll_next

另一个常见的误判是把 poll_readyStream::poll_next 混起来。Stream 是"异步的 Iterator"——每次 poll 产出一个 item。表面上看,Service 也是一个"一次 call 产出一个 Future"的流式接口——是不是可以看作 Stream?

不行,方向反了。

Stream 是生产者——你 poll 它,它吐 item 给你。Stream 的 poll_next 返回 Poll<Option<Item>>,回答的是"你的下一条数据在哪里"。

Service 是消费者——你 call 它,把 item 送给它。poll_ready 返回 Poll<Result<(), Error>>,回答的是"你做好准备接下一条了吗"。

两者方向相反。Stream 是"拉",Service 是"推"。poll_ready 的存在就是因为"推"需要接方在推之前表达"我准备好了"——这个信号在"拉"的世界里不需要(拉的一方自然知道自己什么时候准备好)。

再说清楚一点:Iterator::next 是同步拉;Stream::poll_next 是异步拉;Service::call 是异步推;poll_ready 是"异步推"场景下接方的容量广播——它是四种 trait 里独有的那一个,因为前三种都不需要。

24.2.3 Backpressure 的类型级表达

poll_ready 的本质是把"系统容量"搬进类型系统

传统语言里,"我满了"这个信号要么不表达(然后 OOM)、要么通过 exception 表达(try/catch 在延迟敏感路径上是灾难)、要么通过返回码表达(callsite 要记得检查,检查忘记就是 bug)。Rust 把它放进 trait 的类型签名——调用方必须先看到 Poll::Ready(Ok(())),才能调 call。忘了?编译器不管,但 tower 的中间件会把你打到"panic on call without ready"里(第 4 章那个 Buffer 示例)。生态工具(如 tower::util::ReadyExt)把这个两步协议封装成 svc.ready().await?.call(req).await?,让忘记 poll_ready 从可能变成不可能。

这是 Rust 整个生态一贯做事方式的一个缩影:能放进类型系统的信号,就不要留在运行时。类似的还有 Send/Sync(线程安全放进类型)、Pin(内存固定放进类型)、?Sized(动态大小放进类型)。poll_ready 让"容量"也进到这个列表里。

24.2.4 但它不是没有代价

我们也要诚实——poll_ready 不是白拿的。它的代价有三个:

  1. 两步协议增加了心智负担:用户很容易写出先 call 再被 panic 的代码;或者忘记"poll_ready 之后必须紧跟 call,不能中间 await 别的东西"(会泄漏预留的资源)。tower-service 的文档(lib.rs:346-350)专门花了一段警告这个问题。
  2. 资源预留可能浪费:poll_ready 返回 Ready 之后,如果你没调 call 就 drop 了 Service,预留的资源(semaphore permit、队列位置)必须被释放。这要求每个中间件作者都写正确的 Drop。
  3. &mut self 约束了并发模型poll_ready(&mut self) 意味着你不能同时对同一个 Service 实例做两次 poll_ready。这是 hyper 1.0 决定切换到 &self 的直接诱因——我们下面第 24.4 节讲。

多年运营下来,社区对 poll_ready 的态度是"必要但沉重"。必要,是因为 unbounded queue collapse 真的会死人;沉重,是因为它让一个本应两行的抽象变成了两个方法的协议。有没有更轻的替代方案?现在还没看到好的答案。只要你还要在编译期表达容量信号,poll_ready 式的两步协议就几乎是唯一结构。

24.3 Layer 的类型级设计

24.3.1 Layer 本质是 "Service → Service" 的类型级函数

翻开 tower-layer/src/lib.rs:100-106

rust
pub trait Layer<S> {
    type Service;
    fn layer(&self, inner: S) -> Self::Service;
}

一个 trait,一个关联类型,一个方法。这就是 tower 的全部中间件基础设施。

数学地看,Layer<S> 是一个类型级的函数

Layer<S> : type_of(S) -> type_of(Self::Service)

给我一个 Service 类型 S,还我一个新 Service 类型 Self::Service。更进一步——因为 SSelf::Service 都要求是 Service,Layer 是一个 Service 类型到 Service 类型的函数。这和 Finagle 的 Filter 本质相同,只是用 Rust 的 trait system 表达。

这种"类型级函数"在 Rust 里有个名字叫 higher-kinded 模拟。真正的 higher-kinded types(Haskell 的 Functor f、Scala 的 F[_])Rust 没有——它有 generic type、associated type、GATs,但没有"把类型构造器本身作为参数"这个能力。tower 的做法是把这个缺失用 trait Layer<S> 模拟出来:Self::Service 关联类型取代了"类型构造器"。

24.3.2 为什么不能用 fn make_layer(svc: S) -> ...

初学者会问——既然 Layer 就是一个"Service 变 Service"的函数,为什么不直接写一个函数:

rust
fn make_timeout<S>(svc: S, d: Duration) -> Timeout<S> {
    Timeout::new(svc, d)
}

这样不是更简单吗?——在单一 Layer 的场景里,你说得对。如果你只想给某个具体 Service 包一层 Timeout,写个函数就够了。

但看看 tower-layer/src/stack.rs:41-53

rust
impl<S, Inner, Outer> Layer<S> for Stack<Inner, Outer>
where
    Inner: Layer<S>,
    Outer: Layer<Inner::Service>,
{
    type Service = Outer::Service;

    fn layer(&self, service: S) -> Self::Service {
        let inner = self.inner.layer(service);
        self.outer.layer(inner)
    }
}

Stack<Inner, Outer> 把两个 Layer 堆在一起——先用 Inner::layer(S) 得到 Inner::Service,再用 Outer::layer(Inner::Service) 得到 Outer::Service。这段代码要编译,Outer 必须 "能作用在 Inner::Service 上"——这个约束只能用 trait bound 表达:Outer: Layer<Inner::Service>。用普通函数是写不出来这种链式的、类型依赖前一步输出的组合的——你可以写,但没法让 Stack 本身也作为一个 Layer 再被塞到更大的 Stack 里。

换句话说,Layer trait 的真正用处不是一次变换,而是能被组合成任意深的变换。ServiceBuilder::new().timeout(..).rate_limit(..).load_shed(..).buffer(..) 每一步都是一个 Stack::new(prev_stack, new_layer),编译出来是一个巨长的嵌套类型:

Stack<Stack<Stack<Stack<Identity, TimeoutLayer>, RateLimitLayer>, LoadShedLayer>, BufferLayer>

这种类型你写不出来(名字就不知道怎么写),但编译器帮你推出来了。这才是 Layer trait 的价值——它把"类型命名"的重担转移给编译器,让用户只管逻辑组合

24.3.3 layer_fn:通往闭包的桥

为了让简单场景不用定义结构体,tower 提供了 layer_fntower-layer/src/layer_fn.rs:68-70):

rust
pub fn layer_fn<T>(f: T) -> LayerFn<T> {
    LayerFn { f }
}

impl<F, S, Out> Layer<S> for LayerFn<F>
where
    F: Fn(S) -> Out,
{
    type Service = Out;
    fn layer(&self, inner: S) -> Self::Service {
        (self.f)(inner)
    }
}

一个结构体 + 一个 blanket impl 就把任何闭包 Fn(S) -> Out 变成了 Layer。这是 "小 trait + blanket impl" 组合拳的又一次胜利——Layer trait 自己只有三行,但配合 LayerFn,任何闭包都能变成中间件;配合 Stack,任何数量的中间件都能堆成一个;配合 tower::ServiceBuilder,这堆中间件可以用 builder 风格 fluent 地写出来。

24.3.4 与其他语言中间件的对比

看看其他语言的中间件模型:

框架中间件表示组合类型检查
Express (JS)(req, res, next) => {}运行时 app.use(...)
Koa (JS)async (ctx, next) => {}运行时 app.use(...)
Django (Py)class Middleware: __call__运行时配置字符串
Flask (Py)装饰器 + WSGI装饰器堆叠
Gin (Go)func(*gin.Context)engine.Use(...)
Finagle (Scala)Filter[Req, Rep, ReqOut, RepOut]andThen编译期
Tower (Rust)trait Layer<S>Stack<Inner, Outer>编译期

Express / Koa / Django / Gin 用的是 "运行时中间件列表" 模型——中间件是 Box<dyn Fn>、在 Vec 里排队、请求来了按顺序调。坏处是运行时才知道链接正不正确、每个 request 都要走一遍 dispatch。好处是简单、灵活、可以动态改。

Tower 走另一条路:编译期中间件类型——每一个 Layer 在编译期就把下一层的类型确定下来,整条链是一个单态类型。请求到来时不再需要运行时 dispatch,直接一连串 inline 调用。LLVM 优化掉中间层之后,Tower 的中间件链在跑起来时和一个手写 flat 函数几乎等价。

两种模型各有代价——Tower 的代价是编译时间长(单态化爆炸)、错误信息复杂(嵌套类型一长串);Express 的代价是运行时开销和正确性问题(中间件顺序写错、next() 忘记调)。这是一次**典型的把"运行时成本"搬到"编译时成本"**的设计选择,而 Rust 的整个生态在各处都在做这种选择——serde 的 Serializer(第 24.8 节还会提)、tokio 的 Future 状态机、hyper 的 Service 本身——全都是同一个哲学。

24.4 hyper::Service 和 tower::Service 的分叉

24.4.1 hyper 1.0 做的那个看似激进的决定

2023 年 11 月,hyper 1.0 发布。发布公告里有一段常被忽略的话——hyper 定义了自己的 hyper::service::Service trait,不复用 tower::Service

对比两者。先看 tower 的(tower-service/src/lib.rs:322-367,精简):

rust
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 的(hyper/src/service/service.rs:32-57):

rust
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. 没有 poll_ready
  2. call&self 不用 &mut self

为什么?hyper 的注释(service.rs:47-55)自己给了理由:

rust
/// `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>

把原因翻译整理:

  • &self 允许并发&mut self 一次只能一个 Future 借用 self;&self 允许多个 Future 同时借 self、并发处理多个请求。HTTP/2 和 HTTP/3 里一条连接同时跑几百个 stream,&mut self 会成为瓶颈。
  • &self 让 async fn 自然:未来如果 hyper 切换到 async fn call(&self, req),Future 借用 &self 是天经地义;&mut self 需要 future 独占整个 Service 直到完成。
  • 现实中 Service 基本都 Clone&mut self 的用处是共享可变状态;但 Rust 里共享可变状态通常要 Arc<Mutex<_>>,这时 &mut self 就成了累赘——你已经在用内部可变性了。

这三个理由每一个单独看都够呛足够说服所有人(poll_ready 的拥护者会强烈反对第一点——并发是好的,但失去 backpressure 表达能力是大代价),但合起来构成一个实用主义选择

hyper 作为最底层的 HTTP 实现,需要的是"一个能跑 HTTP request → response 的抽象"。它不需要 backpressure(HTTP 协议本身有流控、keep-alive 能拒连接)、它需要 &self(HTTP/2 强制多路复用)。tower 的 Service 给得太多——poll_ready 对 hyper 来说是累赘,&mut self 对 hyper 来说是瓶颈。

24.4.2 一次成熟的权衡

为什么 2014 年前后 tower 刚出生时所有人都用同一个 Service,到 2023 年 hyper 要分叉出去?

原因是约束不同。2014 年——Rust 异步生态一穷二白,所有人都要一个 "事实标准";hyper、tonic、linkerd 都依赖 tower 的 Service,生态统一是最大价值。到 2023 年——Rust 异步生态已经成熟,tower 已经扛住了 5-6 年的生产使用,但它的设计也被锁死了(很多生产项目依赖 poll_ready 的存在,tower 不能轻易移除它)。hyper 决定"不变但要前进"——自己做一个更适合 HTTP 场景的 Service,同时通过 hyper-util::service::TowerToHyperService 提供桥接。

hyper-util/src/service/glue.rs:20-46

rust
pub struct TowerToHyperService<S> {
    service: S,
}

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

    fn call(&self, req: R) -> Self::Future {
        TowerToHyperServiceFuture {
            future: Oneshot::new(self.service.clone(), req),
        }
    }
}

二十几行代码,把 tower::Service 包成 hyper::service::Service。核心是 S: ... + Clone——把 tower 的 &mut self 约束通过 clone 转化成"每次 call 都克隆一份 service 实例",克隆出来的实例上调 poll_ready + call(Oneshot 做的就是这件事,见第 6 章)。这是一层显式、用户可见的桥。你要 tower 生态(retry、rate_limit、buffer……)的所有 Layer,你就付克隆成本;你不要,你就直接写 hyper::Service

这个分叉是 Rust 社区一次成熟的集体行动:

  • 不破坏下游:tower 的 Service 定义没有动(动了的话,几十个生产项目一夜编译不过)。
  • 不跟死历史:hyper 这样的"基石库"得以用它觉得更合适的抽象,不被 tower 五年前的决定锁死。
  • 通过中间层桥接hyper-util 承担胶水的角色。用户写业务代码时不需要关心底层用的是 tower 还是 hyper 的 trait——用 service_fn、用 axum、用 tonic,框架层已经帮你选好了。

这是我们这本书讲的生态结构在这一层的体现——第 21 章讲 hyper-util::client::legacy::Client 时,我们已经看过 hyper 做"精"、hyper-util 做"全"的分工;第 22 章讲 axum 时,我们看过 axum 怎么把 tower 中间件和 hyper 服务器粘起来。24 章看到的分叉,就是前面那些分工的最上层体现

24.4.3 对读者的启示

作为读者,你从这次分叉可以学到三件事:

  1. 一个 trait 一旦稳定就很难改——即使你觉得"应该 &self 更好",如果下游依赖 &mut self 的语义(例如 retry 的状态要写在 &mut self 里),你改了就是 breaking change。tower 当年选了 &mut self 是那时候最合理的选择,但留下了今天的历史债务。
  2. 当抽象被锁死而需求在演化,新做一个比硬改一个更好——hyper 做了新 trait,没有试图在 tower 里加 feature flag;这比"兼容性补丁"健康得多,代价是生态暂时分叉。
  3. 分叉不是末日,桥梁就好——TowerToHyperService 只有几十行。好的抽象即使分叉了也能被廉价桥接,因为两边问的是同一个问题("请求→响应")只是问法不同。

24.5 async trait 的演进:#[async_trait] → stable async trait

24.5.1 Rust 异步历史简表

时间事件
2015-08Rust 1.0
2016Finagle-inspired 抽象在 Twitter 内部成型
2017-06futures 0.1Poll<T, E> 老式签名)
2018-03async-await-preview 首次 nightly 可用
2018-10tower 项目启动
2019-03Pin<&mut T> 稳定(Rust 1.33)
2019-11async fn / .await 稳定(Rust 1.39)
2019-12async-trait crate 发布 0.1
2022-11GATs 稳定(Rust 1.65)——打开了 async fn in trait 的门
2023-12async fn in trait + -> impl Trait in trait 稳定(Rust 1.75)
2024-至今生态逐步迁移,tower 还没切(本书写作时是 2026-04)

这个时间线的关键是——tower 的 Service 诞生于 "Rust 根本没 async fn" 的时代。type Future: Future<...> + fn call(&mut self, req) -> Self::Future 的两关联类型 + 手写状态机,是那时候唯一能写出来的异步 trait。

24.5.2 #[async_trait] 宏是怎么解决这个问题的

2019 年 async fn 稳定之后,dtolnay(著名的 Rust 工程师,serde、syn、quote 的作者)放出了 async-trait crate。它的思路很直接——用宏把

rust
#[async_trait]
trait Greet {
    async fn hello(&self, name: &str) -> String;
}

展开成

rust
trait Greet {
    fn hello<'life0, 'life1, 'async_trait>(
        &'life0 self,
        name: &'life1 str,
    ) -> Pin<Box<dyn Future<Output = String> + Send + 'async_trait>>
    where
        'life0: 'async_trait,
        'life1: 'async_trait,
        Self: 'async_trait;
}

两个关键点:

  1. Future 被装在 Pin<Box<dyn Future>>——动态分发,每次 call 都要堆分配。
  2. 生命周期被显式标注——'async_trait 是宏合成的,表示 Future 生命周期的下界。

代价是 堆分配 + 动态分发,跑起来比手写的关联 Future 类型慢。但好处巨大——写起来像普通 async fn、所有 Rust async 工具(tokio::select、tokio::spawn、.await)都自然兼容。从 2019 到 2023 年,绝大部分 Rust 异步 trait 都是用这个宏写的。tonic 的 gRPC trait、axum 的 FromRequest、sqlx 的 Executor,早期版本全用 #[async_trait]

24.5.3 2023 年底:stable async fn in trait

Rust 1.75(2023-12-28)稳定了 async fn in trait-> impl Trait in trait。从此你可以写:

rust
trait Greet {
    async fn hello(&self, name: &str) -> String;
}

不需要宏。编译器内部把它脱糖为:

rust
trait Greet {
    fn hello(&self, name: &str) -> impl Future<Output = String> + '_;
}

一个 -> impl Future 的返回值,没有 Box、没有动态分发。如果实现者的 Future 是一个具体类型(async fn 编译成的匿名状态机),那就是零额外成本。

这是 Rust 异步十年来最重要的一次演进。它把"异步 trait"从"有代价的便利"变成"零成本的原语"。

24.5.4 那为什么 tower::Service 还没切换?

如果 async fn in trait 这么好,为什么 tower 今天(2026-04)的 Service 还长这样——三个关联类型、两个方法、用户要手写 Future?

答案分三层,都跟"零成本"的隐藏约束有关。

第一层:Future 必须 Send。tower::Service 的使用场景里,95% 的情况下我们希望 Service::Future: Send,因为它要在 tokio 多线程 runtime 里 spawn。如果 trait 里写 async fn call(...),编译器脱糖出来的 impl Future不默认带 Send bound 的。你必须写:

rust
trait Service<Request> {
    type Response;
    type Error;
    fn call(&self, req: Request) -> impl Future<Output = Result<Self::Response, Self::Error>> + Send;
}

但这样写,实现者如果不小心 future 里借了 !Send 的东西(如 RcRefCell),编译就不过。而且——这个 Send bound 一旦写死,就不能去掉。有些用户场景是 !Send(例如本地 single-thread executor 跑的 Service)——他们就用不了这个 trait。

tower 目前的 type Future: Future<...> 写法给了用户选择权——用户自己决定 Future 是不是 Send(在 Impl 的 type Future = ... 行里决定)。

第二层:Return Position Impl Trait in Trait (RPITIT) 的生命周期 elision 还在成熟。stable 版的 -> impl Future 在生命周期自动推导上有些边缘情况——例如 &self 的 lifetime 和 Future 的 lifetime 的绑定、dyn Trait 对象的支持、以及 impl Trait 是否允许 "捕获" 某些生命周期参数。2023 稳定之后 Rust 还在不断修补这些边缘情况(2024 的 "precise capturing" RFC 就是为此)。tower 作为 foundational crate,保守策略是等到这些边缘情况完全沉淀再动。

第三层:语义兼容。tower 今天有几十万行下游代码依赖 Service::Future 作为关联类型存在——它们写 where S::Future: UnpinS::Future: 'staticS::Future: Send + 'static——这些 bound 在 async fn in trait 里没有直接对应物(因为返回值是 impl Future,这个类型是匿名的,用户没法写 S::Future: Foo)。切换意味着 breaking change。

所以 tower 的策略是:"观望"。等 Rust 异步的一整套生态(dyn Trait 兼容 async、Send bound 的更细粒度控制、"Return-Type Notation" 语法)在 2025-2026 年稳定下来,再做一次彻底的 v0.6 升级。这是一个对生态负责的、慢的、正确的策略。

24.5.5 axum 和 tonic 的选择

对比 tower 的保守,框架层更积极。

  • axum:在 Handler trait 上用 -> impl Futureaxum/src/handler/mod.rs:148-153):
rust
pub trait Handler<T, S>: Clone + Send + Sync + Sized + 'static {
    type Future: Future<Output = Response> + Send + 'static;
    fn call(self, req: Request, state: S) -> Self::Future;
}

等等——这里 axum 其实用了 type Future 不是 async fn。为什么?同样的原因——Send + 'static bound 要显式写;handler 要能被 Clone 存起来;用户写的 async fn handler(...) 会被 axum 的宏或者 blanket impl 包成具体的 Future 类型,存进 type Future。这是 async fn in trait 还不够灵活时的务实选择

  • tonic:gRPC 的 service trait 早期用 #[async_trait],现在新版本(tonic 0.12+)开始往 async fn in trait 迁移(当编译器版本允许时)。迁移是逐步的、渐进的,新代码写 async fn,老代码兼容层保留。

这是 Rust 生态一次性能看到的演进方式——基础库(tower、hyper)保守,框架层(axum、tonic)渐进,应用层用最新语法。三层步调不齐,中间层做适配器——这是一个成熟生态应该长的样子。

24.6 未来:Service v0.6 与 GATs

24.6.1 社区正在讨论的 Service v0.6 提案

tower GitHub issue tracker 上最热烈的两个讨论(截止 2026 年 4 月):

  1. "Migrate Service to async fn in trait"(#783):把 fn call(&mut self, req) -> Self::Future 换成 async fn call(&mut self, req),Future 隐式。
  2. "Remove poll_ready or make it optional"(#1026):背压要不要改成基于 Semaphore 的显式协议,从 trait 里拿出去。

两个提案都没落地。第一个因为前面讲的 Send / lifetime / 生态兼容问题;第二个因为社区对"是否丢掉类型级 backpressure"分歧严重——丢了简单但危险,留着复杂但正确。

设想一下 Service v0.6 如果真的落地,长什么样:

rust
pub trait Service<Request> {
    type Response;
    type Error;

    // async fn call 取代 type Future + fn call(&self, req) -> Self::Future
    async fn call(&self, req: Request) -> Result<Self::Response, Self::Error>;

    // poll_ready 变成 provided method,默认 Ok
    async fn ready(&self) -> Result<(), Self::Error> {
        Ok(())
    }
}

改动:

  • callasync fn;Future 匿名;不需要 Pin<Box>;零额外堆分配。
  • &self 取代 &mut self;并发友好;对齐 hyper 1.0。
  • ready() 变 provided method;大多数 Service 不需要实现;backpressure 成为 opt-in 而非必须。

这会带来一个漂亮的未来——一个 trait,两个 async fn,完事。但代价是大 breaking change,tower 生态所有中间件要重写。社区是否愿意付这个代价,还没共识。

24.6.2 GATs 能做什么

Rust 1.65 稳定的 GATs(Generic Associated Types,泛型关联类型)让关联类型可以带生命周期或类型参数:

rust
trait Service<Request> {
    type Response;
    type Error;
    type Future<'a>: Future<Output = Result<Self::Response, Self::Error>> + 'a
    where
        Self: 'a;

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

Future 关联类型可以"捕获" self 的生命周期。这让"Future 借用 self"变得可表达——而这正是 &self + async fn 的核心需要。

实际上,async fn in trait 的脱糖正是用 GATs 实现的——编译器合成一个匿名的 type Future<'a>。GATs 是 async fn in trait 的地基。

未来 Service 可能的形态里,GATs 让用户可以显式写出 Future 捕获的生命周期,从而让 bound(Send, 'static)能被精确控制。这是比现在"语法糖自动脱糖"更灵活的形式。

24.6.3 我的预测

基于目前(2026-04)的观察,我对未来 2-3 年 tower / hyper 的走向的个人预测:

  • hyper:继续保持自己的 Service,与 tower 分开。可能在 hyper 2.0 把 call 换成 async fn call(&self, req)(省掉用户写 Future)。
  • tower:会在 2027 年前后发布 v0.6,API 有一次 breaking change,基本就是上面那个 "async fn call + optional ready" 的版本。过渡期会有兼容层。
  • axum / tonic:跟着 tower v0.6 升级。用户代码(handler、service impl)基本不用改(因为用户一直是写 async fn),底层脱糖方式换。
  • poll_ready:会降为 opt-in,但不会消失。严肃的生产 gateway(linkerd、pingora-rs、cloudflare 内部服务)还会用它。

这不是预言、这是观察社区讨论得出的趋势。真正发生的时候可能差别不小——Rust 社区最不可预测的地方就是"有多久一次会有一个新颖的 insight 改变讨论方向"——例如 2022 年的 GATs 稳定就是这种 insight。

24.7 整本书的串联:小 trait + 单态化

现在让我们回头看这本书走过的路,把每一章都嵌回整个设计哲学里。

24.7.1 第 2-4 章:Service / Layer / poll_ready

主题——"RPC 是函数、中间件是函数变换"。我们从 Finagle 的思想出发,看 tower 怎么用一个 trait + 一个关联 Future 表达 RPC;用另一个 trait + 一个 type Service 表达中间件;用 poll_ready 表达背压。这三章是全书的公理层

24.7.2 第 5-8 章:具体中间件

主题——"小 Layer 组合出复杂行为"。Timeout、Retry、RateLimit、Buffer、LoadShed、Balance、Discover、Filter、Steer——每个都是几百行代码、一个 Layer + 一个 Service 实现。单独看都不复杂;组合起来能做出 linkerd 这样的 L7 代理。这几章是全书的组合层——证明"小 trait + 组合"在真实场景里能跑。

24.7.3 第 9-10 章:http crate + http-body

主题——"协议领域的 trait 抽象"。http crate 只做 Request/Response 的数据结构,不管 IO;http-body 只做 "流式 body" 的 trait,不管协议。这两个 crate 是 hyper / tonic / axum / reqwest 共用的地基——因为它们只定义类型,不绑定实现。这两章讲明白了"抽象要抽对层次"——http 和 http-body 被抽在协议语义那一层,所以 HTTP/1、HTTP/2、HTTP/3 都能用它们。

24.7.4 第 11-18 章:HTTP/1 + HTTP/2 的具体实现

主题——"在 trait 下面放真实工程"。这 8 章是全书最重的部分——字节解析、状态机、流控、HPACK、PING / GOAWAY、Upgrade。每一章都是 hyper 的一个模块、几千行代码的精读。这是全书的实作层。前面的 trait 是,这里的源码是。trait 写得再漂亮,没有这些字节级实现,就只是 "PowerPoint 工程"。

24.7.5 第 19-21 章:hyper-util + pool + dispatch

主题——"胶水也是产品"。hyper 做了"精",但生产用户要"全"——连接池、legacy Client、TowerToHyperService 桥、服务器 acceptor。这些胶水代码在 hyper-util 里,它是 hyper 的"下位品"——它是 hyper 的"互补品"。这三章展示了 Rust 生态一种特殊的分层:核心库做最小正确、util 库做生产够用、框架库做用户友好

24.7.6 第 22-23 章:框架层 + 生产调优

主题——"用户视角 + 运维视角"。axum 把 hyper::Service 适配成 Handler、tonic 把 Service 适配成 gRPC stub。两者都建立在 tower + hyper 之上,但给用户展示的都是"写一个普通的 async fn"。生产调优那一章讲参数——keep_alive_intervalinitial_window_sizemax_send_buf_size——每一个参数都是前面几章某个机制的"旋钮"。抽象再干净,旋钮也得露出来

24.7.7 一条贯穿线

从第 2 章到第 23 章,每一章每一段源码,都有一个共同主题:小 trait + 单态化

  • Service 小、Layer 小——组合出一切。
  • Body 小、Stream 小——抽象流式数据。
  • AsyncRead / AsyncWrite 小——抽象 IO 字节。
  • Connect / MakeService 小——抽象连接建立。

每一个 trait 都只问一个问题、只带最少的关联类型、只定义最必要的方法。每一个 trait 都被 blanket impl 扩展出大量派生能力(ServiceExtStreamExtFutureExt)。每一个使用点都用泛型参数传入具体类型,由编译器做单态化,产出一条条零开销的调用链。

这是 Rust 和它之前几乎所有语言最不一样的一点——抽象不用付运行时代价。Haskell 也有好看的抽象,但 GHC 的优化器不能每次都消掉;Java 的接口漂亮,但 JVM 每个虚调用都要走 vtable;Go 的 interface 干净,但 interface call 是 indirect。只有 Rust(以及 C++ 的模板,代价是编译时间爆炸)能做到"定义层看起来像高阶抽象、运行层看起来像 C"。

这是 hyper + tower + axum + tonic 这一整摞库能在同一个时代成型的根本——它们每一层都在用这个"抽象不花钱"的能力。

24.8 可迁移的设计模式

读这本书你看到的不只是 hyper / tower。这套设计思路在 Rust 生态的每个角落都在重复。

24.8.1 "trait 统一抽象"

同一种"小 trait"模式在其他地方:

  • tokio 的 AsyncRead / AsyncWrite:IO 的最小抽象。TcpStream、UnixSocket、TLS stream、Stdin、File 全都实现它,组合器(BufReaderFramedtimeout)围着它转。《Tokio 异步运行时》里会细讲。
  • serde 的 Serialize / Deserialize / Serializer / Deserializer:数据格式的最小抽象。JSON、CBOR、Bincode、TOML、MessagePack、Protobuf 全都是同一套 trait。数据类型只要 derive 一次,所有格式都支持。《Serde 元编程》里会细讲这套 trait 怎么用 visitor pattern 压缩掉 N×M 的组合爆炸。
  • futures 的 Stream / Sink:流式数据的最小抽象。channel、socket、文件、subprocess output 都可以暴露成 Stream。
  • hashbrown 的 Hasher:哈希算法的最小抽象。SipHash、AHash、FxHash 任选其一。

每一个成功的 Rust 库,它的核心都是一个或两个小 trait。记住这个模式。下次你写一个新库的时候,先问自己:我能不能定义一个小 trait,把我要抽象的东西装下?如果能,你已经赢了一半。

24.8.2 "Layer 组合"

同一种"类型级中间件组合"的模式:

  • tower 的 Layer:Service → Service。
  • serde 的 Serializer 组合PrettyFormatter<CompactFormatter> 这种嵌套类型。
  • nom 的 parser combinatorsparser.map(|x| ...).and_then(...).or(...) ——每一步都返回新类型的 parser。
  • futures 的组合子fut.map(...).and_then(...).timeout(...) ——每一步都返回新类型的 Future。

Rust 里一个成熟生态的标志,就是它的组合子丰不丰富。组合子就是"类型级函数"——输入类型 A、输出类型 B;A 和 B 都实现同一个 trait;组合子链接起来产生越来越特化的类型,在编译时被 LLVM 消化,跑起来像手写代码。

学这种写法最好的办法不是读 tutorial,是读 tower 的 ServiceBuilder 源码 + 读 serde_json 的 Serializer 实现——一本书是 tower/src/builder/mod.rs,一本书是 serde_json/src/ser.rs。读完这两块你对"Rust 式组合"就有感觉了。

24.8.3 "async 状态机 + poll 模型"

Rust 的 Future / poll 模型影响了整个编程语言世界:

  • Kotlin coroutines:CoroutineContext、suspend functions——本质上在 JVM 上重演 Rust 的 Pin<&mut Future>::poll,只是换了一套语法。
  • Swift async/await(2021):Task、continuations——async 基础设施深受 Rust 设计影响(Apple 工程师公开承认过)。
  • JavaScript 的 async iteratorfor await ... of 的实现思路。
  • Python 的 asyncio:比 Rust 早,但 Rust 改进的"零成本"版本被很多 Python 3.11+ 的 C-extension 模仿。

Rust 把 Pin + Future::poll + Waker 这一套机制推向极致;后来者不一定照抄,但**"异步是状态机"**这个思想已经彻底普及。这种"一个语言的实现细节改变了整个领域的心智模型"的现象——C 的指针、Haskell 的 typeclass、Rust 的 borrowck——每十几年一次,异步 Rust 就是其中之一。

24.8.4 "编译器做重活"

这本书反复出现的一个暗线——Rust 让编译器帮你做掉很多运行时的事。这不是 Rust 独有(C++ 模板也这样),但 Rust 的做法更克制、错误信息更好、表达能力更强。我们看到的具体体现:

  • Service trait 的单态化:每一层 Layer 展开成一个具体类型,调用是 inline 的。
  • serde 的 derive:Serialize/Deserialize 代码是 proc-macro 在编译期生成的。
  • tokio 的 async 脱糖async fn 变成状态机,由编译器合成,无堆分配。
  • hyper 的泛型 dispatchDispatcher<T, B, S> 里每个类型参数都在编译期确定,运行期没有 vtable。

"把人类写抽象代码的负担变成编译器在编译时干的活"——这是 Rust 整个设计哲学的一句话总结。前面几本书(《Tokio 异步运行时》、《Serde 元编程》、后面会写的《Rust 编译器》)会从不同角度拆这条主线。

24.8.5 这套模式的代价

诚实地讲,这套设计不是免费的:

  • 编译时间:单态化让代码膨胀,编译时间爆炸。一个中等 tower/axum 项目的 release build 十分钟很正常。
  • 错误信息复杂Stack<Stack<Stack<..., TimeoutLayer>, RateLimitLayer>, ...> 这种类型出现在错误信息里读起来很痛苦。
  • 二进制体积:静态分发 + inline 让二进制膨胀。一个"hello world" axum 服务 release 几十 MB 很常见。
  • 学习曲线:读懂 impl<S, Req> Service<Req> for Wrapper<S> where S: Service<Req>, ... 这种 impl 块对新人是一堵墙。

Rust 生态正在各个方向缓解这些成本——cargo-pgocargo-bloatcargo-llvm-lines 查膨胀;增量编译、cranelift 提编译速度;"help" tag 和 diagnostic attribute 改错误信息——但这些代价不会完全消失。你要的不是"没代价",是"代价和收益匹配"

24.9 往哪里继续

读源码的几条线——

  • reqwest:Rust 最流行的 HTTP 客户端,底层是 hyper + tower。重点看 src/async_impl/client.rs(主 Client)、src/async_impl/decoder.rs(gzip/br 解码中间件)。
  • axum 的 routing 模块axum/src/routing/ 的 Matcher、MethodRouter、Endpoint。这是 hyper::Service 上长出来的"路由 Service",代码非常干净。
  • linkerd2-proxy:生产级 L7 代理。所有中间件都是 tower Layer,是 tower 生态"能达到什么高度"的例子。
  • hyper 的 v0.14 → v1.0 迁移指南和 PR:理解为什么 &mut self 要变 &self、为什么 Buf 要从 body trait 上消失。看一次大项目怎么做不破坏生态的 API 升级。

可以练手的 Layer——

练手题依赖章节规模
InstrumentLayer:给任意 Service 加 tracing span第 3 章~30 行
IdempotencyLayer:按 Idempotency-Key header 去重第 3、5 章~200 行
CircuitBreakerLayer:按下游错误率开关服务第 4、5 章~500 行
AdmissionControlLayer:按系统负载动态调 QPS第 4、6 章~1000 行

hyper / tower / axum 的贡献入口——

  • 文档和例子:hyper 的内部机制大部分没有系统文档,把某个晦涩细节写成 hyper 官方 doc PR 通常能 merge。
  • edge case 的小修:hyper 的 issue tracker 有很多标了 "help wanted" 的 edge case。挑一个能复现的(例如 h2 GOAWAY 的某个 race、upgrade.rs 的某个错误路径),提一个最小复现 + 修复。

致谢

这本书直接依赖下列项目作者的长期工作:

  • Sean McArthur——hyper 的主要维护者,自 2014 年至今。hyper 能从 0.6 慢慢磨到 1.0、API 能保持长期稳定、源码能保持这么可读——是 Sean 十二年如一日坚持 "小而对" 的结果。
  • Carl Lerche——tower、tokio 的主要作者。tower 的 Service 抽象、tokio 的 Future 运行时——Rust 异步世界的两个地基都是 Carl 主导的。
  • Lucio Franco——tonic 的主要作者、tower 的 co-maintainer。tonic 是 Rust 生态第一个能用、稳定、高性能的 gRPC 实现。
  • David Pedersen(davidpdrsn)——axum 的主要作者。axum 把 tower + hyper 变成了 "普通 Rust 开发者每天能用" 的样子。
  • dtolnay——serde、syn、quote、anyhow、thiserror 的作者,async-trait 的作者。24.5 节讲 #[async_trait] 的那段历史主角就是他。
  • h2 crate 和 HPACK 实现的贡献者们——h2 的代码质量和 spec 覆盖度让 Rust 的 HTTP/2 在现实世界比多数 Go/Java 实现还稳。
  • Niko Matsakis 与 Rust 编译器团队——GATs 稳定、async fn in trait 稳定,让 24.5 节的演进成为可能。
  • hyper-util、hyper-rustls、hyper-tls、tower-http 的贡献者——胶水层不光鲜,生产系统靠这层跑起来。
  • 所有在 GitHub 上写过 hyper / tower PR 的贡献者——累计上千人,每一次 review 都沉淀到源码里。

基于 VitePress 构建