Hyper 与 Tower:工业级 HTTP 栈
第22章 Axum / Tonic 如何构建在 Hyper + Tower 之上
22.0 本章导读
前面二十一章一直在拆 hyper 与 tower 这两层底座——Service trait、Layer 组合、HTTP/1 wire、HTTP/2 hpack 与流控、keep-alive、upgrade……这些东西有个共同特征:你很少直接在业务代码里见到它们。真正被业务代码 use 的,是 axum、是 tonic。这两个框架面向的是两种完全不同的工程语义——axum 是一个 RESTful 风格的 HTTP 框架、tonic 是一个 gRPC-over-HTTP/2 的 RPC 框架——但它们在内核上是同一个东西:hyper::server::conn 起连接、hyper-util 桥接 IO、tower::Service 作为请求处理单元、tower::Layer 作为横切关注点。
理解这一章的价值在三个方向:
- 工程上——你能解释清楚"为什么一个
TraceLayer可以同时装在 Axum 和 Tonic 上"。回答这个问题需要你同时看懂两个框架对Service的适配方式。 - 设计上——你能看到 "一个够好的抽象 vs 两个不错的框架" 的张力是怎么解决的。Rust 网络生态没有重新发明轮子的文化——
Servicetrait 好到让上层框架愿意老老实实套壳。 - 心智上——你会发现"axum 的 Handler 系统"其实就是一组声明宏(
macro_rules!)+ 泛型 trait 在Service<Request<Body>>之上做的用户友好壳,而 tonic 的Routes内部真的就持有一个axum::Router(pub struct Routes { router: axum::Router, .. },tonic-v0.14.5/tonic/src/service/router.rs:14-16)。看源码一眼穿透。
本章我们把 axum 与 tonic 的源码并排读:Router → Service、Handler → FromRequest/IntoResponse、Codec → HTTP/2 DATA frame、Metadata → HTTP/2 Trailers。源码版本:axum 0.8.9(axum-full/axum/Cargo.toml:3)、axum-core 0.5.6(axum-full/axum-core/Cargo.toml:12),与 tonic 0.14.5(tag v0.14.5)。两侧行号都按本机 checkout 的实际文件给——tonic 的行号尤其要认版本,0.14.x 内部结构在小版本之间改过好几次。
读完本章,你能:
- 解释
axum::Router为什么可以直接当作tower::Service<Request<Body>>用——注意那个 impl 写死了状态已经填满的形态:impl<B> Service<Request<B>> for Router<()>(axum-full/axum/src/routing/mod.rs:599)。 - 用
#[diagnostic::on_unimplemented]诊断"handler 不满足 trait"类错误的根因(axum 一共挂了 5 处:axum-full/axum/src/handler/mod.rs:145、axum-full/axum-core/src/extract/mod.rs:50 与 76、axum-full/axum-core/src/response/into_response.rs:112、axum-full/axum-core/src/response/into_response_parts.rs:73)。 - 给 tonic 客户端的
Channel套一层tower::retry::RetryLayer——知道它为什么该装在客户端,以及请求体不能克隆带来的限制。 - 在同一个
TcpListener上同时跑 Axum(REST)和 Tonic(gRPC)——理解Routes::into_axum_router()(tonic-v0.14.5/tonic/src/service/router.rs:106)在做什么。 - 和 Go 的
net/http + grpc-go、Python 的FastAPI + grpcio对照,看懂"为什么 Rust 可以做到框架级复用,而其他语言不太行"。
22.1 Axum 骨架:Router + Service + Handler
22.1.1 Router 是什么
打开 axum/src/routing/mod.rs:85-88:
#[must_use]
pub struct Router<S = ()> {
inner: Arc<RouterInner<S>>,
}
Router<S> 的核心就是一个 Arc<RouterInner<S>>——S 是缺失的应用状态类型,() 表示"状态齐了,可以服役"。RouterInner<S> 结构体在 axum/src/routing/mod.rs:98-102:
struct RouterInner<S> {
path_router: PathRouter<S>,
default_fallback: bool,
catch_all_fallback: Fallback<S>,
}
PathRouter 是 matchit 路由树 + 每条路由对应的 Endpoint<S>,Fallback 是兜底 handler。这些都是细节。真正重要的是 Router<()> 实现了 tower::Service<Request<B>>——见 axum/src/routing/mod.rs:599-618:
impl<B> Service<Request<B>> for Router<()>
where
B: HttpBody<Data = bytes::Bytes> + Send + 'static,
B::Error: Into<axum_core::BoxError>,
{
type Response = Response;
type Error = Infallible;
type Future = RouteFuture<Infallible>;
#[inline]
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
#[inline]
fn call(&mut self, req: Request<B>) -> Self::Future {
let req = req.map(Body::new);
self.call_with_state(req, ())
}
}
注意两点工程品味:
Error = Infallible——Router 本身不会失败;所有错误都已经在内部被IntoResponse转成了Response。这一步保证顶层永远不会拿到Result::Err,连接也就不会因为某个请求出错而被 hyper 中断,hyper::server::conn::http1::Builder::serve_connection(io, TowerToHyperService::new(router))这种最朴素的调用方式就够用(hyper 只要求错误类型能转成Box<dyn Error + Send + Sync>,并不要求Infallible;hyper 1.x 自己的hyper::service::Service与 tower 的不是同一个 trait,所以中间要经 hyper-util 的TowerToHyperService桥一下,见 §22.4.2)。poll_ready永远 Ready——Router 是无状态的 demux,背压不发生在这里。背压由每条路由内部的 Service负责(例如ConcurrencyLimitLayer 装进来后才会有Poll::Pending)。
第 2 章手写 HelloService 时用的也是 type Error = Infallible(§2.5)——axum 选择这条路,是因为 HTTP 服务器的语义里用 4xx/5xx 响应表达失败比返回 Err 更自然。
再看 Future = RouteFuture<Infallible>——这是一个具名的 future 类型,不是 Pin<Box<dyn Future>>。对比 tonic 的 Routes 用 RoutesFuture(它只是包了一层 axum 的 RouteFuture<Infallible>,tonic/src/service/router.rs:162),两者对外都走"关联类型 + 具名 Future struct"的路子。不过具名只到最外层:RouteFuture 内部持有的是 Oneshot<BoxCloneSyncService<Request, Response, E>, Request>(axum/src/routing/route.rs:110-116),而 BoxCloneSyncService 的 type Future 就是 BoxFuture(tower-0.5.3/tower/src/util/boxed_clone_sync.rs:52),每个请求仍有一次 future 装箱——这是 §22.1.2 那层类型擦除的代价。Future 类型可命名是第 2 章列出的 tower 相比 async fn in trait 的优势之一(§2.2.3 还列了"没法写显式 Send/Sync bound"和"下游生态建立在 type Future 上"两条),axum 在对外接口上保留了这个形状。
一个相关的细节:Router<S> 泛型在 S != () 时不是 Service——只有 Router<()> 才是。这是 axum 用类型系统强制 "必须 with_state(...) 把状态注入完才能部署" 的方式。你尝试把 Router<AppState> 直接 axum::serve(listener, router) 会编译错——Router<S> 上没有为这个错误单独写 #[diagnostic::on_unimplemented],报的是普通的 trait bound 不满足;Router::with_state 的文档(axum/src/docs/routing/with_state.md:78-79,以 include_str! 挂到 with_state 上)专门解释了这一点:"This is because we can only call Router::into_make_service on Router<()>, not Router<AppState>."。把运行期规则编译期化,这在 Rust 框架设计里反复出现。
22.1.2 Route 是怎么装下任意 Service 的
Router 的每一条路由背后都是一个 Route——见 axum/src/routing/route.rs:31:
pub struct Route<E = Infallible>(BoxCloneSyncService<Request, Response, E>);
Route 的本质就是 tower::util::BoxCloneSyncService<Request, Response, E>——类型擦除过的 Service。这个类型擦除非常关键:Router 要在一个 HashMap 里存"任意不同类型的 handler",必须把类型差异塞到 dyn 背后。Route::new 定义在 axum/src/routing/route.rs:33-41:
impl<E> Route<E> {
pub(crate) fn new<T>(svc: T) -> Self
where
T: Service<Request, Error = E> + Clone + Send + Sync + 'static,
T::Response: IntoResponse + 'static,
T::Future: Send + 'static,
{
Self(BoxCloneSyncService::new(MapIntoResponse::new(svc)))
}
}
MapIntoResponse::new(svc) 是一个轻量 Service 包装,做的事情就是"把 T::Response 调一次 .into_response()"。经过它之后,里外都是 Response,可以放进 BoxCloneSyncService<Request, Response, E>。这是泛型到 trait object 的经典收口:泛型在接收用户代码时保持最大灵活性,Box<dyn> 在内部存储时消除类型差异。
和 tower 的 ServiceBuilder 一样,axum 的 Router 是一个 typed-at-the-edges / erased-in-the-middle 的结构。这个设计在 Rust 生态被反复使用——tonic 的 BoxService(tonic/src/transport/server/mod.rs:76,BoxCloneService 的类型别名)、sqlx 的 DatabaseExtension、tower 的 Steer(第 8 章)都用同一个配方。
22.1.3 Handler trait:函数即 Service
用户写 async fn handler(Path(id): Path<u64>, Json(body): Json<UserReq>) -> impl IntoResponse 时并不自觉——这个普通异步函数为什么能塞进 Router::route(path, get(handler)) 调用?
答案在 axum/src/handler/mod.rs:145-205 的 Handler trait 定义:
#[diagnostic::on_unimplemented(
note = "Consider using `#[axum::debug_handler]` to improve the error message"
)]
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;
fn layer<L>(self, layer: L) -> Layered<L, Self, T, S>
where /* ... */ { /* ... */ }
fn with_state(self, state: S) -> HandlerService<Self, T, S> {
HandlerService::new(self, state)
}
}
Handler 是 Service 的 "用户友好变种"——对比 Service::call(&mut self, Request) -> Future<Response>,Handler 的 call(self, Request, state) -> Future<Response> 差别只有两点:self 是 by-value(Handler 实现 Clone,可以每次请求拷一份)、多了 state: S 参数。
类型参数 T 是什么?文档注释在 axum/src/handler/mod.rs:138-141 自己解释:
The type parameter
Tis a workaround for trait coherence rules, allowing us to write blanket implementations ofHandlerover many types of handler functions with different numbers of arguments, without the compiler forbidding us from doing so because one typeFcan in theory implement bothFn(A) -> XandFn(A, B) -> Y.
这就是 axum 的魔法入口:用一个类型参数 T 把"函数签名"塞进 trait system,绕开 coherence 的限制——理论上同一个类型 F 可以同时实现 Fn(A) -> X 和 Fn(A, B) -> Y,没有 T 来区分,各元数的 blanket impl 就会被判为重叠。
22.1.4 blanket impl + all_the_tuples
handler 的核心魔术在 axum/src/handler/mod.rs:221-262:
macro_rules! impl_handler {
(
[$($ty:ident),*], $last:ident
) => {
#[diagnostic::do_not_recommend]
#[allow(non_snake_case, unused_mut)]
impl<F, Fut, S, Res, M, $($ty,)* $last> Handler<(M, $($ty,)* $last,), S> for F
where
F: FnOnce($($ty,)* $last,) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = Res> + Send,
S: Send + Sync + 'static,
Res: IntoResponse,
$( $ty: FromRequestParts<S> + Send, )*
$last: FromRequest<S, M> + Send,
{
type Future = Pin<Box<dyn Future<Output = Response> + Send>>;
fn call(self, req: Request, state: S) -> Self::Future {
let (mut parts, body) = req.into_parts();
Box::pin(async move {
$(
let $ty = match $ty::from_request_parts(&mut parts, &state).await {
Ok(value) => value,
Err(rejection) => return rejection.into_response(),
};
)*
let req = Request::from_parts(parts, body);
let $last = match $last::from_request(req, &state).await {
Ok(value) => value,
Err(rejection) => return rejection.into_response(),
};
self($($ty,)* $last,).await.into_response()
})
}
}
};
}
all_the_tuples!(impl_handler);
短短四十来行承担了 axum 整个 Handler 系统的心脏。拆开看:
- 对任意
F: FnOnce(T1, T2, ..., Tn, Tlast) -> Fut,只要所有前置参数Ti都是FromRequestParts,最后一个参数Tlast是FromRequest,返回值Res是IntoResponse——就给Fblanket 实现Handler<(M, T1, T2, ..., Tn, Tlast), S>。 call的实现逻辑:先Request.into_parts(),按顺序用每个FromRequestParts把parts提取成Ti,最后用FromRequest把完整 Request 提取成Tlast,再调用self(...)拿到Res,最后res.into_response()。- 任何一步
Err(rejection)都直接rejection.into_response()——错误也是 Response,不会冒泡出 handler。
all_the_tuples! 是 axum/src/macros.rs:49-68 里的宏,从 [] T1 到 [T1..T15] T16 共展开 16 次——前置参数 0 到 15 个、外加一个 $last,也就是 1 到 16 个参数;零参数的 handler 另有一条单独的 impl<F, Fut, Res, S> Handler<((),), S> for F(axum/src/handler/mod.rs:207-219)。这就是为什么你能写 0 到 16 个 extractor 参数的 handler——超过 16 个编译器报错,你的函数签名太夸张了。
这套"泛型 + tuple 宏展开"和 Rust 标准库给 12 元以内 tuple 实现 Debug/PartialEq/Hash 等 trait 的宏是同一个套路。第 2 章谈 tower 的 Service::call 为什么不用 async fn、偏要 type Future 时,背后是同样的考虑:当 trait 方法返回类型要根据实现自动推导时,只能靠关联类型。
22.1.5 从 Handler 到 Service 的桥:HandlerService
Handler 和 Service 的桥接点在 axum/src/handler/service.rs——Handler::with_state(state) 返回 HandlerService<H, T, S>,这个类型直接实现 tower::Service<Request>。它在内部就是把 self.handler.clone().call(req, self.state.clone()) 包成一个 Future。
所以 get(handler) 背后的完整链路是:
Router::route("/users/{id}", get(handler))
└─ get(handler) 返回 MethodRouter,内部把 handler 先包成 MethodEndpoint::BoxedHandler(BoxedIntoRoute)(axum/src/routing/method_routing.rs:638)
└─ 注入 state 时(with_state / 顶层 Router<()>)Handler::with_state(state) 变成 HandlerService: Service<Request>
└─ Route::new(HandlerService) 再 MapIntoResponse 再 BoxCloneSyncService
└─ 存进 PathRouter 的路由表
handler 从"普通异步函数"到"存在路由表里的 dyn Service"中间经过了四次转换——但用户完全感觉不到。axum 的哲学是"免费抽象让新手友好,零成本让老手满意"。实际发生的事情是两三个 Clone + 一次 trait object 构造——每个请求一次,和 tokio task 的调度开销比可以忽略。
值得额外强调的是Layered<L, H, T, S>(axum/src/handler/mod.rs:285-350)——它是Handler 本身可以装 Layer的支撑结构,和"Router 整体装 Layer"是两条不同的路径。看源码 axum/src/handler/mod.rs:317-350 的实现:
impl<H, S, T, L> Handler<T, S> for Layered<L, H, T, S>
where
L: Layer<HandlerService<H, T, S>> + Clone + Send + Sync + 'static,
H: Handler<T, S>,
L::Service: Service<Request, Error = Infallible> + Clone + Send + 'static,
<L::Service as Service<Request>>::Response: IntoResponse,
<L::Service as Service<Request>>::Future: Send,
/* ... */
{
type Future = future::LayeredFuture<L::Service>;
fn call(self, req: Request, state: S) -> Self::Future {
use futures_util::future::{FutureExt, Map};
let svc = self.handler.with_state(state);
let svc = self.layer.layer(svc);
/* ... oneshot ... */
}
}
Layered 让你写 handler.layer(SomeLayer::new(...)) ——给单个 handler 叠 Layer,而不是给整个 Router。这个颗粒度在某些场景很有用:你只想给 /upload 这一个端点加 RequestBodyLimitLayer::new(100 * 1024 * 1024) 而不影响其他端点——用 Router 级别的 .layer 不行、用 handler 级别的 .layer 正好。
22.1.6 为什么 Handler 不直接等于 Service
看到这里你可能会问:既然 Handler 最终都要变成 Service,为什么不干脆让用户直接写 Service?技术上 axum 确实有 route_service(...) 允许直接挂 Service;但 route(..., get(handler)) 才是主流——差别在人体工程学。
写 Service:每次都得 impl Service<Request>、手写 poll_ready / call、自己处理 body 反序列化、自己组织 Response 头。十几行模板代码才能写出一个"接 JSON 返 JSON"的端点。
写 Handler:async fn create_user(State(pool): State<PgPool>, Json(req): Json<CreateReq>) -> Result<Json<User>, AppError>——一行参数声明完成数据反序列化、状态注入、错误处理声明。代码量差出一个数量级。
这是为什么 axum 不满足于"只做 Service"——Service 是一个协议无关的抽象,而 Handler 是 HTTP-REST 场景专用的高阶抽象。Service 对框架作者友好、Handler 对用户友好。axum 同时支持两层,让用户按需下钻。
22.1.7 一条完整请求走过的路
把以上内容串起来,一条 HTTP 请求到达 axum::serve 之后的实际流程:
sequenceDiagram
participant TCP as TcpListener
participant Hyper as hyper::server::conn
participant Router as axum::Router (Service)
participant MethodRouter as MethodRouter
participant Route as Route (BoxCloneSyncService)
participant MapInto as MapIntoResponse
participant HS as HandlerService
participant Handler as async fn user_handler
TCP->>Hyper: accept() → TcpStream
Hyper->>Router: Service::call(Request<Incoming>)
Router->>Router: path_router.match(&uri) → RouteId
Router->>MethodRouter: oneshot(req)
MethodRouter->>MethodRouter: 按 method 选 handler
MethodRouter->>Route: oneshot(req)
Route->>MapInto: oneshot(req)
MapInto->>HS: call(req)
HS->>Handler: Handler::call(self, req, state)
Handler->>Handler: 按顺序 FromRequestParts 提取
Handler->>Handler: FromRequest 提取(body)
Handler->>Handler: 业务代码
Handler-->>HS: Res
HS-->>MapInto: Res
MapInto-->>MapInto: Res.into_response()
MapInto-->>Route: Response
Route-->>Router: Response
Router-->>Hyper: Response
Hyper->>TCP: 序列化 + 写出
看得到 axum 的 request lifecycle 分四段:路由(path/method match) → 提取(FromRequest) → 执行(业务代码) → 转换(IntoResponse)。每一段都有独立 trait——这是 axum 能在 0.4 到 0.8 几个 major 版本里持续演进而API 向后兼容大部分代码的根因:关注点分离得好。
22.2 Extract 系统:如何类型级提取
22.2.1 两个 trait 背后的哲学
打开 axum-core/src/extract/mod.rs:50-89:
#[diagnostic::on_unimplemented(
note = "Function argument is not a valid axum extractor. \nSee `https://docs.rs/axum/0.8/axum/extract/index.html` for details"
)]
pub trait FromRequestParts<S>: Sized {
type Rejection: IntoResponse;
fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send;
}
#[diagnostic::on_unimplemented(
note = "Function argument is not a valid axum extractor. \nSee `https://docs.rs/axum/0.8/axum/extract/index.html` for details"
)]
pub trait FromRequest<S, M = private::ViaRequest>: Sized {
type Rejection: IntoResponse;
fn from_request(
req: Request,
state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send;
}
两个 trait 看起来差不多,但有两个关键区别:
FromRequestParts只拿&mut Parts——即 method / uri / headers / extensions / version。不能碰 body。所以可以出现多次,用在 handler 的前 N 个参数。FromRequest拿整个Request——可以消费 body。所以只能出现一次,用在 handler 的最后一个参数。
这个 "前 N 个参数用 Parts 型,最后一个用 Request 型" 的约束就是前一节 impl_handler! 里 $($ty: FromRequestParts<S>,)* $last: FromRequest<S, M> + Send, 的根源。Rust 类型系统在这里承担了一个设计强制:body 只能被消费一次这条 HTTP 语义被编译进了 trait bound,编译期就挡住所有想在 handler 里提取两次 body 的愚蠢代码。
另一个巧妙点——所有 FromRequestParts 都经由 marker M = ViaParts 被 blanket 实现为 FromRequest(普通 body 型 extractor 用的是默认的 M = ViaRequest)(axum-core/src/extract/mod.rs:91-104):
impl<S, T> FromRequest<S, private::ViaParts> for T
where
S: Send + Sync,
T: FromRequestParts<S>,
{
type Rejection = <Self as FromRequestParts<S>>::Rejection;
fn from_request(
req: Request,
state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> {
let (mut parts, _) = req.into_parts();
async move { Self::from_request_parts(&mut parts, state).await }
}
}
任何 FromRequestParts 的类型自动也是 FromRequest——只是丢弃 body。这样用户在 handler 里任何位置都能写 Path<T>、Query<Q>、HeaderMap 这些 Parts 型提取器;body 型提取器(Json<T>、Bytes、String、Multipart)必须放最后。M 这个 marker 类型参数就是为了让 blanket impl 和各 body 型 extractor 的 impl FromRequest<S> for Json<T>(实际上是 impl FromRequest<S, ViaRequest>)不冲突 —— 这是典型的 "newtype + marker" 绕开 coherence。
22.2.2 Path extractor:extension 驱动
看 axum/src/extract/path/mod.rs:157-190:
impl<T, S> FromRequestParts<S> for Path<T>
where
T: DeserializeOwned + Send,
S: Send + Sync,
{
type Rejection = PathRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
fn get_params(parts: &Parts) -> Result<&[(Arc<str>, PercentDecodedStr)], PathRejection> {
match parts.extensions.get::<UrlParams>() {
Some(UrlParams::Params(params)) => Ok(params),
Some(UrlParams::InvalidUtf8InPathParam { key }) => { /* ... */ }
None => Err(MissingPathParams.into()),
}
}
/* ... */
match T::deserialize(de::PathDeserializer::new(get_params(parts)?)) {
Ok(val) => Ok(Self(val)),
Err(e) => Err(failed_to_deserialize_path_params(e)),
}
}
}
Path extractor 从 parts.extensions 里拿出 UrlParams——这个 extensions 值是路由匹配阶段由 PathRouter 提前放进去的。用户的视角:写 Path(id): Path<u64>;实际发生:路由匹配 → 把捕获的 segments 放 extensions → extractor 从 extensions 取出来 → 调 serde DeserializeOwned 反序列化。
这里可以关联《Serde 元编程》专栏里讲的 Deserializer 自定义——de::PathDeserializer 就是一个手写的 Deserializer 实现,把 &[(Arc<str>, PercentDecodedStr)] 这种 URL 捕获 tuple 列表"伪装"成 serde 数据源,让 T::deserialize 可以按 struct / tuple 的方式反序列化(详见《Serde 元编程》专栏第 4 章 "Deserializer 与 Visitor:反序列化的反向控制")。这个模式极其通用——axum::extract::Query 在 axum 0.8.9 里是用 serde_urlencoded 做的(主线已换成 serde_html_form),Json 是用 serde_json,它们都走"把具体数据格式包装成 Deserializer 实现,再让 serde 驱动反序列化"的路子。
22.2.3 Json extractor:body 消费
看 axum/src/json.rs:99-114:
impl<T, S> FromRequest<S> for Json<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = JsonRejection;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if !json_content_type(req.headers()) {
return Err(MissingJsonContentType.into());
}
let bytes = Bytes::from_request(req, state).await?;
Self::from_bytes(&bytes)
}
}
注意三层的组合:
Json本身是FromRequest(消费 body)。- 它内部调用
Bytes::from_request(req, state).await?——复用Bytes这个 body 型 extractor。Bytes的实现是http_body_util::BodyExt::collect()把流式 body 全收进内存。 - 拿到 bytes 后
Self::from_bytes(&bytes)(axum/src/json.rs:164)用serde_json反序列化(外面套了一层serde_path_to_error,让错误信息带上字段路径)。
这一层套一层的复用很关键——用户自己写自定义 extractor 时,几乎永远是 "先 delegate 给别人抓到 bytes,再自己做一步反序列化"。把 serde_json 换成 rmp_serde 就是一个 Msgpack<T> extractor——代码长度也就 20 行(背后的道理见《Serde 元编程》专栏第 2 章 "Serde Data Model:29 种原语的设计哲学")。
22.2.4 State extractor:FromRef 的组合学
用户写 State(pool): State<PgPool>,背后要求 PgPool: FromRef<S>(S 本身就是 PgPool 时靠 impl<T: Clone> FromRef<T> for T 这条 blanket impl 自动满足)。FromRef 定义在 axum-core/src/extract/from_ref.rs:14-17:
pub trait FromRef<T> {
/// Converts to this type from a reference to the input type.
fn from_ref(input: &T) -> Self;
}
这就允许用户写一个 AppState struct 里塞 3 个子 state(PgPool、RedisClient、Settings),给 AppState 加上 #[derive(FromRef)],为每个字段类型派生出 FromRef<AppState>——handler 里就可以按需只 State(pool): State<PgPool>。这是组合子关系的典型:单个 state 可以组成大 state、大 state 可以投影到小 state,全部在类型系统上表达、零运行时开销。Rust 编译器的单态化(《Rust 编译器与运行时揭秘》专栏第 6 章 "单态化:泛型的编译期展开")确保这些 trait 查询全部在编译期消失。
22.2.5 自定义 extractor 的完整例子
假设你想要"从 Authorization header 提取 Bearer token 并校验签名"的 extractor,你这样写:
struct AuthedUser {
pub id: i64,
pub roles: Vec<String>,
}
impl<S> FromRequestParts<S> for AuthedUser
where
S: Send + Sync,
Arc<JwtKey>: FromRef<S>, // 要求 S 里能拿出 JwtKey
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let key: Arc<JwtKey> = FromRef::from_ref(state);
let token = parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or((StatusCode::UNAUTHORIZED, "missing bearer"))?;
let claims = verify_jwt(&key, token)
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid jwt"))?;
Ok(AuthedUser { id: claims.sub, roles: claims.roles })
}
}
写完这一个 impl,所有 handler 都可以写 async fn foo(user: AuthedUser, ...)——不需要过 middleware、不需要在 handler 里 .unwrap()、不需要 context / extensions manual 传递。鉴权逻辑被压缩进类型系统。
对比 Go 的 net/http middleware 写法——你要用 context.WithValue(ctx, authedUserKey, user),然后在 handler 里 ctx.Value(authedUserKey).(*AuthedUser)——类型全丢失、nil 检查要自己做。对比 Python 的 FastAPI——它有 Depends(get_current_user) 也能达到类似效果,代价是运行时依赖注入、错误到运行期才抛。Rust 的 extractor 是编译期静态依赖注入——写错 trait bound 直接编译报错。
22.3 IntoResponse:返回值多态
22.3.1 trait 本体
axum-core/src/response/into_response.rs:115-119:
pub trait IntoResponse {
/// Create a response.
#[must_use]
fn into_response(self) -> Response;
}
一个方法、一个返回值、一行文档——axum 的全部返回值多态都压在这个 trait 上。和 FromRequest 对称:那边是 "Request → T",这边是 "T → Response"。
22.3.2 十几种现成实现
axum-core/src/response/into_response.rs:121 起的一长串 IntoResponse 实现清单本身就是 axum 用户体验的"配方"(Html<T>、Json<T> 两条在 axum 主 crate 的 axum/src/response/mod.rs:39、axum/src/json.rs:197;tuple 形式靠 HeaderMap: IntoResponseParts 那套 impl_into_response! 宏展开):
| 类型 | 含义 |
|---|---|
StatusCode |
空 body + 指定状态码 |
() |
空 body + 200 |
Infallible |
不可达 |
Result<T, E> |
T: IntoResponse、E: IntoResponse,递归 |
Response<B> |
身份映射 |
&'static str / String / Cow<'static, str> |
text/plain |
Bytes / BytesMut |
application/octet-stream |
Html<T> |
text/html |
Json<T> |
application/json |
(StatusCode, T) |
把 status 和 T 的 response 合起来 |
(HeaderMap, T) |
把 headers 合进 T 的 response |
(StatusCode, HeaderMap, T) |
三元组 |
重点看 Result<T, E> 的实现(axum-core/src/response/into_response.rs:141-152):
impl<T, E> IntoResponse for Result<T, E>
where
T: IntoResponse,
E: IntoResponse,
{
fn into_response(self) -> Response {
match self {
Ok(value) => value.into_response(),
Err(err) => err.into_response(),
}
}
}
这条 blanket impl 几乎是 axum handler 里 ? 语法工作的所有秘密:你的 handler 返回 Result<Json<User>, AppError>,AppError 实现 IntoResponse(把业务错误映射到合适的 HTTP status 和 body),整个 Result 自动成 IntoResponse。再往前倒推:tuple 型 (StatusCode, Json<Value>) 实现了 IntoResponse,于是你可以写:
async fn create_user(/* ... */) -> Result<(StatusCode, Json<User>), AppError> {
Ok((StatusCode::CREATED, Json(user)))
}
这个 "用 tuple 把多个 Response 片段拼起来" 的模式是 axum 和 actix-web、rocket 等框架最显眼的差别——前者靠纯 trait 组合、后者靠"Builder 对象"。纯 trait 组合的代价是 IDE hover 上可能看不出来你返回的到底是什么(impl IntoResponse 藏了一切),优势是扩展性:你自己的 MyCoolResponse 只要实现 IntoResponse 就能和所有官方类型平权组合。
22.3.3 impl IntoResponse 在错误提示里的陷阱
用户最常被卡住的编译错误是 handler 签名不对。axum 用 #[diagnostic::on_unimplemented] 做用户友好提示(见 axum/src/handler/mod.rs:145-147、axum-core/src/extract/mod.rs:50-52、axum-core/src/response/into_response.rs:112-114)——编译器在 trait 未满足时除了正常错误消息还会打印这些注释。Rust 1.78 开始稳定的这个属性被 axum 大量用来引导用户看文档。
一个著名的坑:
下面是读者示例,不是 axum 源码原文——bad 只是示例自取的 handler 名:
async fn bad() -> impl IntoResponse {
if cond() {
return "text"; // 这里是 &'static str
}
Json(data) // 这里是 Json<T>
}
这段编译不过——impl IntoResponse 要求所有分支返回同一具体类型。想混合类型,就得把各分支统一成 Response,或者用 Result/Either 这类枚举把两种类型包起来。这是 Rust "zero-cost impl Trait" 的代价——不像 Go 的 interface{} 可以接任意类型。解决方案就是手动 .into_response() 统一类型:
async fn good() -> Response {
if cond() {
return "text".into_response();
}
Json(data).into_response()
}
22.3.4 错误类型的惯用设计
在生产项目里,AppError 一般是这样的结构:
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
Validation(#[from] validator::ValidationErrors),
#[error("internal: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
Self::Db(_) | Self::Internal(_) => {
tracing::error!(error = ?self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
这套模式的优点:handler 里可以随便 ? 掉 sqlx::Error 或 validator::ValidationErrors(靠 #[from] 自动转成 AppError),AppError::into_response 集中决定每种错误的 HTTP status 和日志策略。错误日志和返回解耦——Db 错误只在日志里暴露细节,给客户端统一返回 "internal error" 避免信息泄漏(SQL injection 的探测器最喜欢的就是详细错误消息)。
这套模式深度融合了 Rust 的两大生态:thiserror(错误定义)和 anyhow(错误承载)。在 axum 里这两者配合 IntoResponse 形成一条清爽的业务错误 → HTTP 响应的单向数据流,基本不会漏错误、不会暴露敏感信息、不会多写重复代码。
22.4 Tonic 骨架:gRPC over HTTP/2
22.4.1 transport::Server 的起点
打开 tonic/src/transport/server/mod.rs:23-27:
use hyper_util::{
rt::{TokioExecutor, TokioIo, TokioTimer},
server::conn::auto::{Builder as ConnectionBuilder, HttpServerConnExec},
service::TowerToHyperService,
};
整个 Tonic Server 的 import 头就直白写着:我们用 hyper-util 的 auto Builder 跑连接、用 TokioExecutor 做 spawn、用 TokioIo 把 tokio 的 TcpStream 桥到 hyper 的 IO trait、用 TowerToHyperService 把 tower Service 适配成 hyper Service。
Tonic Server 的连接级代码在 tonic/src/transport/server/mod.rs:766-791:
let server = {
let mut builder = ConnectionBuilder::new(TokioExecutor::new());
if http2_only {
builder = builder.http2_only();
}
builder
.http2()
.timer(TokioTimer::new())
.initial_connection_window_size(init_connection_window_size)
.initial_stream_window_size(init_stream_window_size)
.max_concurrent_streams(max_concurrent_streams)
.keep_alive_interval(http2_keepalive_interval)
.keep_alive_timeout(http2_keepalive_timeout)
.adaptive_window(http2_adaptive_window.unwrap_or_default())
.max_pending_accept_reset_streams(http2_max_pending_accept_reset_streams)
.max_local_error_reset_streams(http2_max_local_error_reset_streams)
.max_frame_size(max_frame_size);
if let Some(max_header_list_size) = max_header_list_size {
builder.http2().max_header_list_size(max_header_list_size);
}
builder
};
这段代码回到了第 15-17 章讲 hyper HTTP/2 的那些参数:initial_stream_window_size / initial_connection_window_size(hyper 的 Builder 上没有笼统的 initial_window_size,stream 级和连接级是两个旋钮,见 hyper-1.9.0/src/server/conn/http2.rs:157、170;hyper 内部再把 stream 级那个交给 h2 的 initial_window_size,hyper-1.9.0/src/proto/h2/server.rs:133)、keep_alive_interval、keep_alive_timeout、max_pending_accept_reset_streams(rapid reset CVE 的修复旋钮)——全都是 Tonic 在透传 hyper 的配置。tonic 自己对外的方法名多带一个 http2_ 前缀:Server 上是 initial_stream_window_size / initial_connection_window_size、http2_keepalive_interval、http2_keepalive_timeout、http2_max_pending_accept_reset_streams(tonic/src/transport/server/mod.rs:236、247、299、314、338),客户端 Endpoint 上的两个窗口方法同名(tonic/src/transport/channel/endpoint.rs:334、344)。Tonic 只是 "给 gRPC 用的预设面板"——你自己拿 hyper 直接起 HTTP/2 server,一行一行配也能达到同样效果。但这块面板的默认值并不都等于 hyper 的默认值:DEFAULT_HTTP2_KEEPALIVE_TIMEOUT = Duration::from_secs(20)(tonic/src/transport/server/mod.rs:79)与 hyper 自己的默认相同(hyper-1.9.0/src/proto/h2/server.rs:72);而 max_concurrent_streams、http2_max_local_error_reset_streams 在 tonic 里默认是 None(tonic/src/transport/server/mod.rs:127、136),上面那段代码又无条件把它们传给 hyper,于是覆盖掉了 hyper 的 Some(200) 与 Some(1024)(hyper-1.9.0/src/proto/h2/server.rs:68、70)——tonic 默认既不限制单连接的并发 stream 数,也不启用本地错误 reset 的上限,需要这两道防线就得自己显式设置。
22.4.2 TowerToHyperService:关键桥
tonic/src/transport/server/mod.rs:820-828 是最核心的一段:
let req_svc = svc
.call(&io)
.await
.map_err(super::Error::from_source)?;
let hyper_io = TokioIo::new(io);
let hyper_svc = TowerToHyperService::new(req_svc.map_request(|req: Request<Incoming>| req.map(Body::new)));
serve_connection(hyper_io, hyper_svc, server.clone(), graceful.then(|| signal_rx.clone()), max_connection_age);
三行三个桥:
svc.call(&io)——svc是一个MakeService,每个连接 call 一次,产出本连接专用的 request-level service。这就是常说的 MakeService 模式(hyper 1.9.0 自身已不再提供 MakeService,每连接一个 service 由上层框架自己组织)。TokioIo::new(io)——把tokio::io::AsyncRead + AsyncWrite适配成hyper::rt::Read + Write。这是 hyper 1.0 之后 IO trait 独立的必要桥(第 13 章讲过)。TowerToHyperService::new(req_svc)——tower::Service<Req>(poll_ready+call(&mut self))和hyper::service::Service<Req>(只有call(&self))是不同的 trait(hyper 1.0 故意把自己的 Service trait 独立了,避免强耦合到 tower)。TowerToHyperService是一个 newtype 桥:impl<S, R> hyper::service::Service<R> for TowerToHyperService<S> where S: tower_service::Service<R> + Clone(hyper-util 仓库src/service/glue.rs:33-35)——每次call先 clone 一份内层 service,再用Oneshot走完poll_ready→call(第 19 章 §19.3)。
然后是 map_request——把 Request<Incoming>(hyper 的 body type)映射成 Request<Body>(tonic 自己的 body type)。Tonic 自己的 Body 是对 http_body_util::combinators::UnsyncBoxBody<Bytes, Status> 的薄封装(tonic/src/body.rs,内部是 Kind::Empty | Kind::Wrap(UnsyncBoxBody))——多一层统一,让 tonic 内部的各个 codec / streaming 都不用 care 上游 body 具体类型。
22.4.3 UnaryService / ServerStreamingService / ClientStreamingService / StreamingService
打开 tonic/src/server/service.rs——里面定义了四个 trait,分别对应 gRPC 的四种调用模式。以 UnaryService 为例(tonic/src/server/service.rs:10-19):
pub trait UnaryService<R> {
type Response;
type Future: Future<Output = Result<Response<Self::Response>, Status>>;
fn call(&mut self, request: Request<R>) -> Self::Future;
}
这个 trait 看起来和 tower::Service 几乎一模一样——只是少了 poll_ready、Error 固定成 Status。紧接着的 tonic/src/server/service.rs:21-31:
impl<T, M1, M2> UnaryService<M1> for T
where
T: Service<Request<M1>, Response = Response<M2>, Error = crate::Status>,
{
type Response = M2;
type Future = T::Future;
fn call(&mut self, request: Request<M1>) -> Self::Future {
Service::call(self, request)
}
}
blanket impl—— 任何符合形状的 tower::Service 自动实现 UnaryService。同理 ServerStreamingService(response 是 Stream)、ClientStreamingService(request 是 Streaming)、StreamingService(双向都是)都是对 tower::Service 不同"形状"的 blanket impl(tonic/src/server/service.rs:51-122)。
这是 tonic 最漂亮的设计:用户通过 #[tonic::async_trait] 写的业务代码(handler),由 tonic-build 给每个方法生成一个小结构体(例如 SayHelloSvc<T>),直接实现相应的 UnaryService 等 trait(tonic-build/src/server.rs:463),再交给 Grpc::unary/server_streaming/... 消费;整个生成的 GreeterServer<T> 对外则是一个标准的 tower::Service<http::Request<B>>。这四条 blanket impl 让任何形状对得上的 tower::Service 也能直接喂给 Grpc 的四个方法。外层对外(tower 生态)是标准 Service、内层对内(gRPC 语义)是四种专用 trait——两套语言的翻译机。
22.4.4 Routes:Tonic 内部是 axum::Router
打开 tonic/src/service/router.rs:14-16:
#[derive(Debug, Clone)]
pub struct Routes {
router: axum::Router,
}
你没看错——Tonic 的 Routes 里直接 hold 一个 axum::Router。add_service 的实现在 tonic/src/service/router.rs:78-94:
pub fn add_service<S>(mut self, svc: S) -> Self
where
S: Service<Request<Body>, Error = Infallible>
+ NamedService
+ Clone
+ Send
+ Sync
+ 'static,
S::Response: axum::response::IntoResponse,
S::Future: Send + 'static,
{
self.router = self.router.route_service(
&format!("/{}/{{*rest}}", S::NAME),
svc.map_request(|req: Request<axum::body::Body>| req.map(Body::new)),
);
self
}
每加一个 gRPC service——例如 S::NAME = "helloworld.Greeter"——就给 axum Router 加一条 /helloworld.Greeter/{*rest} 的通配路由。方法派发由通配的 *rest 部分(例如 /SayHello)在 gRPC service 内部再做一次匹配。Service<Request<B>> for Routes(tonic/src/service/router.rs:143-160)直接 delegate 到 self.router.call(req)。
这是工程品味的巅峰表现——tonic 没有重复实现"把路径映射到 handler"这件事。axum 已经做得很好了,tonic 直接用,仅在 IntoResponse / Body 的边界上做一点点 map 适配。这条设计决策落在 tonic 0.7.0(PR #830 "port router to axum",把自研 router 换成 axum Router;该提交 146 行新增、310 行删除,路由相关代码净减),是一条值得记住的"放弃自研"的胜利。
这里也能看到 "NamedService" 这个 trait 的巧妙——每个 gRPC service 通过 tonic-build 生成时自动派生 impl NamedService for GreeterServer<T>,关联 const NAME: &'static str = "helloworld.Greeter"。Routes::add_service 直接用这个 NAME 拼 path。用户不需要显式指定 path,path 就是 gRPC service 的 fully-qualified name——这是 gRPC 规范要求的格式(/package.Service/Method)。这个设计把路由规则自动化,消除了"gRPC service 名和路由前缀对不上"这类低级 bug 的存在空间。
22.4.5 混部 REST + gRPC 的半官方入口
tonic/src/service/router.rs:106-113(into_axum_router 是 0.12.2 由 into_router 改名而来,PR #1862):
pub fn into_axum_router(self) -> axum::Router {
self.router
}
pub fn axum_router_mut(&mut self) -> &mut axum::Router {
&mut self.router
}
暴露两个方法——一个消费 self 拿到 axum Router,一个拿可变引用可以继续 .route(...) 挂 REST 接口。这就是官方认可的 "REST + gRPC 混部" 入口。在 22.7 我们展开。
22.5 Codec + stream:gRPC 四种调用类型
22.5.1 gRPC-over-HTTP/2 的物理格式
gRPC 每一条逻辑消息在 HTTP/2 DATA frame 里的字节布局固定是:
+--------+------------------+-------------------+
| 1 byte | 4 bytes | message bytes |
+--------+------------------+-------------------+
| flag | big-endian u32 | protobuf payload |
| (0/1) | length | |
+--------+------------------+-------------------+
flag 是 compression 标志(0 = 不压缩、1 = 按 grpc-encoding header 指定的算法压缩)。length 是后面 payload 的字节数。message bytes 是 protobuf 序列化之后的字节。这个5 字节头在 tonic 源码里以 HEADER_SIZE 常量出现(tonic/src/codec/mod.rs:93-98):
#[doc(hidden)]
pub const HEADER_SIZE: usize =
// compression flag
std::mem::size_of::<u8>() +
// data length
std::mem::size_of::<u32>();
22.5.2 encode 路径
tonic/src/codec/encode.rs:133-179 的 encode_item 就是把一条 proto message 编进 buffer 的完整流程:
fn encode_item<T>(
encoder: &mut T,
buf: &mut BytesMut,
uncompression_buf: &mut BytesMut,
compression_encoding: Option<CompressionEncoding>,
max_message_size: Option<usize>,
buffer_settings: BufferSettings,
item: T::Item,
) -> Result<(), Status>
where
T: Encoder<Error = Status>,
{
let offset = buf.len();
buf.reserve(HEADER_SIZE);
unsafe {
buf.advance_mut(HEADER_SIZE);
}
if let Some(encoding) = compression_encoding {
// …(这个分支先 encode 到 uncompression_buf、再 compress 进 buf,共 18 行,与本节论点无关,略去)
} else {
encoder
.encode(item, &mut EncodeBuf::new(buf))
.map_err(|err| Status::internal(format!("Error encoding: {err}")))?;
}
// now that we know length, we can write the header
finish_encoding(compression_encoding, max_message_size, &mut buf[offset..])
}
关键技巧:先 advance_mut(HEADER_SIZE) 预留 5 字节,先把 proto 编码进去后才能知道真实长度,最后 finish_encoding 回填 flag + length。这个 "先占位后回填" 的写法在很多 length-prefixed 协议的编码器里都能看到——避免两遍序列化。
22.5.3 EncodeBody 与 HTTP/2 trailers
tonic 最巧妙的一点是把 gRPC Status 映射到 HTTP/2 Trailers。看 tonic/src/codec/encode.rs:281-300:
impl EncodeState {
fn trailers(&mut self) -> Option<Result<HeaderMap, Status>> {
match self.role {
Role::Client => None,
Role::Server => {
if self.is_end_stream {
return None;
}
self.is_end_stream = true;
let status = if let Some(status) = self.error.take() {
status
} else {
Status::ok("")
};
Some(status.to_header_map())
}
}
}
}
以及 impl Body for EncodeBody<T, U> 里的 poll_frame(tonic/src/codec/encode.rs:314-334):
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let self_proj = self.project();
match ready!(self_proj.inner.poll_next(cx)) {
Some(Ok(d)) => Some(Ok(Frame::data(d))).into(),
Some(Err(status)) => match self_proj.state.role {
Role::Client => Some(Err(status)).into(),
Role::Server => {
self_proj.state.is_end_stream = true;
Some(Ok(Frame::trailers(status.to_header_map()?))).into()
}
},
None => self_proj
.state
.trailers()
.map(|t| t.map(Frame::trailers))
.into(),
}
}
流程:
- 业务 stream 出一条消息——把消息编码成 DATA frame,发出去。
- 业务 stream 耗尽(
Poll::Ready(None))——生成一个 HeaderMap,里面填grpc-status: 0(message 为空时不写grpc-message,tonic/src/status.rs:548),作为 trailers 发出去。 - 业务中途
Err(status)——同样生成 trailers,填grpc-status: <code>。
grpc-status 在 HTTP/2 里走 trailers——也就是 DATA 之后、带 END_STREAM 的那个 HEADERS 帧(HTTP/2 没有单独的 "TRAILERS" 帧类型;第 15 章讲过 HPACK 动态表如何编码 header、第 16 章讲过 stream 半关状态如何处理)——这是 gRPC 刻意选择的:把最终 status code 放在 trailers 里,而不是 response header,这样服务端可以先 stream 完数据再判断最终成功 / 失败。这对 server-streaming 尤其关键——一个流了一半突然失败的 RPC,客户端通过 trailers 清楚知道"前 1000 条是有效数据,之后失败了"。
这在 HTTP/1 上很难做到——HTTP/1.1 只有 Transfer-Encoding: chunked 能带 trailers,实际支持并不普遍。gRPC 选择 HTTP/2 的核心理由之一就是 trailers,另一个是多路复用。
22.5.4 四种调用类型的映射
在 tonic/src/server/grpc.rs:219-362 定义了 unary / server_streaming / client_streaming / streaming 四个方法——分别处理四种 gRPC 调用:
| gRPC 调用类型 | HTTP/2 映射 | request body | response body |
|---|---|---|---|
| unary | 1 个 stream | 1 条消息 | 1 条消息 + trailers |
| server-streaming | 1 个 stream | 1 条消息 | N 条消息 + trailers |
| client-streaming | 1 个 stream | N 条消息 | 1 条消息 + trailers |
| bidi-streaming | 1 个 stream | N 条消息 | N 条消息 + trailers |
表里的"消息"指带 5 字节头的 gRPC 消息,一条消息可能跨多个 DATA 帧、多条消息也可能挤在同一个 DATA 帧里;请求方向不带 trailers(客户端角色的 EncodeState::trailers 直接返回 None,见 §22.5.3 的源码),trailers 只出现在响应方向。
看 tonic/src/server/grpc.rs:219-259 的 unary:
pub async fn unary<S, B>(
&mut self,
mut service: S,
req: http::Request<B>,
) -> http::Response<Body>
where
S: UnaryService<T::Decode, Response = T::Encode>,
/* ... */
{
/* ... */
let request = match self.map_request_unary(req).await {
Ok(r) => r,
Err(status) => { /* error response */ },
};
let response = service
.call(request)
.await
.map(|r| r.map(|m| tokio_stream::once(Ok(m))));
/* map_response */
}
注意 tokio_stream::once(Ok(m))——unary 响应也是通过 Stream 语义走的,"一条消息" 只是 "N 条消息" 的特例。这是一个非常干净的设计:tonic 内部只有一套 streaming 引擎,unary 只是 once 出来的一元流。所有四种调用类型走同一条 encode 管线。
这种"把特例统一成一般"的抽象选择和 AsyncRead 为什么只有 poll_read 而没有 read_one_byte 的道理相通——一个接口(Stream)+ 一个特例(once)总是比"两个接口(一次性 / 流式)"更容易维护、更少 bug、更可组合。tonic 的工程师显然很清楚这一点,把它贯彻到了 codec 层。
看完 tonic 的 codec,你应该能回答这个问题:为什么 gRPC 的单向一次 RPC(unary)和 bidi streaming 在你的代码里长得几乎一样? 因为在 tonic 内部它们走的就是同一套代码路径,只是 Stream 的长度分别是 1 和 N。这带来一个副作用——你可以在调试时把 unary 方法临时当 streaming 方法打开,例如加一个 streaming response 版本做 A/B 测试,proto 定义改一下、业务代码几乎不变。
22.5.5 trailers 被代理吞掉时,tonic 怎么判
gRPC-over-HTTP/2 把最终状态放在 trailers 里(grpc-status)。一旦中间的代理不转发 trailers,客户端拿到的就是一个"有 body、没有最终状态"的响应。tonic 对这种情况的处理在 tonic/src/status.rs(0.14.5 的第 788-807 行):
trace!("trailers missing grpc-status");
let code = match status_code {
// Borrowed from https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
http::StatusCode::BAD_REQUEST => Code::Internal,
http::StatusCode::UNAUTHORIZED => Code::Unauthenticated,
http::StatusCode::FORBIDDEN => Code::PermissionDenied,
http::StatusCode::NOT_FOUND => Code::Unimplemented,
http::StatusCode::TOO_MANY_REQUESTS
| http::StatusCode::BAD_GATEWAY
| http::StatusCode::SERVICE_UNAVAILABLE
| http::StatusCode::GATEWAY_TIMEOUT => Code::Unavailable,
// We got a 200 but no trailers, we can infer that this request is finished.
//
// This can happen when a streaming response sends two Status but
// gRPC requires that we end the stream after the first status.
//
// https://github.com/hyperium/tonic/issues/681
http::StatusCode::OK => return Err(None),
_ => Code::Unknown,
};
注意最后那条 200 分支——它不是报错,是 return Err(None):tonic 认为"HTTP 200 且没有 trailers"意味着这次请求正常结束了。映射表本身也不是 tonic 自创的,注释里写明借自 gRPC 官方的 http-grpc-status-mapping.md;200 分支的注释则指向 tonic#681。注意这条分支是 0.14.5 的行为:从 tonic 0.14.6 起(PR #2543 "map no trailers ok status to unknown",tag tonic-v0.14.6),"200 无 trailers"改为返回一个 Code::Unknown 的 Status,下面说的"静默截断"会变成显式报错,升级时请复核。
这条分支决定了故障的表现形式,值得记住:
- 代理返回 4xx / 5xx 且丢了 trailers:还有救——按上表映射成
Unauthenticated/Unavailable等,客户端至少知道出了什么类别的问题。 - 代理返回 200 但丢了 trailers:客户端看到的是"流正常结束",不会有任何错误。服务端本来要发的
Status(哪怕是cancelled或internal)就此消失,业务代码拿到的是一个提前结束、且看起来成功的 stream。
所以这类问题最典型的症状不是报错,而是静默截断:结果少了几条,日志里没有异常。排查时先确认链路上的代理是否转发 HTTP/2 trailers,再去看业务代码——和第 17 章讲 GOAWAY / RST_STREAM 时的思路一致,传输层的语义丢失往往不以异常的形式出现。
22.5.6 Decode 路径:从 DATA frame 到 proto message
编码讲完,反过来看 decode。tonic/src/codec/decode.rs 里的 Streaming<T> 是 tonic 暴露给业务的流式解码器——impl Stream<Item = Result<T, Status>>。它内部做的事情是反过来的 5 字节头处理:
- 从 http_body 里
poll_frame()拿 DATA frame 的 Bytes。 - 累积到内部的
BytesMutbuffer 里,看是否至少有 5 字节。 - 读 flag(是否压缩)+ u32 length。
- 确认后续累积了 length 字节的完整消息。
- 按 flag 调用
Decoder::decode(&mut DecodeBuf)反序列化出 proto message。 - 剩余字节留在 buffer 里,等下一帧。
这是一个经典的"帧组装状态机"——和 HTTP/1 chunked body 的 parser、postgresql wire protocol、redis RESP 的 parser 同结构。tonic 的实现有一个优化:每个 RPC 先按 buffer_size 预分配一块 buffer(tonic/src/codec/decode.rs:137),读到长度头后再按消息长度 reserve(tonic/src/codec/decode.rs:197),避免频繁 realloc。buffer_size 默认 8 KiB;同一组设置里的 yield_threshold 默认 32 KiB,作用在编码侧——攒够这么多待发字节再一次性交出去(tonic/src/codec/encode.rs:117)。你在 tonic/src/codec/mod.rs:67-89 的 BufferSettings 里能看到对应的旋钮——大部分应用不需要动,极端大消息 + 高吞吐时可以把 buffer_size 调到 64 KiB 或 yield_threshold 调高以批量 flush。
22.5.7 从 proto 到 Prost:Codec 的实际实现
Codec trait 只是接口。tonic 0.14 实际用的是 tonic_prost::ProstCodec(prost 支持已拆到独立的 tonic-prost crate)——一个把 Codec::encode 委托给 prost::Message::encode 的薄壳。tonic-build 生成的服务代码里你会看到:
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
/* 实际还链式调用 apply_compression_config / apply_max_message_size_config,此处省略 */;
let res = grpc.unary(method, req).await;
换句话说,tonic 并不强绑定 prost。只要实现 Codec / Encoder / Decoder 三个 trait,你可以用 bincode、msgpack、甚至自定义二进制格式替换——这就是 tonic 的 Codec 抽象层。这在内部服务间自定义协议时很有用:享受 HTTP/2 的多路复用 + 头部压缩 + trailers,但把 protobuf 换成你偏好的编码。
关联《Serde 元编程》第 2 章 "Serde Data Model"——Serde 的数据模型刻意与具体格式解耦,所以非 serde 驱动的编码(比如 prost)也能被包一层 serde 外衣。反之,Codec 也可以把 serde_json::to_writer 包成一个 Codec,让你用 JSON 跑 gRPC——语义上不是"纯 gRPC"但体验上一模一样。内部服务想要流式 + 双向通信、又不想引入 protobuf 时,JSON-over-gRPC 就是这么做出来的。
22.6 两者共享的 Tower 生态:Layer 可互通
22.6.1 Service 是同一个 Service
我们前面已经看到的关键事实:
axum::Router<()>: tower::Service<Request<B>, Response = Response, Error = Infallible>(axum/src/routing/mod.rs:599-618)tonic::service::Routes: tower::Service<Request<B>, Response = Response<Body>, Error = Infallible>(tonic/src/service/router.rs:143-160)
两者签名极度接近——都是 Service<Request<B>, Error = Infallible>,Response 只差一个 Body 类型参数。这意味着任何一个对"Service<Request<B>, Error = Infallible>"通用的 Layer,都能同时装到 axum 和 tonic 上。
我们举几个例子。
22.6.2 共用的 Tower Layer 例子
tower::timeout::TimeoutLayer(tower-0.5.3/tower/src/timeout/layer.rs):
use std::time::Duration;
use tower::ServiceBuilder;
// Axum 侧
use axum::{error_handling::HandleErrorLayer, http::StatusCode, BoxError};
let app = axum::Router::new()
.route("/", get(handler))
.layer(
ServiceBuilder::new()
// Timeout 的错误类型是 BoxError,axum 要求 Infallible,先把错误转成响应
.layer(HandleErrorLayer::new(|_: BoxError| async { StatusCode::REQUEST_TIMEOUT }))
.layer(tower::timeout::TimeoutLayer::new(Duration::from_secs(30))),
);
// Tonic 侧
let server = tonic::transport::Server::builder()
.layer(tower::timeout::TimeoutLayer::new(Duration::from_secs(30)))
.add_service(GreeterServer::new(svc));
同一个 Layer、同一份语义——30 秒内不回就超时——对 REST 和 gRPC 同时有效。两侧的差别在错误类型:axum 的 Router::layer(axum/src/routing/mod.rs:296-302)要求套上 Layer 之后的 Service 错误满足 Into<Infallible>;而 Timeout 的错误类型是 Box<dyn Error + Send + Sync>,直接 .layer(TimeoutLayer::new(..)) 会编译失败(实测 E0277),所以 axum 侧要先用 HandleErrorLayer 把错误变成响应;tonic 的 Server 只要求错误能转成 BoxError(tonic/src/transport/server/mod.rs:635-636),可以直接装。细节:tonic 的 Server::layer 把 Layer 叠到整个 Routes 的入口,每个 gRPC service 也各自可以再叠 tower Layer。
tower::retry::RetryLayer——第 5 章讲过。唯一要注意的是 retry 要求 Policy 能 clone_request(tower-0.5.3/tower/src/retry/policy.rs:89)——而在 HTTP 这一层,请求体 tonic::body::Body(tonic/src/body.rs:10-13)只 derive 了 Debug,不能克隆,要重试就得先把 body 缓冲下来再重建:unary 只有一条消息,还做得到;streaming 调用基本不行。实际生产中 retry 通常在 client 侧而不是 server 侧——第 5 章的组合示例也是把 Retry 包在调用第三方服务的客户端(ocr_client)外面。
tower_http::trace::TraceLayer(tower-http-0.6.8/tower-http/src/trace/mod.rs):
let app = axum::Router::new()
.route("/", get(handler))
.layer(
tower_http::trace::TraceLayer::new_for_http()
.make_span_with(|req: &Request<_>| {
tracing::info_span!("http", method = %req.method(), uri = %req.uri())
})
);
// tonic 同样可以 .layer(...)
TraceLayer::new_for_grpc()(tower-http-0.6.8/tower-http/src/trace/layer.rs:198)是 gRPC 专用的变体——语义相同,只是把失败分类器换成 GrpcErrorsAsFailures:按响应/trailers 里的 grpc-status 而不是 HTTP 状态码判断一次调用算不算失败。
tower::load_shed::LoadShedLayer——第 6 章讲过。可以同样装在 Axum 和 Tonic 上做过载保护。
tower::limit::ConcurrencyLimitLayer——第 4 章讲过。同样通用,但注意挂法:经 axum 的 Router::layer 挂上去时,每个端点各套一份、各有一个信号量,限的不是总并发(《Axum 设计与实现》专栏第 12 章有实测);要全局限流,用共享信号量的 GlobalConcurrencyLimitLayer,或把它套在整个 Router 外层。
22.6.3 Layer 的类型体操
同一个 Layer 能通用,依赖的是 Rust 的一个核心设计:trait-based generic programming。Go 的 net/http middleware 用 http.Handler 接口、gRPC-go 的 interceptor 用 grpc.UnaryServerInterceptor 函数类型——两者不是同一个东西。Go 工程师想写一个"同时给 REST 和 gRPC 用的 trace middleware"只能分别写两份,或者自己在底层自造一层抽象。Rust 的 Service trait 因为足够通用——输入/输出/错误全泛型——承担了这个"通用中间件语言"的角色。
这一点和第 3 章讲 Layer trait 作为"横切关注点代数"的那节对上。tower 的 Layer 不仅是"Service 的 wrapper",而是"描述 middleware 如何组合的代数"——Layer 之间可以 Stack、可以 Identity,完美满足 monoid 结构。Axum 和 Tonic 同为这个代数的消费者,自然继承了这个代数的所有性质。
22.6.4 生产案例:一个 Layer 管所有请求
我在某次迭代里给一个混部服务(axum REST + tonic gRPC)统一加了一组 tower_http 的 Layer,代码大概是这样:
use tower_http::{
trace::TraceLayer,
compression::CompressionLayer,
cors::CorsLayer,
set_header::SetResponseHeaderLayer,
};
let layer_stack = tower::ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(SetResponseHeaderLayer::if_not_present(
http::header::HeaderName::from_static("x-server-id"),
http::HeaderValue::from_static("rust-svc-01"),
));
let app = axum::Router::new()
.route("/api/v1/users", get(list_users))
.merge(grpc_router)
.layer(layer_stack);
一条 Layer stack 同时生效于 REST 路由和 gRPC 路由——trace 给两边打 span;compression 虽然也装在两边,但它的默认谓词 DefaultPredicate 会跳过 content-type 为 application/grpc 的响应(tower-http-0.6.8/tower-http/src/compression/predicate.rs:123-129,按前缀匹配,application/grpc+proto 也算),所以实际只压 REST 响应。这种统一层的方便度在 Go 或 Python 里要付出明显更多适配工作。
有个坑需要提醒:CompressionLayer 对 gRPC 要小心——gRPC 自己定义了 grpc-encoding: gzip 头,tonic 开了 send_compressed 之后会在单消息级别压缩。外层再压一遍 HTTP body 等于压两次,带来纯 overhead。默认配置下这件事不会发生,正是因为上面那条 NotForContentType::GRPC;但如果你用 compress_when 换了自定义谓词,要记得把它保留下来:
use tower_http::compression::{
predicate::{NotForContentType, Predicate, SizeAbove},
CompressionLayer,
};
let compression = CompressionLayer::new()
.compress_when(SizeAbove::new(1024).and(NotForContentType::GRPC));
这种可组合的谓词也是 Layer 生态的优势——同一个 Layer 可以按响应特征决定对谁生效。
22.7 实战:Axum + Tonic 混部
22.7.1 需求场景
一个后端服务同时要提供:
/api/v1/users这类 REST JSON 接口给前端。helloworld.Greeter/SayHello这类 gRPC 方法给内部微服务调用。/grpc-web/helloworld.Greeter/SayHello这类 gRPC-Web 供浏览器直接调用。/metricsPrometheus 抓取、/healthzliveness 探针。
理论上可以开三个端口——REST + gRPC + Admin。但 k8s 部署里常见要求 "一个服务一个端口"——服务网格 mTLS、端口配额、安全组配置都按端口维度管理。把四套东西塞到一个端口上是常见需求。
22.7.2 实现方式:axum Router 收口
tonic 0.12.2 起的官方支持方式(Routes::into_axum_router)——tonic 转成 axum Router,再在 axum 里拼:
use axum::routing::get;
use tonic::service::Routes;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. 创建 gRPC service
let greeter = MyGreeter::default();
let reflection = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(proto::FILE_DESCRIPTOR_SET)
.build_v1()?;
// 2. 把 tonic Routes 转成 axum Router
let grpc_router = Routes::new(GreeterServer::new(greeter))
.add_service(reflection)
.prepare() // 让 axum 做一次路由内部整理(perf)
.into_axum_router(); // 拿到 axum::Router
// 3. 合并 REST 路由
let app = axum::Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/api/v1/users", get(list_users))
.route("/metrics", get(metrics))
.merge(grpc_router); // 与 gRPC 合一
// 4. 一个端口跑全部
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
这里 axum::Router::merge 做的事情就是把两棵路由树合并——gRPC 的 /helloworld.Greeter/* 和 REST 的 /api/v1/users 共存于一个 PathRouter。路径不冲突时 zero conflict;冲突时 axum 会 panic——PathRouter::merge(axum/src/routing/path_router.rs:146-170)把路径冲突以 Err 返回,调用方 Router::merge 再用 panic_on_err! 把它变成 panic(axum/src/routing/mod.rs:284);两个 Router 都自带 fallback 时则直接 panic!(axum/src/routing/mod.rs:280)。
这条 fallback 规则在混部时要特别留意:tonic 的 Routes 自带一个 fallback(tonic/src/service/router.rs:51,返回 gRPC Unimplemented),merge 之后它就成了整个 app 的 fallback。按上面的代码实测(axum 0.8.9、tonic 0.14.5、tokio 1.46.1),curl 一个不存在的 REST 路径拿到的是 200 + content-type: application/grpc + grpc-status: 12,而不是 404;而如果你的 REST Router 自己也设了 .fallback(..),merge 会按上面那条规则直接 panic。
22.7.3 协议协商:HTTP/1 还是 HTTP/2?
axum::serve 用的是 hyper-util 的 auto::Builder(axum/src/serve/mod.rs:603),按连接开头的字节自动选 HTTP/1.1 或 HTTP/2。于是:
- 浏览器 HTTP/1.1 过来——走 REST。
- gRPC-Web 过来——走 HTTP/1.1 + gRPC-Web(前提是给 gRPC 路由装上 §22.7.4 的
GrpcWebLayer,上面的代码没有装)。 - gRPC 原生过来——走 HTTP/2(明文 h2c,靠 preface 识别)。
单个 TCP 端口同时支持三种协议——hyper-util 的自动协议协商是关键。第 19 章(§19.5)讲过 hyper-util 的 auto::Builder 通过预读前 24 字节的 HTTP/2 connection preface 判断:如果是 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 就走 HTTP/2,否则走 HTTP/1.1。还有一个容易忽略的前提:axum 的默认 feature 里只有 http1 没有 http2(axum/Cargo.toml:41-51),上面的例子能直接收 gRPC,是因为 tonic 的 server feature 打开了 hyper-util/server-auto(其中含 http2),经 Cargo 的 feature 合并一起生效;单独用 axum 想收 HTTP/2,要显式打开 axum 的 http2 feature。按 §22.7.2 的代码实测:curl 的 HTTP/1.1 与 --http2-prior-knowledge 请求、tonic 客户端的 SayHello 与 reflection ListServices 都在同一个端口上成功。
实测要注意的细节:auto::Builder 看的是明文字节。如果在进程内终结 TLS,走不走 HTTP/2 是在 TLS 握手里由 ALPN 协商决定的——TLS 配置里要把 h2 和 http/1.1 都列进去,否则 gRPC 客户端协商不到 h2;解密之后的流照样可以交给 auto::Builder 按 preface 判断。另一种常见做法是让 ingress(Envoy / Istio)终结 TLS,再用明文(h2c 或 HTTP/1.1)转给后端,后端纯明文跑 hyper-util auto,简单可靠。
22.7.4 gRPC-Web 的 grpc_web 层
gRPC-Web 是一个浏览器适配协议——把 gRPC 的 HTTP/2 特性(trailers、binary framing)包装成 HTTP/1.1 可以承载的格式。tonic 的 tonic_web crate 提供一个 Layer:
use tonic_web::GrpcWebLayer;
tonic::transport::Server::builder()
.accept_http1(true) // 允许 HTTP/1.1 的 gRPC-Web
.layer(GrpcWebLayer::new()) // 转换层
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
这是 tonic-web 文档给出的标准接法(目前只支持 unary 与 server-streaming)。GrpcWebLayer 本质上是一个 tower::Layer——把 gRPC-Web 请求转换成内部等价的 gRPC 请求,响应方向再转回来。这又是"Layer 作为协议转换桥"的经典用法。
具体点——gRPC-Web 的 wire format 和原生 gRPC 有两个差异:第一,trailers 不能走 HTTP/2 的 trailers HEADERS 帧(HTTP/1 没有),所以 gRPC-Web 把 trailers 编码成 body 末尾额外的一帧(同样是 5 字节头 + 内容),用 flag 字节的最高位(0x80,tonic-web/src/call.rs:44)标记"这是 trailer";第二,content-type 变成 application/grpc-web+proto 或 application/grpc-web-text(后者 base64 包了一层,让纯文本 HTTP 代理也能透传)。GrpcWebLayer 在 request 方向把 application/grpc-web 改成 application/grpc、在 response 方向把 trailers 编码成 gRPC-Web 的 trailer 帧——用户业务代码一行都不用改。
22.7.5 metrics 和 healthcheck
/metrics 这类特殊端点通常要绕过所有业务 middleware——Prometheus 抓取不应该被 rate limit 拦、不应该被 auth middleware 拦。axum 的 Router::route + Router::merge 的组合让这很好实现:
let public_routes = axum::Router::new()
.route("/metrics", get(metrics_handler))
.route("/healthz", get(|| async { "ok" }))
.route("/readyz", get(readiness_handler));
let biz_routes = axum::Router::new()
.route("/api/v1/users", get(list_users))
.merge(grpc_router)
.layer(auth_layer) // 只给业务路由
.layer(rate_limit_layer);
let app = public_routes.merge(biz_routes);
auth_layer 只作用在 .layer(..) 之前的 Router——public_routes 不受影响。这种分层应用 Layer 的能力在第 3 章我们讲过,axum 直接利用 Router 的组合结构表达清楚。
22.7.6 生态里的同款架构
这不是某一家的独门做法:tonic 自己的 Routes 就建在 axum Router 上(§22.4.4),linkerd2-proxy 的控制面客户端走 tonic、数据面走 hyper + tower,都是"一个 Service 底座、上面按协议分叉"的形状。单端口、axum 扛 REST、tonic 扛 gRPC、共用一组 tower Layer 做 tracing / limit / auth,是这个生态的默认姿势。
为什么 Rust 生态可以做到而其他语言很吃力?——见下节对照。
22.8 和 Go net/http + grpc-go、Python FastAPI + grpc 的对照
22.8.1 Go:cmux 分流
Go 的标准答案是 cmux——一个连接多路复用库。做法:
m := cmux.New(listener)
grpcListener := m.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"))
httpListener := m.Match(cmux.HTTP1Fast())
go grpcServer.Serve(grpcListener) // grpc-go
go httpServer.Serve(httpListener) // net/http
m.Serve()
cmux 通过预读字节判断协议,分流到两个 listener——然后 gRPC 和 HTTP 各跑自己的 server。这个方案能工作,但有三个尴尬:
- 不共用 middleware——grpc-go 的
grpc.UnaryServerInterceptor和 net/http 的http.Handlermiddleware 不是同一个东西。想要 "一个 trace 同时用" 必须自己在下游写适配层。 - 两份连接池 / goroutine 池——两个 Server 独立管理。
- 两份 graceful shutdown 协调——要想"5 秒内不结束的请求全部回 503" 这类全局策略,你得自己写调度代码。
22.8.2 Python:ASGI + grpcio,两个进程
Python FastAPI + grpcio 的混部通常是两个进程——FastAPI 跑 uvicorn、grpcio 跑自己的 server、前面挂 nginx/envoy 路由。原因:Python 的 asyncio event loop 和 grpcio 用的 grpc.aio 虽然都基于 asyncio,但 grpcio 自己做了 C++ 核心(grpc_core 绑定),和 uvicorn 的 loop 协调极难。于是实践上就放弃"同进程共用"——拆两个进程。
22.8.3 Rust:trait 通用导致框架可共栈
Rust 的 axum + tonic 共栈能跑起来,本质原因是tower::Service 这个 trait 不 care 你的协议——Service<Request<B>, Response = Response<B>> 本身就是一个协议无关的 "请求-响应" 抽象。axum 是它在 REST 语义下的用户壳、tonic 是它在 gRPC 语义下的用户壳、中间件是它上面的代数运算——三者共享同一个底座 trait。
Go 没有这个特性——http.Handler 是 ServeHTTP(ResponseWriter, *Request) 的具体接口,grpc-go 的 handler 签名是 (context.Context, interface{}) (interface{}, error),两者在类型系统上无共同祖先。Python 动态类型避开了这个问题但代价是运行时才发现不匹配。Rust 的 Service trait 凭借完全泛型的 Input/Output/Error 找到了"通用形状 + 各自特化"的黄金分割点。
22.8.4 代价
不是没有代价——
- 编译慢。axum 的 Handler 系统大量使用 blanket impl + tuple 宏展开,让编译器做大量类型推导,中型服务的全量编译明显偏慢。
- 错误信息长。一个 handler 签名写错,trait 找不到时错误消息非常长。好在
#[diagnostic::on_unimplemented]帮着引导。 - IDE 支持有限。rust-analyzer 对深层泛型的补全仍然弱于 gopls 或 pylance。
这是典型的 "编译期复杂度换运行期性能" 的交易。对愿意接受编译器税的团队,Rust 的"框架共栈"是生产力红利。
22.8.5 什么时候不共栈
共栈也不是银弹。三种场景应该拆:
-
gRPC 业务和 REST 业务有完全不同的 SLA。比如 gRPC 是内部高频 RPC(p99 < 5ms 要求)、REST 是给外部 OpenAPI(慢一点没事)。混在一个 runtime 里、一组 tower Layer 里,你很难单独给 gRPC 独占的资源池。拆端口、拆进程、拆 k8s Pod——按 SLA 隔离更清爽。
-
鉴权机制完全不同。gRPC 走 mTLS、REST 走 OAuth2 Bearer token——虽然技术上一个 middleware 可以路由不同 extractor,但把复杂的鉴权混在一起不利于审计。大多数合规环境(PCI / HIPAA)审计喜欢简单清晰的边界。
-
团队拆分边界。如果 REST 和 gRPC 由两个不同团队维护、deploy 周期不同——用单进程只会让两边的变更互相阻塞。
我见过的经验做法:初期混部、规模上来后按业务切分。早期共栈让基础设施代码(trace / metrics / auth)只写一份;随着业务多起来,把高 QPS / 高 SLA 的 gRPC 服务拆成独立 deployment,REST 部分保留混部状态。Rust 的 tower::Service 统一底座让这种拆分几乎零迁移成本——代码基本不动,只是 main.rs 里改几行 Router 组合。
22.8.6 axum 全家桶 35223 行的工程账本
把 §22.0 提到的"axum 框架"按真实分布拆开——axum 不是单 crate,它在 crates.io 上分四个 crate 联合发布。下面所有行数都是同一个口径:各 crate src/ 下全部 .rs 文件的物理行数(含空行、注释和 #[cfg(test)] 模块),版本以本专栏锚定的那份 checkout 为准。
| crate | 行数 | 角色 |
|---|---|---|
axum-0.8.9 |
20902 | Router + Handler + 内置 extractor + serve |
axum-core-0.5.6 |
3271 | IntoResponse / FromRequest 核心 trait + body 抽象 |
axum-macros-0.5.1 |
3687 | #[debug_handler] + #[derive(FromRequest)] 的 proc-macro |
axum-extra-0.12.6 |
7363 | 额外 extractor(Cookie、Query 多种风格、Multipart、Typed Header) |
| 合计 | 35223 | axum "framework" 的完整含义 |
主 crate(axum-0.8.9)内部按子目录拆:
| 子目录 | 行数 | 占比 | 含义 |
|---|---|---|---|
src/routing/ |
7004 | 33% | mod.rs 840 + path_router.rs + method_routing.rs 等 |
src/extract/ |
5910 | 28% | Path / Query / Json / Form / State 等内置 extractor |
src/middleware/ |
1715 | 8% | from_fn / from_extractor 中间件包装 |
src/response/ |
1518 | 7% | IntoResponse 实现集 |
src/serve/ |
1465 | 7% | serve 主入口(封装 hyper-util) |
src/handler/ |
700 | 3% | Handler + IntoService 体操 |
| 其他 | 2590 | 12% | body / boxed / extension / form / json / 测试基础设施 |
两条直接观察:
- routing + extract 占 axum 主 crate 62%(12914 / 20902)——axum 的"框架感"几乎全部来自这两块;本章 §22.1-22.3 也确实把篇幅集中在这里,比例和源码分布一致。
- macros 单独占 3687 行——
#[debug_handler]在 handler 签名写错时给出更准确的报错,#[derive(FromRequest)]等派生宏替用户生成 extractor 实现;它们都在 axum 的macrosfeature 后面,默认 feature 并不包含(axum/Cargo.toml:41-51)。§22.8.4 说的"编译慢"主要来自 Handler 那套 blanket impl + tuple 宏展开,而不是这个 crate。
把这个分布和 tower-service 那 401 行放在一起看——"协议薄、框架重" 在 axum 这里是又一次实证:tower-service 401 行 → tower 11935 行 → hyper 1.9.0 全部 21587 行 → axum 全家桶 35223 行。每往应用层抬一级,代码量都上一个台阶。同口径下 tonic 0.14.5 是 14926 行,比 axum 主 crate 少约三成——gRPC 的协议约束更强,留给框架发挥的余地反而小。
22.9 落到你键盘上
22.9.1 本章核心
flowchart LR
TCP[TcpListener] --> HyperUtilAuto[hyper-util auto::Builder]
HyperUtilAuto -->|HTTP/1| HyperConn1[hyper::server::conn::http1]
HyperUtilAuto -->|HTTP/2| HyperConn2[hyper::server::conn::http2]
HyperConn1 & HyperConn2 --> TowerToHyper[TowerToHyperService]
TowerToHyper --> TowerService[tower::Service]
TowerService -->|REST 路径| AxumRouter[axum::Router]
TowerService -->|gRPC 路径| TonicRoutes[tonic::Routes]
TonicRoutes -.持有.-> AxumRouter
AxumRouter --> Handler[Handler / FromRequest / IntoResponse]
TonicRoutes --> GrpcCodec[Grpc + Codec + Streaming]
TowerService -.可叠加.-> Layers[TimeoutLayer<br/>TraceLayer<br/>LoadShedLayer<br/>ConcurrencyLimitLayer<br/>...]
一张图说明 axum 和 tonic 是同一栈的上层——底下共用 hyper + hyper-util + tower,中间通过 TowerToHyperService 这类桥 trait 连接,上层用户 API 按协议语义分叉。
22.9.2 可立刻做的练习
-
Router 当 tower::Service 用。在一个没有
axum::serve的环境里(例如 Cloudflare Workers)把axum::Router拿出来,手工用router.clone().oneshot(request).await处理每个请求,体会 "axum 只是一个 Service" 的事实。 -
手写一个 FromRequestParts。定义一个
struct ClientVersion(pub String),从X-Client-Versionheader 提取,没有 header 时 reject 400。参考axum/src/extract/path/mod.rs的实现模式——50 行之内搞定。 -
给 tonic 装一个 tower::retry。用
tower::retry::RetryLayer+ 自己的 Policy(对 UNAVAILABLE 重试 2 次),装在 tonic client 的 channel 上。注意Channel的Error只是传输层错误,gRPC 状态要在 Policy 里自己解析(服务端直接返回错误时是 trailers-only 响应,grpc-status就在响应头里);请求体要先缓冲才能在clone_request里重建(见 §22.6.2)。体会 Layer 是 "横切" 的含义——服务代码完全不需要改。 -
单端口跑 axum + tonic。按 22.7 的代码起一个进程,同时
curl http://localhost:8080/healthz和grpcurl localhost:8080 helloworld.Greeter/SayHello。再grpcurl -use-reflection看 reflection service 是否正常。 -
看一次 trailers。用
nghttp -v -H 'content-type: application/grpc' -H 'te: trailers' -d req.bin http://localhost:8080/helloworld.Greeter/SayHello(req.bin是按 §22.5.1 的 5 字节头编好的请求消息)抓包,确认返回的 HEADERS / DATA / 带 END_STREAM 的 HEADERS(trailers)三帧——理解 22.5.3 那段源码的物理含义。
22.9.3 工程清单
整理成一张可以贴在工位上的清单:
- 写 axum handler 时,参数顺序是 "所有
FromRequestParts在前、一个FromRequest在后"。写错就编译报错,看#[diagnostic::on_unimplemented]的提示。 - 写自定义 extractor 时,先想清楚"需不需要消费 body"——不需要就实现
FromRequestParts(可以在多个参数复用),需要就实现FromRequest(只能放最后)。 - 写错误类型时,
thiserror::Error+impl IntoResponse for MyError,集中决定每种错误对应的 HTTP status 和日志级别。 - 生产部署 tonic 服务时,先验证中间代理层支持 HTTP/2 trailers——这是 gRPC 生产故障的常见根因。
- Layer 尽量写成协议无关
Service<Request<B>>形式——能同时给 axum 和 tonic 复用,拒绝 "REST 一个版本、gRPC 一个版本"的重复劳动。 - tonic + axum 混部时用
Routes::into_axum_router()走官方支持路径,不要自己手动match path_prefix再 demux——axum 的 PathRouter 已经做得比你写的更好。 - 给 tonic 顶层配置
CatchPanicLayer——panic 时至少回一个 HTTP 500,而不是让这条 stream 直接断掉。注意这个 500 不带grpc-status,tonic 客户端只能按 HTTP 状态码推断出Unknown(tonic-v0.14.5/tonic/src/status.rs:775-806);想要更明确的 gRPC 状态,得自己定制 panic 时的响应。
22.9.4 延伸阅读
如果本章引起你的兴趣,推荐再啃几段:
axum-macros里的#[derive(FromRequest)]和#[debug_handler]——看过程宏如何辅助生成复杂的 trait 实现、以及如何用 span 定位 trait 错误。tonic-build生成的 server 代码——找一个在build.rs里调用tonic-prost-build的工程,打开OUT_DIR下生成的.rs,观察它如何把.protoservice 定义编译成Service<http::Request<B>>实现。tower::util里的BoxCloneSyncService/MapErr/Oneshot——axum 和 tonic 都在大量使用。看这些通用工具的实现能加深你对 Service trait 体操的理解。- h2 crate 的
SendStream::send_trailers——tonic 编码出的 trailers 最终在这个 API 上落地。读它会让你把"gRPC trailer = HTTP/2 结尾那个带 END_STREAM 的 HEADERS 帧"这件事物理意义上看清楚。
22.9.5 下一章预告
本章我们讲清楚了 "axum 与 tonic 如何构建在 hyper + tower 之上"——框架层。下一章(第23章 生产环境调优)切到运维层:
- 完整的超时矩阵:TCP、HTTP、请求、Body 四层时钟怎么叠。
- 端到端背压:业务慢了,信号有没有真的传到 h2 window、TCP buffer、客户端。
- 优雅关闭与 K8s 部署:SIGTERM 到 pod 死亡的那 30 秒里 hyper 在做什么。
- 可观测性、压测方法,以及三个典型事故的复盘(pool 耗尽、Slowloris、rolling update 500)。
- 一份可以直接复制的生产 Builder 模板。
调完参之后,这个专栏的循环才闭合——从 "Service trait 的形状" 一路走到 "线上 p99 为什么会突然抖"。生产的网络栈没有魔法,每一个毫秒都能从源码找到出处。