Skip to content

第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 网络生态没有重新发明轮子的文化——Service trait 好到让上层框架愿意老老实实套壳。
  • 心智上——你会发现"axum 的 Handler 系统"其实就是一组过程宏 + 泛型 traitService<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/srctonic/src 目录)。所有行号均按实际文件给。

读完本章,你能:

  1. 解释 axum::Router 为什么可以直接当作 tower::Service<Request<Body>> 用。
  2. #[diagnostic::on_unimplemented] 诊断"handler 不满足 trait"类错误的根因。
  3. 给 tonic Server 装一个通用 tower::retry::RetryLayer——知道它装在哪一层、为什么能装。
  4. 在同一个 TcpListener 上同时跑 Axum(REST)和 Tonic(gRPC)——理解 Routes::into_axum_router() 在做什么。
  5. 和 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::Errhyper::server::conn::http1::Builder::serve_connection(io, router) 这种最朴素的调用方式成立。
  • poll_ready 永远 Ready——Router 是无状态的 demux,背压不发生在这里。背压由每条路由内部的 Service负责(例如 ConcurrencyLimit Layer 装进来后才会有 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 的 RoutesRoutesFuture,两者都走"关联类型 + 具名 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 T is a workaround for trait coherence rules, allowing us to write blanket implementations of Handler over 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 系统的心脏。拆开看:

  1. 对任意 F: FnOnce(T1, T2, ..., Tn, Tlast) -> Fut只要所有前置参数 Ti 都是 FromRequestParts,最后一个参数 TlastFromRequest,返回值 ResIntoResponse——就给 F blanket 实现 Handler<(M, T1, T2, ..., Tn, Tlast), S>
  2. call 的实现逻辑:先 Request.into_parts()按顺序用每个 FromRequestPartsparts 提取成 Ti,最后用 FromRequest 把完整 Request 提取成 Tlast,再调用 self(...) 拿到 Res,最后 res.into_response()
  3. 任何一步 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 实现 FromRequestextract/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>BytesStringMultipart)必须放最后。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 本身就是 PgPoolFromRef 定义在 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: IntoResponseE: IntoResponse,递归
Response<B>身份映射
&'static str / String / Cow<'static, str>text/plain
Bytes / BytesMutapplication/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-147extract/mod.rs:50-52into_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::Errorvalidator::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_sizekeep_alive_intervalkeep_alive_timeoutmax_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);

三行三个桥:

  1. svc.call(&io)——svc 是一个 MakeService,每个连接 call 一次,产出本连接专用的 request-level service。这对应 hyper 的 MakeService 模式。
  2. TokioIo::new(io)——把 tokio::io::AsyncRead + AsyncWrite 适配成 hyper::rt::Read + Write。这是 hyper 1.0 之后 IO trait 独立的必要桥(第 13 章讲过)。
  3. 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_readyError 固定成 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::Routeradd_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 Routesrouter.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-179encode_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-335impl 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(),
    }
}

流程:

  1. 业务 stream 出一条消息——把消息编码成 DATA frame,发出去。
  2. 业务 stream 耗尽(Poll::Ready(None))——生成一个 HeaderMap,里面填 grpc-status: 0 / grpc-message: "",作为 trailers frame 发出去。
  3. 业务中途 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 bodyresponse body
unary1 个 stream1 条 DATA frame + trailers1 条 DATA frame + trailers
server-streaming1 个 stream1 条 DATA frame + trailersN 条 DATA frame + trailers
client-streaming1 个 streamN 条 DATA frame + trailers1 条 DATA frame + trailers
bidi-streaming1 个 streamN 条 DATA frame + trailersN 条 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 Layertower_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 字节头处理:

  1. 从 http_body 里 poll_frame() 拿 DATA frame 的 Bytes。
  2. 累积到 BufReader 里,看是否至少有 5 字节。
  3. 读 flag(是否压缩)+ u32 length。
  4. 确认后续累积了 length 字节的完整消息。
  5. 按 flag 调用 Decoder::decode(&mut DecodeBuf) 反序列化出 proto message。
  6. 剩余字节留在 buffer 里,等下一帧。

