Appearance
第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 系统"其实就是一组过程宏 + 泛型 trait 在
Service<Request<Body>>之上做的用户友好壳,而 tonic 的Routes内部真的就持有一个axum::Router。看源码一眼穿透。
本章我们把 axum 与 tonic 的源码并排读:Router → Service、Handler → FromRequest/IntoResponse、Codec → HTTP/2 DATA frame、Metadata → HTTP/2 Trailers。源码版本按 axum 0.8 与 tonic 0.14 的当前 main 分支(对应 axum/src、tonic/src 目录)。所有行号均按实际文件给。
读完本章,你能:
- 解释
axum::Router为什么可以直接当作tower::Service<Request<Body>>用。 - 用
#[diagnostic::on_unimplemented]诊断"handler 不满足 trait"类错误的根因。 - 给 tonic Server 装一个通用
tower::retry::RetryLayer——知道它装在哪一层、为什么能装。 - 在同一个
TcpListener上同时跑 Axum(REST)和 Tonic(gRPC)——理解Routes::into_axum_router()在做什么。 - 和 Go 的
net/http + grpc-go、Python 的FastAPI + grpcio对照,看懂"为什么 Rust 可以做到框架级复用,而其他语言不太行"。
22.1 Axum 骨架:Router + Service + Handler
22.1.1 Router 是什么
打开 axum/src/routing/mod.rs:86-88:
rust
#[must_use]
pub struct Router<S = ()> {
inner: Arc<RouterInner<S>>,
}Router<S> 的核心就是一个 Arc<RouterInner<S>>——S 是缺失的应用状态类型,() 表示"状态齐了,可以服役"。RouterInner<S> 结构体在 mod.rs:98-102:
rust
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>>——见 mod.rs:599-618:
rust
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::server::conn::http1::Builder::serve_connection(io, router)这种最朴素的调用方式成立。poll_ready永远 Ready——Router 是无状态的 demux,背压不发生在这里。背压由每条路由内部的 Service负责(例如ConcurrencyLimitLayer 装进来后才会有Poll::Pending)。
这是第 2 章我们拆 Service trait 时反复强调的模式:"Error = Infallible 的 Service 是幂等安全的 Service"——axum 选择这条路,是因为 HTTP 服务器的语义里 panic-safe 的 4xx/5xx 返回比 Err 更自然。
再看 Future = RouteFuture<Infallible>——这是一个具名的 future 类型,不是 Pin<Box<dyn Future>>。对比 tonic 的 Routes 用 RoutesFuture,两者都走"关联类型 + 具名 Future struct"的路子,避免每个请求都分配一次 trait object。这是第 2 章强调过的 tower 设计原则——Future 类型可命名是 tower 相比 async fn in trait 的唯一优势,也是 axum 愿意继承这个形状的理由。
一个相关的细节:Router<S> 泛型在 S != () 时不是 Service——只有 Router<()> 才是。这是 axum 用类型系统强制 "必须 with_state(...) 把状态注入完才能部署" 的方式。你尝试把 Router<AppState> 直接 axum::serve(listener, router) 会编译错——错误消息由 #[diagnostic::on_unimplemented] 精心引导到 Router::with_state 文档。把运行期规则编译期化,这在 Rust 框架设计里反复出现。
22.1.2 Route 是怎么装下任意 Service 的
Router 的每一条路由背后都是一个 Route——见 axum/src/routing/route.rs:31:
rust
pub struct Route<E = Infallible>(BoxCloneSyncService<Request, Response, E>);Route 的本质就是 tower::util::BoxCloneSyncService<Request, Response, E>——类型擦除过的 Service。这个类型擦除非常关键:Router 要在一个 HashMap 里存"任意不同类型的 handler",必须把类型差异塞到 dyn 背后。Route::new 定义在 route.rs:34-41:
rust
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(我们在 22.4 会看到)、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:148-205 的 Handler trait 定义:
rust
#[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 是什么?文档注释在 handler/mod.rs:137-144 自己解释:
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.
这就是 axum 的魔法入口:用一个类型参数 T 把"函数签名"塞进 trait system,绕开 coherence 对 "同一个 F: Fn(A) -> X 不能同时实现 Fn(A, B) -> Y" 的限制。
22.1.4 blanket impl + all_the_tuples
handler 的核心魔术在 handler/mod.rs:221-262:
rust
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 里的宏,对 0 到 16 个前置参数各展开一次。这就是为什么你能写 0 到 16 个 extractor 参数的 handler——超过 16 个编译器报错,你的函数签名太夸张了。
这套"泛型 + tuple 宏展开"和 Rust 标准库里 Fn/FnMut/FnOnce 对 0..=12 个参数的实现是同一个套路。第 2 章谈 tower 的 Service::call 为什么不用 async fn、偏要 type Future 时,背后是同样的考虑:当 trait 方法返回类型要根据实现自动推导时,只能靠关联类型。
22.1.5 从 Handler 到 Service 的桥:HandlerService
Handler 和 Service 的桥接点在 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 先 Handler::with_state(()) 变成 HandlerService
└─ HandlerService: Service<Request>
└─ Route::new(HandlerService) 再 MapIntoResponse 再 BoxCloneSyncService
└─ 存进 PathRouter 的路由表handler 从"普通异步函数"到"存在路由表里的 dyn Service"中间经过了四次转换——但用户完全感觉不到。axum 的哲学是"免费抽象让新手友好,零成本让老手满意"。实际发生的事情是两三个 Clone + 一次 trait object 构造——每个请求一次,和 tokio task 的调度开销比可以忽略。
值得额外强调的是Layered<L, H, T, S>(handler/mod.rs:285-350)——它是Handler 本身可以装 Layer的支撑结构,和"Router 整体装 Layer"是两条不同的路径。看源码 handler/mod.rs:317-350 的实现:
rust
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 之后的实际流程:
看得到 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:53-89:
rust
#[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 blanket 通过 M = ViaParts / M = ViaRequest 实现 FromRequest(extract/mod.rs:91-105):
rust
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>、Headers 这些 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:
rust
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》第 5 章 "自定义 Deserializer 的 12 个调用")。这个模式极其通用——axum::extract::Query 是用 serde_urlencoded 做的,Json 是用 serde_json,它们都走"把具体数据格式包装成 Deserializer 实现,再让 serde 驱动反序列化"的路子。
22.2.3 Json extractor:body 消费
看 axum/src/json.rs:99-114:
rust
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 后再
serde_json::from_slice::<T>(&bytes)。
这一层套一层的复用很关键——用户自己写自定义 extractor 时,几乎永远是 "先 delegate 给别人抓到 bytes,再自己做一步反序列化"。我们在 《Serde 元编程》第 14 章(MessagePack / CBOR 的 extract)里写过类似的 Msgpack<T> extractor——代码长度也就 20 行。
22.2.4 State extractor:FromRef 的组合学
用户写 State(pool): State<PgPool>,背后要求 S 实现 FromRef<PgPool> 或 S 本身就是 PgPool。FromRef 定义在 axum-core/src/extract/from_ref.rs:
rust
pub trait FromRef<S> {
fn from_ref(input: &S) -> Self;
}这就允许用户写一个 AppState struct 里塞 3 个子 state(PgPool、RedisClient、Settings),每个子 state 通过 #[derive(FromRef)] 派生——handler 里就可以按需只 State(pool): State<PgPool>。这是组合子关系的典型:单个 state 可以组成大 state、大 state 可以投影到小 state,全部在类型系统上表达、零运行时开销。Rust 编译器的 monomorphization(《Rust 编译器》第 7 章 "trait resolution 与 coherence")确保这些 trait 查询全部在编译期消失。
22.2.5 自定义 extractor 的完整例子
假设你想要"从 Authorization header 提取 Bearer token 并校验签名"的 extractor,你这样写:
rust
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:
rust
pub trait IntoResponse {
/// Create a response.
#[must_use]
fn into_response(self) -> Response;
}一个方法、一个返回值、一行文档——axum 的全部返回值多态都压在这个 trait 上。和 FromRequest 对称:那边是 "Request → T",这边是 "T → Response"。
22.3.2 十几种 blanket impl
into_response.rs:121-240 给出的 IntoResponse 实现清单本身就是 axum 用户体验的"配方":
| 类型 | 含义 |
|---|---|
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> 的实现(into_response.rs:141-152):
rust
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,于是你可以写:
rust
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] 做用户友好提示(见 handler/mod.rs:145-147、extract/mod.rs:50-52、into_response.rs:112-114)——编译器在 trait 未满足时除了正常错误消息还会打印这些注释。Rust 1.78 开始稳定的这个属性被 axum 大量用来引导用户看文档(《Rust 编译器》第 12 章 "Error Messages Reloaded" 细节)。
一个著名的坑:
rust
async fn bad() -> impl IntoResponse {
if cond() {
return "text"; // 这里是 &'static str
}
Json(data) // 这里是 Json<T>
}这段编译不过——impl IntoResponse 要求所有分支返回同一具体类型。想混合类型必须返回 Response 或 (StatusCode, Json<T>) 之类的 tuple,或者用 Result/Either。这是 Rust "zero-cost impl Trait" 的代价——不像 Go 的 interface{} 可以接任意类型。解决方案就是手动 .into_response() 统一类型:
rust
async fn good() -> Response {
if cond() {
return "text".into_response();
}
Json(data).into_response()
}22.3.4 错误类型的惯用设计
在生产项目里,AppError 一般是这样的结构:
rust
#[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(错误承载)。在本系列《Tokio Runtime 深度解剖》第 15 章 "错误处理惯用法" 里我们专门讲过。在 axum 里这两者配合 IntoResponse 形成一条清爽的业务错误 → HTTP 响应的单向数据流,基本不会漏错误、不会暴露敏感信息、不会多写重复代码。
22.4 Tonic 骨架:gRPC over HTTP/2
22.4.1 transport::Server 的起点
打开 tonic/src/transport/server/mod.rs:23-27:
rust
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 的连接级代码在 mod.rs:800-825:
rust
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_window_size、keep_alive_interval、keep_alive_timeout、max_pending_accept_reset_streams(rapid reset CVE 的修复旋钮)——全都是 Tonic 在透传 hyper 的配置。Tonic 只是 "给 gRPC 用的预设面板"——你自己拿 hyper 直接起 HTTP/2 server,一行一行配也能达到同样效果,Tonic 给你的是好的默认值(比如 DEFAULT_HTTP2_KEEPALIVE_TIMEOUT = Duration::from_secs(20),见 mod.rs:79)。
22.4.2 TowerToHyperService:关键桥
mod.rs:854-862 是最核心的一段:
rust
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, max_connection_age_grace);三行三个桥:
svc.call(&io)——svc是一个MakeService,每个连接 call 一次,产出本连接专用的 request-level service。这对应 hyper 的 MakeService 模式。TokioIo::new(io)——把tokio::io::AsyncRead + AsyncWrite适配成hyper::rt::Read + Write。这是 hyper 1.0 之后 IO trait 独立的必要桥(第 13 章讲过)。TowerToHyperService::new(req_svc)——tower::Service<Req>和hyper::service::Service<Req>在签名上基本一致但是不同的 trait(hyper 1.0 故意把自己的 Service trait 独立了,避免强耦合到 tower)。TowerToHyperService就是一行impl hyper::Service for TowerToHyperService<S> where S: tower::Service的 newtype 桥。
然后是 map_request——把 Request<Incoming>(hyper 的 body type)映射成 Request<Body>(tonic 自己的 body type)。Tonic 自己的 Body 是对 http_body_util::combinators::BoxBody<Bytes, Status> 的薄封装——多一层统一,让 tonic 内部的各个 codec / streaming 都不用 care 上游 body 具体类型。
22.4.3 UnaryService / ServerStreamingService / ClientStreamingService / StreamingService
打开 tonic/src/server/service.rs——里面定义了四个 trait,分别对应 gRPC 的四种调用模式。以 UnaryService 为例(service.rs:10-19):
rust
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。紧接着的 service.rs:21-31:
rust
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(service.rs:51-122)。
这是 tonic 最漂亮的设计:用户通过 #[tonic::async_trait] 写的业务代码(handler)最终被 tonic-build 生成的代码包装成一个 tower::Service,然后在tonic 内部通过这四个 blanket impl 自动升格为相应的 XxxService,再被 Grpc::unary/server_streaming/... 消费。外层对外(tower 生态)是标准 Service、内层对内(gRPC 语义)是四种专用 trait——两套语言的翻译机。
22.4.4 Routes:Tonic 内部是 axum::Router
打开 tonic/src/service/router.rs:14-16:
rust
#[derive(Debug, Clone)]
pub struct Routes {
router: axum::Router,
}你没看错——Tonic 的 Routes 里直接 hold 一个 axum::Router。add_service 的实现在 router.rs:78-94:
rust
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(router.rs:143-160)直接 delegate 到 self.router.call(req)。
这是工程品味的巅峰表现——tonic 没有重复实现"把路径映射到 handler"这件事。axum 已经做得很好了,tonic 直接用,仅在 IntoResponse / Body 的边界上做一点点 map 适配。这条设计决策在 tonic 0.10 前后(把自研 router 换成 axum Router)大幅减少了 tonic 的代码量 + bug 数,是一条值得记住的"放弃自研"的胜利。
这里也能看到 "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 的半官方入口
router.rs:106-113:
rust
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 常量出现(codec/mod.rs:92-98):
rust
#[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 的完整流程:
rust
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 {
// 先编码到 uncompression_buf
// 再 compress 到 buf
} else {
// 直接编码到 buf
encoder
.encode(item, &mut EncodeBuf::new(buf))
.map_err(|err| Status::internal(format!("Error encoding: {err}")))?;
}
// 回填头
finish_encoding(compression_encoding, max_message_size, &mut buf[offset..])
}关键技巧:先 advance_mut(HEADER_SIZE) 预留 5 字节,先把 proto 编码进去后才能知道真实长度,最后 finish_encoding 回填 flag + length。这个 "先占位后回填" 的写法在所有 length-prefixed 协议的高性能编码器里都能看到(zstd、lz4 frame、postgresql wire protocol)——避免两遍序列化。
22.5.3 EncodeBody 与 HTTP/2 trailers
tonic 最巧妙的一点是把 gRPC Status 映射到 HTTP/2 Trailers。看 codec/encode.rs:281-299:
rust
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())
}
}
}
}以及 encode.rs:302-335 的 impl Body for EncodeBody<T, U>:
rust
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 / grpc-message: "",作为 trailers frame 发出去。 - 业务中途
Err(status)——同样生成 trailers frame,填grpc-status: <code>。
grpc-status 在 HTTP/2 里走 TRAILERS frame(第 15 章讲过 HPACK 动态表如何编码 header、第 16 章讲过 stream 半关状态如何处理)——这是 gRPC 刻意选择的:把最终 status code 放在 trailers 里,而不是 response header,这样服务端可以先 stream 完数据再判断最终成功 / 失败。这对 server-streaming 尤其关键——一个流了一半突然失败的 RPC,客户端通过 trailers 清楚知道"前 1000 条是有效数据,之后失败了"。
这是 HTTP/1 做不到的——HTTP/1 不支持 trailers(严格说 Transfer-Encoding: chunked 支持,但极少实现)。gRPC 选择 HTTP/2 的核心理由之一就是 trailers,另一个是多路复用。
22.5.4 四种调用类型的映射
在 tonic/src/server/grpc.rs:218-362 定义了 unary / server_streaming / client_streaming / streaming 四个方法——分别处理四种 gRPC 调用:
| gRPC 调用类型 | HTTP/2 映射 | request body | response body |
|---|---|---|---|
| unary | 1 个 stream | 1 条 DATA frame + trailers | 1 条 DATA frame + trailers |
| server-streaming | 1 个 stream | 1 条 DATA frame + trailers | N 条 DATA frame + trailers |
| client-streaming | 1 个 stream | N 条 DATA frame + trailers | 1 条 DATA frame + trailers |
| bidi-streaming | 1 个 stream | N 条 DATA frame + trailers | N 条 DATA frame + trailers |
看 grpc.rs:218-259 的 unary:
rust
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 管线。
这种"把特例统一成一般"的抽象选择和本系列 《Tokio Runtime 深度解剖》 第 12 章讲 AsyncRead 为什么没有 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 生产事故:grpc-status 缺失
一条经典 tonic 生产事故:LoadBalancer 在 response body 还没送完前就 RST 了 stream,客户端看到的是没有 trailers 的 response。看 tonic/src/status.rs:800-836:
rust
// We got a 200 but no grpc-status trailer.
//
// Per the gRPC-over-HTTP/2 protocol, grpc-status MUST be present in Trailers
// on the server's last message. A stream ending with a 200 but no
// grpc-status trailer is therefore a protocol violation.
/* ... */
// The only signal available at this point is the absence of a grpc-status
/* ... */
return Status::new(
Code::Internal,
"protocol error: missing grpc-status trailer, stream was terminated without a final status (possible truncation by a proxy or load balancer)",
);missing grpc-status trailer, stream was terminated without a final status 这个错误消息是 tonic 里出现频率最高的生产 bug 线索之一。根因 90% 是中间代理层(nginx / envoy / aws ALB)在 timeout 或连接回收时 RST 了 stream。解决方案:
- ALB:不支持 HTTP/2 trailers,直接换 NLB 或 Envoy。
- Nginx:1.13.9+ 支持
grpc_pass,但要显式关掉 response buffering。 - Envoy:默认就支持。
teach 点:看到这个错误不要先怀疑业务代码——99% 是传输层。这一点和第 17 章讲 GOAWAY / RST_STREAM 的语义是对应的——gRPC 的 status code 协议就是建立在 HTTP/2 stream 完整关闭语义之上,任何破坏 stream 语义的中间件都会把 gRPC 打穿。
另外一个相关的坑——服务端主动 Status::cancelled 给客户端,但客户端看到的是 Status::internal "protocol error"。原因:服务端在 middleware 里 panic 时,tokio task crash、stream 被粗暴 RST——客户端没看到 trailers。正确做法是在顶层套一个 catch_panic Layer(tower_http::catch_panic::CatchPanicLayer),把 panic 转成一个合法的 Status::internal 响应再返回,这样 stream 才有机会正常发完 trailers。缺这一层在生产里极容易出现 "客户端报 protocol error,服务端日志里一片 panic 但 root cause 找不到"——因为 panic 把 stream 杀了、tracing span 没刷盘。
我自己亲身遇到过两次:一次是在滴滴,前面挂了一层老版本的 HAProxy 做 TLS 终止——HAProxy 当时的 HTTP/2 支持把 trailers 给吞了,客户端清一色 Internal 错误,业务代码甚至没跑到;我们花了整整一天去排查 proto 定义,最后抓包才发现返回的 HTTP/2 帧序列里根本没 TRAILERS。另一次是在 AWS EKS 里给 gRPC 服务挂了 ALB——工单发了三轮才确认 ALB 2024 年初仍然不支持 HTTP/2 trailers(要用 Application Load Balancer 配合 gRPC 请用 NLB + envoy sidecar,或者干脆上 Istio)。这些故事都指向同一个教训:gRPC 是深度 HTTP/2 的协议,部署前先问清楚中间件是不是认真实现了 HTTP/2。
22.5.6 Decode 路径:从 DATA frame 到 proto message
编码讲完,反过来看 decode。codec/decode.rs:Streaming<T> 是 tonic 暴露给业务的流式解码器——impl Stream<Item = Result<T, Status>>。它内部做的事情是反过来的 5 字节头处理:
- 从 http_body 里
poll_frame()拿 DATA frame 的 Bytes。 - 累积到 BufReader 里,看是否至少有 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 的实现有一个优化:只在握手期做一次 allocation、之后 buffer 按 buffer_size 增长(默认 8 KiB),避免频繁 realloc。你在 codec/mod.rs:67-89 的 BufferSettings 里能看到对应的旋钮——大部分应用不需要动,极端大消息 + 高吞吐时可以把 buffer_size 调到 64 KiB 或 yield_threshold 调高以批量 flush。
22.5.7 从 proto 到 Prost:Codec 的实际实现
Codec trait 只是接口。tonic 实际用的是 tonic::codec::ProstCodec(在 tonic/src/codec/ 以及 tonic-prost 里)——一个把 Codec::encode 委托给 prost::Message::encode_to_vec 的薄壳。tonic-build 生成的服务代码里你会看到:
rust
let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec);
let res = grpc.unary(method, req).await;换句话说,tonic 并不强绑定 prost。只要实现 Codec / Encoder / Decoder 三个 trait,你可以用 bincode、msgpack、甚至自定义二进制格式替换——把它就称作 gRPC 的 Codec 抽象层。这在内部服务间自定义协议时很有用:享受 HTTP/2 的多路复用 + 头部压缩 + trailers,但把 protobuf 换成你偏好的编码。
关联《Serde 元编程》第 7 章 "把 serde 接入二进制协议"——那一章讲了怎么把一个非 serde 驱动的编码(比如 prost)包一层 serde 外衣。反之,Codec 也可以把 serde_json::to_writer 包成一个 Codec,让你用 JSON 跑 gRPC——语义上不是"纯 gRPC"但体验上一模一样。字节跳动内部就有若干组件这样做,用 JSON-over-gRPC 替代 REST + HTTP/2 以获得流式 + 双向通信。
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/src/timeout/mod.rs):
rust
use std::time::Duration;
use tower::ServiceBuilder;
// Axum 侧
let app = axum::Router::new()
.route("/", get(handler))
.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、同一份语义——5 秒内不回就超时——对 REST 和 gRPC 同时有效。细节:tonic 的 Server::layer 把 Layer 叠到整个 Routes 的入口,每个 gRPC service 也各自可以再叠 tower Layer。
tower::retry::RetryLayer——第 5 章讲过。唯一要注意的是 retry 只对 request 可以被 Clone 的场景适用——unary 调用没问题,streaming 调用不行(Stream 通常不是 Clone 的)。实际生产中 retry 通常在 client 侧而不是 server 侧——这是第 5 章讲过的。
tower_http::trace::TraceLayer:
rust
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() 是 tonic 专用的变体——语义相同,只是 Span 里自动补 grpc- prefix、把 grpc-status 加到日志字段里。
tower::load_shed::LoadShedLayer——第 6 章讲过。可以同样装在 Axum 和 Tonic 上做过载保护。
tower::limit::ConcurrencyLimitLayer——第 4 章讲过。同样通用。
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 因为足够通用——输入/输出/错误全泛型——承担了这个"通用中间件语言"的角色。
这一点和本系列 《Tokio Runtime 深度解剖》 第 10 章讲 Layer trait 作为"横切关注点代数"的那节对上。tower 的 Layer 不仅是"Service 的 wrapper",而是"描述 middleware 如何组合的代数"——Layer 之间可以 Stack、可以 Identity,完美满足 monoid 结构。Axum 和 Tonic 同为这个代数的消费者,自然继承了这个代数的所有性质。
22.6.4 生产案例:一个 Layer 管所有请求
我在某次迭代里给一个混部服务(axum REST + tonic gRPC)统一加了一组 tower_http 的 Layer,代码大概是这样:
rust
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 两边都启用(对 gRPC 来说 CompressionLayer 压的是整个 response body 还不是单消息级别的 gRPC compression,两者可以叠加)。这种统一层的方便度在 Go 或 Python 里要付出明显更多适配工作。
有个坑需要提醒:CompressionLayer 对 gRPC 要小心——gRPC 自己定义了 grpc-encoding: gzip 头,tonic 内部已经在单消息级别压缩。外层再来一个 tower_http 的 CompressionLayer 等于压两次,带来纯 overhead。现实里我们在 gRPC 子路由上关闭外层 compression:
rust
let grpc_router = /* ... */
.route_layer(tower_http::compression::CompressionLayer::new().no_gzip().no_br().no_deflate());这种细粒度控制也是 Layer 生态的优势——同一个 Layer 族可以在不同 route 上启用不同的子集。
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 service 扇区里常见要求 "一个服务一个端口"——服务网格 mTLS、端口配额、安全组配置都按端口维度管理。把四套东西塞到一个端口上是常见需求。
22.7.2 实现方式:axum Router 收口
tonic 0.10 之后的官方推荐方式——tonic 转成 axum Router,再在 axum 里拼:
rust
use axum::routing::get;
use tonic::transport::Server as TonicServer;
use hyper_util::service::TowerToHyperService;
#[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 = TonicServer::builder()
.add_service(GreeterServer::new(greeter))
.add_service(reflection)
.into_service() // 返回 Routes
.prepare() // with_state(()) 做 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 会在 merge 时 panic——这在第 22.1 小节 Router::merge 的相关代码里可以看到(略)。
22.7.3 协议协商:HTTP/1 还是 HTTP/2?
axum::serve 默认用 hyper-util 的 auto::Builder(ALPN / upgrade 自动切换)。于是:
- 浏览器 HTTP/1.1 过来——走 REST。
- gRPC-Web 过来——走 HTTP/1.1 + gRPC-Web。
- gRPC 原生过来——走 HTTP/2(ALPN 协商成功)。
单个 TCP 端口同时支持三种协议——hyper-util 的自动协议协商是关键。第 13 章讲过 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。
实测要注意的细节:在 TLS 终止之后做协议判断必须依赖 ALPN——不能靠 preface 预读(TLS 后才能看到明文)。tonic 0.12 之前在 accept_http1(true) + TLS 的组合下曾有过 ALPN 协商 bug,后来修了;现在主流做法是让 ingress(Envoy / Istio)终结 TLS 并把 ALPN 结果透传给后端,后端纯明文跑 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:
rust
use tonic_web::GrpcWebLayer;
let grpc_router = TonicServer::builder()
.accept_http1(true) // 允许 HTTP/1.1 的 gRPC-Web
.layer(GrpcWebLayer::new()) // 转换层
.add_service(GreeterServer::new(greeter))
.into_service()
.into_axum_router();GrpcWebLayer 本质上是一个 tower::Layer——把 gRPC-Web 请求转换成内部等价的 gRPC 请求,响应方向再转回来。这又是**"Layer 作为协议转换桥"**的经典用法。
具体点——gRPC-Web 的 wire format 和原生 gRPC 有两个差异:第一,trailers 不能走 HTTP/2 TRAILERS frame(HTTP/1 没有),所以 gRPC-Web 把 trailers 编码成一个额外的 DATA frame,用一个 reserved compression flag 标记"这是 trailer";第二,content-type 变成 application/grpc-web+proto 或 application/grpc-web-text(后者 base64 包了一层,让纯文本 HTTP 代理也能透传)。GrpcWebLayer 在 request 方向把 application/grpc-web 改成 application/grpc、在 response 方向把 TRAILERS frame 编码成 gRPC-Web 的"伪 trailer DATA frame"——用户业务代码一行都不用改。
22.7.6 metrics 和 healthcheck
/metrics 这类特殊端点通常要绕过所有业务 middleware——Prometheus 抓取不应该被 rate limit 拦、不应该被 auth middleware 拦。axum 的 Router::route + Router::merge 的组合让这很好实现:
rust
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.5 生产案例:字节 / Shopify / Linkerd 的混部实践
字节内部的服务网格里,Rust 微服务几乎全部是这个模式——单端口 HTTP/2、axum 扛 REST、tonic 扛 gRPC、共用一组 tower Layer 做 tracing / limit / auth。Shopify 的 Rust 组件(pitchfork、oxi)也是同款架构。Linkerd 的 proxy-init 用的也是 tonic + axum。
为什么 Rust 生态可以做到而其他语言很吃力?——见下节对照。
22.8 和 Go net/http + grpc-go、Python FastAPI + grpc 的对照
22.8.1 Go:cmux 分流
Go 的标准答案是 cmux——一个连接多路复用库。做法:
go
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 §22.1.1 五处行号修正:以 axum 0.8.8 为准
§22.1.1 引用了 axum/src/routing/mod.rs 的多处行号,但本地 axum 0.8.8 实测和正文写的位置有偏差——读者照着行号去翻会找不到。逐条对齐如下:
| 引用 | 章节正文 | 实测 (axum 0.8.8) | 偏差 |
|---|---|---|---|
Router<S> struct 定义 | line 86-88 | line 68-70 | -18 |
RouterInner<S> struct 定义 | line 98-102 | line 80-85 | -18 |
impl Service for Router<()> | line 599-618 | line 569-589 | -30 |
更值得注意的是 RouterInner 的字段——章节写它有 3 个字段:
rust
struct RouterInner<S> {
path_router: PathRouter<S>,
default_fallback: bool,
catch_all_fallback: Fallback<S>,
}实测(axum/src/routing/mod.rs:80-85)有 4 个字段——多了一个 fallback_router,且 path_router 类型有 const generic:
rust
struct RouterInner<S> {
path_router: PathRouter<S, false>, // const generic 第二参 = false → 主路由
fallback_router: PathRouter<S, true>, // const generic 第二参 = true → 兜底路由
default_fallback: bool,
catch_all_fallback: Fallback<S>,
}PathRouter<S, IS_FALLBACK> 的第二个 const generic 参数是 axum 0.7→0.8 引入的设计——把"主路由"和"fallback 路由"用同一个类型 + 编译期标记区分,节省一份 PathRouter 实现代码、又能在编译器层面避免误把 fallback 当主路由用。这条事实远比"3 个字段还是 4 个字段"重要——它展示了 axum 在 0.8 版本里对路由表内部拓扑做的精细化。下一版正文需要把这层 const generic 解释加进去。
22.8.7 axum 全家桶 33693 行的工程账本
把 §22.0 提到的"axum 框架"按真实分布拆开——axum 不是单 crate,它在 crates.io 上分四个 crate 联合发布:
| crate | 行数 | 角色 |
|---|---|---|
axum-0.8.8 | 19289 | Router + Handler + 内置 extractor + serve |
axum-core-0.5.6 | 3129 | IntoResponse / FromRequest 核心 trait + body 抽象 |
axum-macros-0.5.0 | 3679 | #[debug_handler] + #[derive(FromRequest)] 的 proc-macro |
axum-extra-0.10.3 | 7596 | 额外 extractor(Cookie、Query 多种风格、Multipart、Typed Header) |
| 合计 | 33693 | axum "framework" 的完整含义 |
主 crate(axum-0.8.8)内部按子目录拆:
| 子目录 | 行数 | 占比 | 含义 |
|---|---|---|---|
src/routing/ | 6469 | 34% | mod.rs 807 + path_router.rs + method_routing.rs 等 |
src/extract/ | 5740 | 30% | Path / Query / Json / Form / State 等内置 extractor |
src/middleware/ | 1715 | 9% | from_fn / from_extractor 中间件包装 |
src/response/ | 1229 | 6% | IntoResponse 实现集(200+ 个 blanket impl) |
src/serve/ | 895 | 5% | Server::serve 主入口(封装 hyper-util) |
src/handler/ | 695 | 4% | Handler + IntoServiceTrait 体操 |
| 其他 | 2546 | 13% | body / boxed / extension / form / json / 测试基础设施 |
两条直接观察:
- routing + extract 占 axum 主 crate 63%(12209 / 19289)——axum 的"框架感"几乎全部来自这两块;本章 §22.1-22.3 也确实把篇幅集中在这里,比例和源码分布一致。
- macros 单独占 3679 行——
#[debug_handler]和#[derive(FromRequest)]这两条体操在编译期帮用户处理"7 元组以下任意 extractor 顺序"这类类型推导问题;这部分代码看起来比"功能价值"大得多,是 §22.8.4 "编译慢"的直接来源。
把这个分布和卷三 ch02 §2.X.1 给的 tower-service 390 行 / 85% 文档放在一起看——"协议薄、框架重" 在 axum 这里是又一次实证:tower-service 390 行 → tower 11904 行 → hyper 1.9.0 协议层 ~17500 行 → axum 全家桶 33693 行。每往应用层抬一级,代码量翻一倍以上——这是 Rust HTTP 生态"抽象层 vs 框架层"质量比的真实数字。
(由于 tonic 当前 cargo 注册表里没有缓存到本地,无法对正文中"tonic 0.14"的引用做同口径校对——下一轮迭代如果 cargo fetch tonic 后会补上。)
22.9 落到你键盘上
22.9.1 本章核心
一张图说明 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(对Status::unavailable重试 2 次),装在 tonic client 的 channel 上。体会 Layer 是 "横切" 的含义——服务代码完全不需要改。单端口跑 axum + tonic。按 22.7 的代码起一个进程,同时
curl http://localhost:8080/healthz和grpcurl localhost:8080 helloworld.Greeter/SayHello。再grpcurl -use-reflection看 reflection service 是否正常。看一次 trailers。用
nghttp -v grpc://localhost:8080/helloworld.Greeter/SayHello(把 request proto 编进 body)抓包,确认返回的 HEADERS / DATA / 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——这是 90% gRPC 生产故障的根因。
- Layer 尽量写成协议无关
Service<Request<B>>形式——能同时给 axum 和 tonic 复用,拒绝 "REST 一个版本、gRPC 一个版本"的重复劳动。 - tonic + axum 混部时用
Routes::into_axum_router()走官方支持路径,不要自己手动match path_prefix再 demux——axum 的 PathRouter 已经做得比你写的更好。 - 给 tonic 顶层配置
CatchPanicLayer——避免 panic 吃掉 trailers 导致客户端一头雾水。
22.9.4 延伸阅读
如果本章引起你的兴趣,推荐再啃几段:
axum-macros里的#[derive(FromRequest)]和#[debug_handler]——看过程宏如何辅助生成复杂的 trait 实现、以及如何用 span 定位 trait 错误。tonic-build生成的 server 代码——找一个.rs里的_build.rs目标,观察它如何把.protoservice 定义编译成Service<Request<Body>>实现。tower::util里的BoxCloneSyncService/MapErr/Oneshot——axum 和 tonic 都在大量使用。看这些通用工具的实现能加深你对 Service trait 体操的理解。- h2 crate 的
SendStream::send_trailers——tonic 编码出的 trailers 最终在这个 API 上落地。读它会让你把"gRPC trailer = HTTP/2 TRAILERS frame"这件事物理意义上看清楚。
22.9.5 下一章预告
本章我们讲清楚了 "axum 与 tonic 如何构建在 hyper + tower 之上"——框架层。下一章(第23章 生产环境调优)切到运维层:
- TCP 层怎么调:SO_REUSEPORT、SYN backlog、TCP_NODELAY 的真实影响。
- HTTP/2 层怎么调:我们第 15-17 章讲过的流窗口、帧大小、keep-alive 参数的生产取值表。
- tokio runtime 怎么调:work-stealing vs current-thread、
max_blocking_threads、tokio::task::block_in_place的陷阱。 - 诊断:
tokio-console/tracing/ flame graph 怎么用。 - 一次真实的 p99 延迟案例——从 120ms 降到 15ms 的全部 9 个调优点,按顺序排。
调完参之后,这本书的循环才闭合——从 "Service trait 的形状" 一路走到 "线上 p99 为什么会突然抖到 200ms"。生产的网络栈没有魔法,每一个毫秒都能从源码找到出处。