Appearance
第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_ready、Layer、call三个签名放到显微镜下,看它们为什么长成今天这样; - 解释 hyper 1.0 为什么自己做一个
Servicetrait、不直接复用 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<Response>。就这么一个 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 内部跑了十几年,证明了两件事:
- "网络调用 = 函数"这个抽象足够通用——RPC、HTTP、gRPC、DB 客户端、缓存客户端,都能套进去。
- "中间件 = 函数组合"的好处是模块性彻底彻底地类型级可验证——编译期就能知道中间件能不能套在服务上。
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 客户端对GetUser和UpdateUser都实现Service)。poll_ready带上了&mut Context<'_>——Pin/Waker API 稳定后Context成了标准参数。Future的 Item/Error 换成了Output = Result<...>——跟着 Rust 标准库Futuretrait 走。
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 只有一个必须方法 next,Future 只有一个必须方法 poll,Read 一个 read,Write 两个(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_ready 像 Future::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_ready 和 Stream::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 不是白拿的。它的代价有三个:
- 两步协议增加了心智负担:用户很容易写出先
call再被 panic 的代码;或者忘记"poll_ready 之后必须紧跟 call,不能中间 await 别的东西"(会泄漏预留的资源)。tower-service 的文档(lib.rs:346-350)专门花了一段警告这个问题。 - 资源预留可能浪费:poll_ready 返回 Ready 之后,如果你没调 call 就 drop 了 Service,预留的资源(semaphore permit、队列位置)必须被释放。这要求每个中间件作者都写正确的 Drop。
&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。更进一步——因为 S 和 Self::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_fn(tower-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;
}两个关键改动:
- 没有
poll_ready。 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 对读者的启示
作为读者,你从这次分叉可以学到三件事:
- 一个 trait 一旦稳定就很难改——即使你觉得"应该
&self更好",如果下游依赖&mut self的语义(例如 retry 的状态要写在&mut self里),你改了就是 breaking change。tower 当年选了&mut self是那时候最合理的选择,但留下了今天的历史债务。 - 当抽象被锁死而需求在演化,新做一个比硬改一个更好——hyper 做了新 trait,没有试图在 tower 里加 feature flag;这比"兼容性补丁"健康得多,代价是生态暂时分叉。
- 分叉不是末日,桥梁就好——
TowerToHyperService只有几十行。好的抽象即使分叉了也能被廉价桥接,因为两边问的是同一个问题("请求→响应")只是问法不同。
24.5 async trait 的演进:#[async_trait] → stable async trait
24.5.1 Rust 异步历史简表
| 时间 | 事件 |
|---|---|
| 2015-08 | Rust 1.0 |
| 2016 | Finagle-inspired 抽象在 Twitter 内部成型 |
| 2017-06 | futures 0.1(Poll<T, E> 老式签名) |
| 2018-03 | async-await-preview 首次 nightly 可用 |
| 2018-10 | tower 项目启动 |
| 2019-03 | Pin<&mut T> 稳定(Rust 1.33) |
| 2019-11 | async fn / .await 稳定(Rust 1.39) |
| 2019-12 | async-trait crate 发布 0.1 |
| 2022-11 | GATs 稳定(Rust 1.65)——打开了 async fn in trait 的门 |
| 2023-12 | async 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;
}两个关键点:
- Future 被装在
Pin<Box<dyn Future>>里——动态分发,每次 call 都要堆分配。 - 生命周期被显式标注——
'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 的东西(如 Rc、RefCell),编译就不过。而且——这个 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: Unpin、S::Future: 'static、S::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:在
Handlertrait 上用-> impl Future(axum/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 月):
- "Migrate Service to async fn in trait"(#783):把
fn call(&mut self, req) -> Self::Future换成async fn call(&mut self, req),Future 隐式。 - "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(())
}
}改动:
call是async 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_interval、initial_window_size、max_send_buf_size——每一个参数都是前面几章某个机制的"旋钮"。抽象再干净,旋钮也得露出来。
24.7.7 一条贯穿线
从第 2 章到第 23 章,每一章每一段源码,都有一个共同主题:小 trait + 单态化。
Service小、Layer小——组合出一切。Body小、Stream小——抽象流式数据。AsyncRead/AsyncWrite小——抽象 IO 字节。Connect/MakeService小——抽象连接建立。
每一个 trait 都只问一个问题、只带最少的关联类型、只定义最必要的方法。每一个 trait 都被 blanket impl 扩展出大量派生能力(ServiceExt、StreamExt、FutureExt)。每一个使用点都用泛型参数传入具体类型,由编译器做单态化,产出一条条零开销的调用链。
这是 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 全都实现它,组合器(BufReader、Framed、timeout)围着它转。《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 combinators:
parser.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 iterator:
for 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 的泛型 dispatch:
Dispatcher<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-pgo、cargo-bloat、cargo-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 都沉淀到源码里。