这是一个经典的**"帧组装状态机"**——和 HTTP/1 chunked body 的 parser、postgresql wire protocol、redis RESP 的 parser 同结构。tonic 的实现有一个优化:只在握手期做一次 allocation、之后 buffer 按 buffer_size 增长(默认 8 KiB),避免频繁 realloc。你在 codec/mod.rs:67-89BufferSettings 里能看到对应的旋钮——大部分应用不需要动,极端大消息 + 高吞吐时可以把 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 供浏览器直接调用。
  • /metrics Prometheus 抓取、/healthz liveness 探针。

理论上可以开三个端口——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-utilauto::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+protoapplication/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。这个方案能工作,但有三个尴尬:

  1. 不共用 middleware——grpc-go 的 grpc.UnaryServerInterceptor 和 net/http 的 http.Handler middleware 不是同一个东西。想要 "一个 trace 同时用" 必须自己在下游写适配层。
  2. 两份连接池 / goroutine 池——两个 Server 独立管理。
  3. 两份 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.HandlerServeHTTP(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 什么时候共栈

共栈也不是银弹。三种场景应该拆:

  1. gRPC 业务和 REST 业务有完全不同的 SLA。比如 gRPC 是内部高频 RPC(p99 < 5ms 要求)、REST 是给外部 OpenAPI(慢一点没事)。混在一个 runtime 里、一组 tower Layer 里,你很难单独给 gRPC 独占的资源池。拆端口、拆进程、拆 k8s Pod——按 SLA 隔离更清爽。

  2. 鉴权机制完全不同。gRPC 走 mTLS、REST 走 OAuth2 Bearer token——虽然技术上一个 middleware 可以路由不同 extractor,但把复杂的鉴权混在一起不利于审计。大多数合规环境(PCI / HIPAA)审计喜欢简单清晰的边界。

  3. 团队拆分边界。如果 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-88line 68-70-18
RouterInner<S> struct 定义line 98-102line 80-85-18
impl Service for Router<()>line 599-618line 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.819289Router + Handler + 内置 extractor + serve
axum-core-0.5.63129IntoResponse / FromRequest 核心 trait + body 抽象
axum-macros-0.5.03679#[debug_handler] + #[derive(FromRequest)] 的 proc-macro
axum-extra-0.10.37596额外 extractor(Cookie、Query 多种风格、Multipart、Typed Header)
合计33693axum "framework" 的完整含义

主 crate(axum-0.8.8)内部按子目录拆:

子目录行数占比含义
src/routing/646934%mod.rs 807 + path_router.rs + method_routing.rs
src/extract/574030%Path / Query / Json / Form / State 等内置 extractor
src/middleware/17159%from_fn / from_extractor 中间件包装
src/response/12296%IntoResponse 实现集(200+ 个 blanket impl)
src/serve/8955%Server::serve 主入口(封装 hyper-util)
src/handler/6954%Handler + IntoServiceTrait 体操
其他254613%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 可立刻做的练习

  1. Router 当 tower::Service 用。在一个没有 axum::serve 的环境里(例如 Cloudflare Workers)把 axum::Router 拿出来,手工用 router.clone().oneshot(request).await 处理每个请求,体会 "axum 只是一个 Service" 的事实。

  2. 手写一个 FromRequestParts。定义一个 struct ClientVersion(pub String),从 X-Client-Version header 提取,没有 header 时 reject 400。参考 axum/src/extract/path/mod.rs 的实现模式——50 行之内搞定。

  3. 给 tonic 装一个 tower::retry。用 tower::retry::RetryLayer + 自己的 Policy(对 Status::unavailable 重试 2 次),装在 tonic client 的 channel 上。体会 Layer 是 "横切" 的含义——服务代码完全不需要改。

  4. 单端口跑 axum + tonic。按 22.7 的代码起一个进程,同时 curl http://localhost:8080/healthzgrpcurl localhost:8080 helloworld.Greeter/SayHello。再 grpcurl -use-reflection 看 reflection service 是否正常。

  5. 看一次 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 目标,观察它如何把 .proto service 定义编译成 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_threadstokio::task::block_in_place 的陷阱。
  • 诊断:tokio-console / tracing / flame graph 怎么用。
  • 一次真实的 p99 延迟案例——从 120ms 降到 15ms 的全部 9 个调优点,按顺序排。

调完参之后,这本书的循环才闭合——从 "Service trait 的形状" 一路走到 "线上 p99 为什么会突然抖到 200ms"。生产的网络栈没有魔法,每一个毫秒都能从源码找到出处。

基于 VitePress 构建