Axum 设计与实现

第20章 axum-extra:Typed 路由、Cookie、Protobuf、Either

作者 杨艺韬 · 13,665 字 · 发布于 · 更新于

axum 核心 crate 刻意保持精简——只包含"绝大多数 web server 都需要"的功能。但生产项目总有额外需求——session cookie、Protobuf API、类型化路由、返回多类型响应等。axum-extra 就是容纳这些功能的官方扩展 crate。

"官方"的意思是 tokio-rs 团队维护、和 axum 本体在同一个仓库里同步发版——不是第三方 crate。"扩展"的意思是每个功能通过 feature flag 控制——你只需要用到的部分、不用装一整套。

本章扫一遍 axum-extra 的主要模块——看它们解决什么问题、内部怎么实现。学完你应该知道:哪些功能要加 axum-extra 依赖、哪些可以继续用 axum 本体。

为什么把功能拆分到 extra

把 axum 核心保持精简、扩展功能放独立 crate 的策略背后有几个工程考虑:

一、编译时间:axum 本体的每个 feature 要编译——功能越多编译越慢。把 protobuf / prost / headers 等厚依赖放 extra,基础 axum 的依赖树和编译时间都轻一截。生产项目只开需要的 feature——不付不需要的成本。

二、生态兼容性:某些扩展有多种实现选择——cookie 有 cookie crate 和 tower-cookies crate、headers 有 headers crate 和 http::HeaderValue 等。axum-extra 选了一种(cookie 和 headers)——如果放核心会强制所有用户用那种——不灵活。放 extra 作为"官方推荐"——但用户依然可以不用 axum-extra 用 tower-cookies。

三、实验空间:extra 里的功能迭代快——可以试验新 API、小版本随时调整。核心 axum 稳定性要求高——不允许这样快迭代。extra 当"创新前沿"、核心保持保守。

四、依赖隔离:axum 核心不依赖 axum-extra。如果把 axum-extra 的功能放核心——就得依赖所有那些 crate(prost / headers / cookie)——用户即使不用也装。现在 axum 本体只装最小集——用户按需加 extra。

这四点让 axum + axum-extra 的组合比"一个大 crate"更灵活。代价是多一个依赖条目——可以接受。

axum-extra 的学习策略

学 axum-extra 和学 axum 核心不同——不是"一次性读完"。建议:

遇到问题时去看 axum-extra 有没有现成方案。比如想做文件下载——搜 axum-extra 发现 Attachment + FileStream;想做 session cookie——搜发现 PrivateCookieJar;想条件返不同类型——搜发现 Either。

axum-extra 的每个模块都有完整 rustdoc——看文档就能学。不用预先全部记住——遇到需求 grep 一下。

优先用 axum-extra、不要重复实现——官方维护、生态成熟。自己写很容易遗漏 edge case(安全 cookie、错误处理)——用 extra 省心。

关注 CHANGELOG——axum-extra 迭代快、新版本可能有之前没注意的新功能。每次升级看 CHANGELOG 发现惊喜。

axum-extra 的 feature 结构

axum-extra 0.12.6 的 Cargo.toml 定义了二十多个 feature flag——每个模块一个(下面摘录其中一部分,按字母序重排、多行数组压成一行,省略号处还有若干):

[features]
default = ["tracing"]

async-read-body = ["dep:tokio-util", "tokio-util?/io", "dep:tokio"]
attachment = ["dep:tracing"]
cached = ["dep:axum"]
cookie = ["dep:cookie"]
cookie-private = ["cookie", "cookie?/private"]
cookie-signed = ["cookie", "cookie?/signed"]
erased-json = ["dep:serde_core", "dep:serde_json", "dep:typed-json"]
file-stream = ["dep:tokio-util", "tokio-util?/io", "dep:tokio", "tokio?/fs", "tokio?/io-util"]
multipart = ["dep:multer", "dep:fastrand"]
protobuf = ["dep:prost"]
routing = ["axum/original-uri", "dep:rustversion"]
tracing = ["axum-core/tracing", "axum/tracing", "dep:tracing"]
typed-header = ["dep:headers"]
typed-routing = ["routing", "dep:axum-macros", "dep:percent-encoding", "dep:serde_core", "dep:serde_html_form", "dep:form_urlencoded"]
with-rejection = ["dep:axum"]
# ...

注意 axum 本身在 axum-extra 里是可选依赖——cached / form / handler / middleware / routing / with-rejection 这些 feature 会把它拉进来,默认开启的 tracing feature 也会(它写的是 "axum/tracing" 而不是 "axum?/tracing");只有 default-features = false 且只用 cookie、typed-header、protobuf 这类 feature 时,axum-extra 才仅依赖 axum-core(0.12.6 下 cargo tree 实测)。

选功能:用哪些功能、Cargo.toml 开哪些 feature:

[dependencies]
axum = "0.8"
axum-extra = { version = "0.12", features = ["typed-routing", "cookie-signed", "protobuf"] }

这种细粒度 feature 的好处:小项目只付出小的编译成本。不启用 protobuf feature 就不依赖 prost crate——依赖树和编译时间都少一块。

flowchart TB
    classDef core fill:#efe,stroke:#080
    classDef extra fill:#fef,stroke:#808
    AXUM["axum (核心库)"]:::core
    EXTRA["axum-extra"]:::extra
    AXUM --> CORE["Router, Handler, FromRequest, IntoResponse..."]
    EXTRA --> F1["typed-routing"]:::extra
    EXTRA --> F2["cookie / cookie-signed / cookie-private"]:::extra
    EXTRA --> F3["protobuf"]:::extra
    EXTRA --> F4["typed-header"]:::extra
    EXTRA --> F5["json-lines"]:::extra
    EXTRA --> F6["multipart / form / query"]:::extra
    EXTRA --> F7["with-rejection / cached"]:::extra
    EXTRA --> F8["erased-json / attachment"]:::extra
    F1 -.依赖.-> AXUM
    F7 -.依赖.-> AXUM

axum-extra 是一座"积木仓库"——每个 feature 独立、按需取用。没有谁需要用完整的 axum-extra——大多数项目只开其中几个 feature。

TypedPath + RouterExt:类型化路由完整机制

第 19 章讲 #[derive(TypedPath)] 的宏展开——这里看运行时用法。

TypedPath trait

定义在 axum-extra/src/routing/typed.rs:217:

pub trait TypedPath: std::fmt::Display {
    /// The path with optional captures such as `/users/{id}`.
    const PATH: &'static str;

    // 两个带默认实现的方法(此处省略函数体):
    fn to_uri(&self) -> Uri;                                   // 走 Display 再 parse
    fn with_query_params<T: Serialize>(self, params: T) -> WithQueryParams<Self, T>
    where Self: Sized;
}

三件事:

  1. PATH 常量:静态字符串,比如 "/users/{user_id}"
  2. Display impl:把 struct 格式化成实际 URL,比如 format!("/users/{}", self.user_id)——to_uri() 的默认实现就是 self.to_string().parse().unwrap()
  3. 隐含的 FromRequestParts:derive 生成的 impl 让 struct 能作为 handler 参数提取

RouterExt::typed_get 等

axum-extra/src/routing/mod.rs:119-275 定义 RouterExt trait,给 Router 加一组 typed 方法:

pub trait RouterExt<S>: sealed::Sealed {
    #[cfg(feature = "typed-routing")]
    fn typed_get<H, T, P>(self, handler: H) -> Self
    where
        H: axum::handler::Handler<T, S>,
        T: SecondElementIs<P> + 'static,
        P: TypedPath;

    // typed_delete / typed_head / typed_options / typed_patch
    // / typed_post / typed_put / typed_trace / typed_connect 签名相同

    fn route_with_tsr(self, path: &str, method_router: MethodRouter<S>) -> Self
    where Self: Sized;   // 主线(#3586)改成只对该 MethodRouter 处理的方法发重定向
    fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self where ...;
}

两点值得注意:这是个 sealed trait(: sealed::Sealed),外部 crate 不能实现;约束是 T: SecondElementIs<P>——T 是 handler 的参数元组,SecondElementIs 要求其中第二个元素(第一个是内部 marker)正是那个 TypedPath,这就是"路径从 handler 第一个参数推断"的类型级实现。

底层实现调 self.route(P::PATH, axum::routing::get(handler))——和普通 route 等价,只是自动从 TypedPath 拿 PATH 常量。

使用方式:

use axum_extra::routing::{RouterExt, TypedPath};

#[derive(TypedPath, Deserialize)]
#[typed_path("/posts/{post_id}")]
struct PostPath { post_id: u64 }

async fn get_post(PostPath { post_id }: PostPath) -> impl IntoResponse {
    format!("post {post_id}")
}

async fn delete_post(PostPath { post_id }: PostPath) -> StatusCode {
    // 删除逻辑
    StatusCode::NO_CONTENT
}

let app = Router::new()
    .typed_get(get_post)       // 读 PostPath::PATH 注册 GET
    .typed_delete(delete_post); // 读 PostPath::PATH 注册 DELETE

handler 和路径强关联——路径定义在 handler 的签名里(通过参数类型),不是 Router 组装代码里。

TypedPath 反向生成 URL

Display impl 让 URL 可从 struct 生成:

let link = PostPath { post_id: 42 }.to_string();  // "/posts/42"
Redirect::to(&link)

对模板引擎渲染链接、handler 返回 Location 头等场景——比 format! 字符串更类型安全。

Resource:REST 风格路由组

axum-extra 还提供 Resource——把一组 CRUD handler 绑在一个 path prefix 下:

use axum_extra::routing::Resource;

let users = Resource::named("users")
    .index(list_users)        // GET /users
    .show(get_user)           // GET /users/{users_id}
    .create(create_user)      // POST /users
    .update(update_user)      // PUT 或 PATCH /users/{users_id}
    .destroy(delete_user);    // DELETE /users/{users_id}

let app = Router::new().merge(users);   // Resource 通过 `From<Resource<S>> for Router<S>` 转换

五个方法名对应 REST 约定的 CRUD——读代码一眼知道每个 handler 的 verb 和 path。注意路径占位符不是 {id} 而是 {<name>_id}——源码里 show_update_destroy_path() 拼的是 format!("/{0}/{{{0}_id}}", self.name),所以 Resource::named("users") 的详情路径是 /users/{users_id},handler 里要用 Path 取的键名也是 users_id。适合 REST API 多的项目——比手工 route 组装少写字符串 URL。

web 项目几乎都需要 cookie——session、CSRF token、偏好设置。axum 本体没提供 cookie helper——要用 axum-extra 的 cookie feature。

CookieJar:基础

axum_extra::extract::CookieJar:

use axum_extra::extract::{CookieJar, cookie::{Cookie, SameSite}};

async fn login(jar: CookieJar, Form(login): Form<Credentials>) -> impl IntoResponse {
    let session_id = authenticate(&login).await.unwrap();
    let cookie = Cookie::build(("session", session_id))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Lax)
        .build();
    let jar = jar.add(cookie);
    (jar, Redirect::to("/dashboard"))  // jar 作为 IntoResponseParts 自动设 Set-Cookie
}

CookieJar 同时是:

  • 提取器:FromRequestParts(Rejection = Infallible,永不失败)——把请求里所有 Cookie 头按 ; 切开、Cookie::parse_encoded 成 Cookie<'static> 装进内部的 cookie::CookieJar
  • 响应 parts:IntoResponseParts——遍历内部 jar 的 delta()(本次 add/remove 的增量),把每条 cookie.encoded() append 进 SET_COOKIE 头

.add(cookie) / .remove(cookie) 方法返回新 jar(不可变风格)——方便在 handler 里 method chaining。

SignedCookieJar:HMAC 签名

cookie 值可能被客户端篡改——CookieJar 默认没有保护。SignedCookieJar 给每个 cookie 加 HMAC 签名——篡改会被检测:

use axum_extra::extract::{SignedCookieJar, cookie::Key};

#[derive(Clone)]
struct AppState {
    cookie_key: Key,  // HMAC 签名密钥
}

impl FromRef<AppState> for Key {
    fn from_ref(s: &AppState) -> Self { s.cookie_key.clone() }
}

async fn login(jar: SignedCookieJar, /* ... */) -> impl IntoResponse {
    let cookie = Cookie::new("user_id", user.id.to_string());
    let jar = jar.add(cookie);
    // 响应的 Set-Cookie 里 value 前面拼上 base64 编码的 HMAC-SHA256(44 个字符)
    // "user_id=<44 字符的 MAC>123"(cookie 0.18 实测,没有分隔符)
    (jar, /* ... */)
}

async fn handler(jar: SignedCookieJar) -> impl IntoResponse {
    // 读 cookie 时自动验证签名——篡改会返回 None
    if let Some(cookie) = jar.get("user_id") {
        format!("user_id = {}", cookie.value())
    } else {
        "no cookie".into()
    }
}

SignedCookieJar 的机制:

  • 写:用 Key 对 cookie value 算 HMAC——签名拼在 value 前面一起发给客户端
  • 读:按固定长度切出签名和 value——验证签名——通过才返回 cookie

客户端看到的 cookie value 明文(但有签名保证)——不能改、但能看。适合存"不敏感但不想被篡改"的数据,比如 user_id、preference。

PrivateCookieJar:AES 加密

彻底不让客户端看 cookie 内容——用加密:

use axum_extra::extract::PrivateCookieJar;

async fn login(jar: PrivateCookieJar, /* ... */) -> impl IntoResponse {
    let cookie = Cookie::new("secret_token", generate_token());
    let jar = jar.add(cookie);
    // 响应的 Set-Cookie 里 value 是加密的——客户端看不到原文
    (jar, /* ... */)
}

客户端看到的是一串 base64 加密字节——既不能篡改也不能看内容。适合存"不想让客户端看到"的信息:session token payload、API key 等(账号余额这类以服务端为准的数据不该放进 cookie)。

加密实现走 cookie crate 的 private feature(AEAD 加密)——同时提供 confidentiality + authenticity。开销比 SignedCookieJar 大一些,但两者都远小于一次数据库往返。

三者对比

特性 CookieJar SignedCookieJar PrivateCookieJar
客户端能看内容 是 是(明文 + 签名) 否(加密)
客户端能篡改 是(没保护) 否(签名验证) 否(加密认证)
需要 Key 否 是(Key) 是(Key)
性能 最快 中 稍慢
典型用途 非敏感 preference user_id / role session、API token

Key 管理:cookie::Key 是 64 字节随机字节——生产用 Key::generate() 生成、持久化到安全存储(环境变量、KMS)。重启不能变——cookie 签名/加密会失效让用户登出。

典型生产设置:

let cookie = Cookie::build(("session", token))
    .http_only(true)                    // JS 读不到
    .secure(true)                        // 仅 HTTPS 传输
    .same_site(SameSite::Lax)           // CSRF 防护
    .max_age(time::Duration::days(30))  // 30 天过期(需在 Cargo.toml 加 time 依赖)
    .path("/")                          // 作用域全站
    .build();

let jar = private_jar.add(cookie);  // 加密保存

五个 flag + 加密 jar——生产 session 的标准配置。第 11 章讨论过这几个 flag 的语义——不重复。

前后端分离的典型 session 管理:

#[derive(Clone)]
struct AppState {
    db: PgPool,
    cookie_key: Key,
    session_store: Arc<SessionStore>,
}

impl FromRef<AppState> for Key {
    fn from_ref(s: &AppState) -> Self { s.cookie_key.clone() }
}

async fn login(
    jar: PrivateCookieJar,
    State(state): State<AppState>,
    Json(creds): Json<LoginRequest>,
) -> Result<(PrivateCookieJar, Json<LoginResponse>), AppError> {
    let user = state.session_store.authenticate(&creds).await?;
    let session_id = state.session_store.create_session(user.id).await?;

    let cookie = Cookie::build(("session", session_id))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Lax)
        .path("/")
        .max_age(time::Duration::days(30))
        .build();

    let jar = jar.add(cookie);
    Ok((jar, Json(LoginResponse { user_id: user.id })))
}

async fn logout(jar: PrivateCookieJar) -> impl IntoResponse {
    // 删 cookie
    let jar = jar.remove(Cookie::from("session"));
    (jar, Redirect::to("/"))
}

async fn current_user(
    jar: PrivateCookieJar,
    State(state): State<AppState>,
) -> Result<Json<User>, AppError> {
    let session_id = jar.get("session")
        .ok_or(AppError::Unauthorized)?
        .value()
        .to_string();
    let user = state.session_store.get_user(&session_id).await?;
    Ok(Json(user))
}

login / logout / current_user 三个 handler——共享一个 PrivateCookieJar。session_id 在 cookie 里加密传、实际 user 数据在 server-side session store。这是主流 session 架构——cookie 只存 ID、session data 在服务端。

优点:

  • cookie 内容小(只一个 ID)
  • 敏感数据不走客户端
  • 服务端能主动 invalidate session(从 store 删)

缺点:

  • 需要 session store(Redis、数据库、内存)
  • session 查询增加一次 IO

vs JWT 纯 cookie方案(把所有信息编码到 JWT、cookie 存 JWT):

  • 优点:无状态、无 session store
  • 缺点:cookie 大、不能主动 invalidate、token 泄露风险

生产推荐 session store + PrivateCookieJar 的组合——控制可见性、能主动管理登录状态、典型 web 应用的 sweet spot。

Either / Either3-8:分歧响应

有时 handler 返回类型根据条件不同:

async fn h(cond: bool) -> impl IntoResponse {
    if cond {
        Json(data).into_response()        // 返 JSON
    } else {
        Html(page).into_response()        // 返 HTML
    }
}

用 .into_response() 统一成 Response——失去类型信息。Either 让分歧保持类型:

use axum_extra::either::Either;

async fn h(cond: bool) -> Either<Json<Data>, Html<String>> {
    if cond {
        Either::E1(Json(data))
    } else {
        Either::E2(Html(page))
    }
}

Either 是个 enum:

// axum-extra/src/either.rs:108-113
pub enum Either<E1, E2> {
    E1(E1),
    E2(E2),
}

impl IntoResponse——E1::into_response 或 E2::into_response 按 variant 分发。这保留了类型信息、签名里明确写出两种可能响应。

Either3-8:更多分支

Either3、Either4、... 到 Either8——支持 3-8 个分支:

pub enum Either3<E1, E2, E3> {
    E1(E1), E2(E2), E3(E3),
}

使用:

async fn complex_handler(req: ComplexRequest) -> Either3<Json<Data>, Html<String>, Redirect> {
    match req.kind {
        Kind::Api => Either3::E1(Json(data)),
        Kind::Web => Either3::E2(Html(page)),
        Kind::Legacy => Either3::E3(Redirect::to("/new-path")),
    }
}

8 种分支应该能满足几乎所有场景——超过说明 handler 职责过多、应该拆分。

Either 作为提取器

Either 不只是响应类型——也能作为提取器(axum-extra/src/either.rs:229 的 impl_traits_for_either! 宏里的 FromRequestParts impl):

async fn h(auth: Either<BasicAuth, ApiKey>) -> impl IntoResponse {
    match auth {
        Either::E1(basic) => /* Basic 认证 */,
        Either::E2(api) => /* API key 认证 */,
    }
}

Either 按顺序尝试——E1 成功返 E1、失败试 E2、都失败返 最后一个 提取器的 rejection(type Rejection = $last::Rejection)。让 handler 支持多种认证方式。

Either 的内部实现

Either 的 IntoResponse 和 FromRequestParts impl 都用宏生成。简化:

// axum-extra/src/either.rs:108-113
pub enum Either<E1, E2> {
    E1(E1),
    E2(E2),
}

impl<E1: IntoResponse, E2: IntoResponse> IntoResponse for Either<E1, E2> {
    fn into_response(self) -> Response {
        match self {
            Self::E1(v) => v.into_response(),
            Self::E2(v) => v.into_response(),
        }
    }
}

// FromRequestParts 尝试 E1、失败试 E2
impl<S, E1, E2> FromRequestParts<S> for Either<E1, E2>
where E1: FromRequestParts<S>, E2: FromRequestParts<S>, S: Send + Sync,
{
    type Rejection = E2::Rejection;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        match E1::from_request_parts(parts, state).await {
            Ok(v) => Ok(Self::E1(v)),
            Err(_) => Ok(Self::E2(E2::from_request_parts(parts, state).await?)),
        }
    }
}

两个 impl 各解决一个方向:

  • IntoResponse 时 match 分发给 inner——透明转发
  • FromRequestParts 时优先尝试 E1、E1 失败回退 E2

impl_traits_for_either! 宏定义在 axum-extra/src/either.rs:229,axum-extra/src/either.rs:269-275 的七次调用生成 Either 到 Either8 的全部 impl——模板一致、只是 variant 数量不同。

注意 E1 失败回退的语义:Either 默认偏向 E1——业务想 E2 优先必须调换。对"认证方式用 Basic 或 Bearer"场景合适——BasicAuth 失败试 BearerAuth。

Either 的限制

Either 只对 FromRequestParts 有 impl——either.rs 里没有任何 FromRequest impl。原因就是 body 只能消费一次:E1 试失败后 body 已经没了,没法回退给 E2。所以 Either<Json<A>, Form<B>> 这种"按 Content-Type 二选一"的写法编译不过——要做内容协商得自己写一个 FromRequest,先读出 Bytes 再分支。

另外 Either(只有两元的那个)还额外实现了 Layer 和 Service(axum-extra/src/either.rs:277 起)——这就是 option_layer 的实现基础:把"加/不加某个 layer"编码成 Either<L, Identity>。

实际用法里 Either 最多用 3-4 个分支——超过说明业务逻辑该拆分成不同 handler / route。axum-extra 提供到 Either8 是"fallback for 极端场景"——不推荐常规用。

TypedHeader:类型化 header

axum_extra::TypedHeader 让 header 提取类型安全——不用 headers.get("authorization").and_then(to_str) 字符串 chain。

use axum_extra::TypedHeader;
use headers::{Authorization, authorization::Bearer, UserAgent, ContentType};

async fn h(
    TypedHeader(ua): TypedHeader<UserAgent>,
    TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> impl IntoResponse {
    let user_agent = ua.as_str();
    let token = auth.token();
    // ...
}

背后是 headers crate(axum-extra 0.12 依赖 headers = "0.4",并在 lib.rs 里 pub use headers; 重导出)——提供五十来个标准 header 的类型化实现(ContentType、UserAgent、Authorization、CacheControl、ETag 等等)。TypedHeader 把 headers::Header trait 桥接到 axum 的 FromRequestParts。

源码核心(简化):

// axum-extra/src/typed_header.rs:57
impl<T, S> FromRequestParts<S> for TypedHeader<T>
where T: Header, S: Send + Sync,
{
    type Rejection = TypedHeaderRejection;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let mut values = parts.headers.get_all(T::name()).iter();
        let is_missing = values.size_hint() == (0, Some(0));
        T::decode(&mut values)
            .map(Self)
            .map_err(|err| TypedHeaderRejection {
                name: T::name(),
                reason: if is_missing {
                    TypedHeaderRejectionReason::Missing
                } else {
                    TypedHeaderRejectionReason::Error(err)
                },
            })
    }
}

T::name() 是 header 的名字(比如 Authorization::name() == "authorization")、T::decode() 从 header values 解析成 T。is_missing 那一步是关键——先用 size_hint() 判断这个 header 一个值都没有,把 rejection 分成 Missing 和 Error(err) 两类。这让提取失败可以精细——"Authorization 头缺失" vs "Bearer 格式错" 各自独立错误。TypedHeaderRejection 的 IntoResponse 统一返 400 BAD_REQUEST + Display 出来的消息。

自定义 header 的用法——第 11 章讨论过——为自己的 header 类型实现 headers::Header trait,就能 TypedHeader<MyHeader> 提取。

TypedHeader 和 headers crate 的映射

headers 0.4 定义了五十来个标准 HTTP header 的类型化表示:

header name Rust 类型
User-Agent UserAgent
Content-Type ContentType
Authorization Authorization<Basic> / Authorization<Bearer>
Cache-Control CacheControl
ETag ETag
If-None-Match IfNoneMatch
Content-Disposition ContentDisposition
Host Host
Origin Origin
Cookie Cookie
Set-Cookie SetCookie

每个类型都实现 headers::Header trait(定义 name()、decode()、encode())——让 TypedHeader<T> 能自动提取和响应。

注意 headers 0.4 里没有 Accept 类型——内容协商(按 Accept 头选 JSON / HTML / Protobuf)只能自己读 HeaderMap 里的 header::ACCEPT,或者自己实现一个 Header。

自定义 header 加入 ecosystem:

use headers::{Header, HeaderName, HeaderValue, Error};

pub struct XRequestId(pub String);

impl Header for XRequestId {
    fn name() -> &'static HeaderName {
        static NAME: HeaderName = HeaderName::from_static("x-request-id");
        &NAME
    }

    fn decode<'i, I>(values: &mut I) -> Result<Self, Error>
    where I: Iterator<Item = &'i HeaderValue>,
    {
        values.next()
            .and_then(|v| v.to_str().ok())
            .map(|s| Self(s.to_string()))
            .ok_or_else(Error::invalid)
    }

    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
        if let Ok(v) = HeaderValue::try_from(&self.0) {
            values.extend(std::iter::once(v));
        }
    }
}

// 用法
async fn h(TypedHeader(id): TypedHeader<XRequestId>) {}

自定义 Header 的工作主要在 decode/encode——parse header value 到结构化类型、反向生成 HeaderValue。对业务特定的 header(X-Tenant-Id、X-Trace-Id 等)有用——让 handler 里 header 使用类型安全。

TypedHeader vs HeaderMap

对比两种读 header 的方式:

// HeaderMap 方式
async fn h1(headers: HeaderMap) -> String {
    headers.get("user-agent")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("unknown")
        .to_string()
}

// TypedHeader 方式
async fn h2(TypedHeader(ua): TypedHeader<UserAgent>) -> String {
    ua.as_str().to_string()
}

TypedHeader 的优势:

  • 类型安全:编译期保证是 UserAgent 类型、不是任意字符串
  • 签名即文档:看 handler 签名就知道用了什么 header
  • 错误精细:缺失 vs 格式错——各自独立 rejection
  • 失败返回具体 HTTP 响应:TypedHeaderRejection 实现 IntoResponse——400 + 错误消息

HeaderMap 的优势:灵活——能读任意 header。

实际选择:知道的、标准 header 用 TypedHeader、通用 / 动态 header 用 HeaderMap。两者混用很常见。

Protobuf:gRPC-lite

axum_extra::protobuf::Protobuf<T> 让 axum 能处理 Protocol Buffers——适合和 gRPC client、mobile app 对接的场景:

use axum_extra::protobuf::Protobuf;
use prost::Message;

#[derive(prost::Message)]
struct CreateUser {
    #[prost(string, tag="1")] name: String,
    #[prost(string, tag="2")] email: String,
}

#[derive(prost::Message)]
struct User {
    #[prost(uint64, tag="1")] id: u64,
    #[prost(string, tag="2")] name: String,
}

async fn create_user(Protobuf(req): Protobuf<CreateUser>) -> Protobuf<User> {
    // 业务
    Protobuf(User { id: 1, name: req.name })
}

Protobuf 类似 Json——从 body 读字节(req.into_limited_body().collect().await,受默认 body limit 约束)、用 prost crate 的 T::decode 反序列化成 message、handler 返回时 encode 成字节。

有两点和 Json 很不一样,容易踩:提取时不检查 Content-Type(rustdoc 原话:"The extractor does not expect a Content-Type header to be present in the request"),响应时也不设 application/x-protobuf——into_response() 只是把 BytesMut 交给 Bytes 的 IntoResponse,落地是 Content-Type: application/octet-stream。想让客户端按 protobuf 解析,得自己加 header:([(CONTENT_TYPE, "application/x-protobuf")], Protobuf(msg))。

和 Json 的关键差异:

维度 Json Protobuf
可读性 文本、人读友好 二进制、压缩紧凑
序列化速度 文本解析 二进制、无需转义
大小 大 小(同一 schema 下通常明显更紧凑)
生态 几乎全宇宙 主要 gRPC / 内部系统
schema 松(JSON Schema 可选) 强(.proto 文件必须)

用 Protobuf 的场景:和 gRPC 客户端配合(但不完整 gRPC——只是 Protobuf over HTTP)、内部服务间 RPC(性能敏感)。

真正的完整 gRPC 用 tonic crate——不是 axum-extra 的 Protobuf。tonic 可以和 axum 集成(同端口同 Router 跑 REST + gRPC)——但那是 tonic 的事、axum-extra 的 Protobuf 只是"json-like 但 proto 字节"。

Protobuf vs tonic gRPC

要对比清楚两者的定位:

维度 axum-extra::Protobuf tonic gRPC
协议 HTTP + Protobuf body gRPC (HTTP/2 + Protobuf + trailers)
能和 REST 混用 是(同 Router 既有 Json 又有 Protobuf) 需要桥接(tonic + axum 互操作)
流式 需要自己写 Body::from_stream 原生支持(Stream<Request>、Stream<Response>)
客户端 任何能发 HTTP POST + proto body 的 gRPC client(grpcurl、tonic client 等)
错误模型 HTTP status code gRPC status code(在 trailers)

选择:

  • 和 REST 混用、简单请求响应 → axum_extra::protobuf::Protobuf——简单
  • 完整 gRPC 功能(streaming、metadata、服务发现) → tonic
  • 多语言 client 兼容 → tonic(grpcurl、Python client 等都能调)

很多项目两者混用——axum 做 REST API、tonic 做内部服务 RPC——都是 Rust、tokio 上——资源共享。

第三方 axum 扩展生态

axum-extra 之外还有一大批第三方 crate:

Crate 功能
tower-http CORS / compression / trace / timeout / limit
axum-server TLS 支持、listenfd 集成
axum-test 测试工具(下一章讲)
axum-login 登录系统(session + user)
axum-typed-websockets 类型化 WebSocket 消息
tower-sessions session 存储抽象
axum-prometheus Prometheus 指标
axum-jsonschema JSON Schema 验证
axum-valid 用 validator crate 做请求验证
tower_governor rate limiting
axum-streams 更多流式响应工具

按需加——每个 crate 都专注一件事。大项目往往会组合好几个这样的扩展——每个解决一个具体问题、不互相冲突。

怎么选第三方 crate:

  1. stars 数:github star 数只是很粗略的关注度指标,不能单独作为质量依据
  2. 最近 commit:6 个月没更新可能废弃
  3. docs 完善度:好文档 = 好设计
  4. 和 axum 版本兼容:看 Cargo.toml 要求
  5. license:MIT / Apache 2.0 最常见、兼容性好

第三方 crate 和 axum-extra 的差异:axum-extra 是官方、整体生态一致;第三方 crate 是社区的、质量参差。但很多第三方的成熟度已经超过 axum-extra——比如 tower-http 是通用 HTTP 中间件标准。

常用的其他 extra 模块

还没深入讨论的几个 module:

AsyncReadBody

把 tokio::AsyncRead 包装成 Body——比 ReaderStream::new + Body::from_stream 更简洁:

use axum_extra::body::AsyncReadBody;

async fn download() -> impl IntoResponse {
    let file = tokio::fs::File::open("data").await.unwrap();
    ([("content-type", "application/octet-stream")], AsyncReadBody::new(file))
}

一行 AsyncReadBody::new(file)——省几行代码。内部就是第 17 章讲的 Body::from_stream + ReaderStream——只是 wrapper。

Attachment

文件下载响应——自动设 Content-Disposition:

use axum_extra::response::Attachment;

async fn download() -> impl IntoResponse {
    Attachment::new(bytes_data)
        .filename("report.pdf")
        .content_type("application/pdf")
}

比手动 ([("content-disposition", ...), ("content-type", ...)], body) 更清晰——意图明确。

ErasedJson

把任意 Serialize 的值提前序列化成 Bytes、擦掉类型——让不同类型的 JSON 响应能用同一个 Response 类型。源码里它就是 pub struct ErasedJson(Result<Bytes, Arc<serde_json::Error>>),构造时立刻序列化,序列化错误也一起存下来、into_response 时再变成 500。

这里有个容易写反的细节:ErasedJson::new 用的是 serde_json::to_writer 往一个 BytesMut 里写(axum-extra/src/response/erased_json.rs:47-53),不是 to_vec——省掉一次 Vec<u8> → Bytes 的搬运。

用起来是这样:

async fn h(cond: bool) -> ErasedJson {
    if cond { ErasedJson::new(&user) } else { ErasedJson::new(&error) }
}

避免 Either 的繁琐——代价是序列化时机提前、且丢掉了具体类型信息。适合返回类型完全动态的场景——但大多数场景 Either<Json<A>, Json<B>> 更合适(保留具体类型)。同模块还有 ErasedJson::pretty 和一个 json! 宏(基于 typed-json)。

ErasedJson 真正有用的场景:

  • polymorphic API 响应:同 endpoint 按条件返不同 schema——类型化困难
  • 兼容旧 API:老代码里已经是 dyn Trait——直接包 ErasedJson
  • plugin 系统:动态加载模块产生的 JSON——编译期不知道类型

但生产里 Either 通常够用——ErasedJson 是 fallback 工具、不是日常使用。

InternalServerError

错误响应 helper——内部做 tracing::error! + 返 500:

use axum_extra::response::InternalServerError;
use std::io::Error;

async fn h() -> Result<Json<Data>, InternalServerError<Error>> {
    let x = might_fail().await.map_err(InternalServerError)?;
    Ok(Json(x))
}

它是个元组结构体 pub struct InternalServerError<T>(pub T)——没有 new,直接把构造函数当 map_err 的参数用。IntoResponse 约束是 T: Error + 'static:先 tracing::error!(error = &self.0 as &dyn Error) 打日志,再返回固定的 500 + "An error occurred while processing your request."——错误细节不外泄给客户端。需要开 error-response feature。

WithRejection:自定义提取器错误响应

对任何提取器想自定义 rejection 响应——用 WithRejection<T, R>:

use axum_extra::extract::WithRejection;

async fn handler(
    WithRejection(Json(body), _): WithRejection<Json<Payload>, MyError>,
) -> impl IntoResponse {
    // 如果 Json 失败——自动转成 MyError 响应
}

// 前提: impl From<JsonRejection> for MyError

机制:WithRejection<T, R> 的提取尝试提取 T;T 失败、用 From 转成 R、返 R 的响应。

这和第 19 章讨论的 #[derive(FromRequest)] 配合 #[from_request(rejection(MyError))] 属性类似——但 WithRejection 是单提取器的包装器、derive 是复合提取器的。两者互补——前者适合偶尔想自定义一个提取器的错误、后者适合组合提取器统一错误类型。

Cached:缓存已提取的值

同一请求里某提取器被多次调用(比如 middleware 和 handler 都要)——每次都重新提取浪费。Cached<T> 把提取结果缓存到 request extensions:

use axum_extra::extract::Cached;

// middleware
async fn auth(Cached(user): Cached<CurrentUser>, req: Request, next: Next) -> Response {
    // 检查 user 权限——提取一次
    next.run(req).await
}

// handler
async fn handler(Cached(user): Cached<CurrentUser>) -> impl IntoResponse {
    // 同请求第二次用 CurrentUser——从 cache 拿、不重新提取
    format!("hello {}", user.name)
}

机制:首次 Cached<T>::from_request_parts 时调 T::from_request_parts 得到值、clone 到 extensions;后续调用从 extensions 拿——跳过真实提取。

适用场景:提取成本高(比如 DB 查询)的提取器——middleware 提取一次、handler 复用。简单提取器(Method、Uri)不需要 Cached——直接提取更快。

JSON Lines:NDJSON 流

NDJSON(Newline-Delimited JSON)常用于大数据流式传输:

{"id": 1, "name": "Alice"}
{"id": 2, "name": "Bob"}
...

axum_extra::json_lines::JsonLines 提供流式读写:

use axum_extra::json_lines::JsonLines;
use axum::response::{IntoResponse, Response};
use futures_util::stream::StreamExt;

async fn export() -> Response {
    let stream = fetch_users_from_db();      // Stream<Item = Result<User, BoxError>>
    JsonLines::new(stream).into_response()
}

// 或作为提取器:类型参数是「每行反序列化成什么」,JsonLines 自己就是 Stream
async fn import(mut stream: JsonLines<User>) {
    while let Some(user) = stream.next().await {
        // 处理每个 user
    }
}

JsonLines<S, T = AsExtractor> 有第二个类型参数当标记:JsonLines::new(stream) 造出的是 JsonLines<S, AsResponse>(只实现 IntoResponse),提取器方向拿到的是 JsonLines<T, AsExtractor>(实现 FromRequest 和 Stream)。默认参数是 AsExtractor,所以 handler 签名里写 JsonLines<User> 就是提取器。

相当于"JSON 版的 SSE"——但更通用(不限于事件、只要每行一个 JSON 对象)。适合数据导入/导出的大流量场景——一个 10GB 的用户列表能流式发送/接收。

Query / Form 的 axum-extra 变种

第 7 章讲过 axum::extract::Query 和 Form——但 axum-extra 也有同名类型(启用 query 或 form feature)。在 0.12.6 发布版里,两者的差异在反序列化器,不在错误处理:

  • axum 0.8.9 版本:底层是 serde_urlencoded,同名键收不进 Vec(第 7 章列过它的报错)
  • axum-extra 版本:底层是 serde_html_form,rustdoc 列出的区别就是支持多值项——多个同名 <input>(比如复选框)、带 multiple 的 <select>,都能收进 Vec 之类的序列容器

两边想自定义 rejection 都一样靠外面套 WithRejection,这不是 axum-extra 版本独有的能力。

主线(尚未发布)上这两个类型和 feature 都已标 #[deprecated = "see documentation"](#3594;0.12.6 里还没有),原因也不是 WithRejection。rustdoc 里写得很清楚:它们过去用的是另一套 deserializer,现在和 axum::extract::Query/Form 用的是同一个 deserializer 的旧版本(axum-extra 钉在 serde_html_form 0.2),保留下来纯粹是为了让人分两步迁移——升级 axum 本体时先继续用 axum-extra 的 Query/Form,之后再换成 axum 本体的、同时吃下新版 serde_html_form 的行为变化。Cargo.toml 里那句 "DO NOT update" 的注释就是这个意思;注释说 axum 用 0.3.x,但主线 axum 实际依赖的已是 0.4.0(axum/Cargo.toml、Cargo.lock),注释没跟上;0.8.9 发布版的 axum 则还在用 serde_urlencoded。

结论:在 0.8.9 上,只有需要同名多值参数时才用 axum-extra 的 Query / Form,否则直接用 axum::extract::Query / axum::Form;等 #3594 发布后一律用 axum 本体的。要自定义 rejection 就外面套 WithRejection。

Cached 的内部机制

Cached 的实现:

// axum-extra/src/extract/cached.rs:78
pub struct Cached<T>(pub T);

#[derive(Clone)]
struct CachedEntry<T>(T);   // 私有 newtype

impl<S, T> FromRequestParts<S> for Cached<T>
where
    S: Send + Sync,
    T: FromRequestParts<S> + Clone + Send + Sync + 'static,
{
    type Rejection = T::Rejection;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        if let Some(value) = parts.extensions.get::<CachedEntry<T>>() {
            Ok(Self(value.0.clone()))
        } else {
            let value = T::from_request_parts(parts, state).await?;
            parts.extensions.insert(CachedEntry(value.clone()));
            Ok(Self(value))
        }
    }
}

核心:检查 extensions 有没有缓存——有就 clone 拿出、没有就真正提取后存入 extensions。同请求后续提取都走 cache。

细节:存进 extensions 的不是 T 本身而是私有的 CachedEntry<T>。这是刻意的——否则 Cached<T> 的缓存会和用户自己 Extension<T> 插进去的值撞在一起、互相污染。rustdoc 里也明确写了缓存"purely type based"(一个类型只能缓存一个值)且"local to the current request"。

T 必须 Clone + Send + Sync + 'static——否则不能放进 extensions、也不能在多个 handler / middleware 间共享。需要开 cached feature(0.12.0 起才拆出来的)。

实用例:

// middleware 检查权限
async fn auth(Cached(user): Cached<CurrentUser>, req: Request, next: Next) -> Response {
    if !user.has_permission(/* ... */) { return StatusCode::FORBIDDEN.into_response(); }
    next.run(req).await
}

// handler 复用
async fn handler(Cached(user): Cached<CurrentUser>) -> impl IntoResponse {
    // 第二次调用——从 cache 拿
    format!("hello {}", user.name)
}

middleware 提取 + handler 复用——一次 CurrentUser 提取(可能涉及 DB 查 session)——两处用。节省一次 DB 查询。

对提取成本低的类型(Method / Uri)没必要用 Cached——直接提取更简单。重度提取(DB / RPC)才值得 cache。

深入:路由的 Resource 抽象

Resource 的实现(axum-extra/src/routing/resource.rs):

pub struct Resource<S = ()> {
    pub(crate) name: String,
    pub(crate) router: Router<S>,
}

impl<S> Resource<S>
where
    S: Clone + Send + Sync + 'static,
{
    pub fn named(resource_name: &str) -> Self { /* ... */ }

    // 七个方法签名一致:H: Handler<T, S>, T: 'static
    pub fn index<H, T>(self, handler: H) -> Self { /* 加 GET /<name> */ }
    pub fn create<H, T>(self, handler: H) -> Self { /* 加 POST /<name> */ }
    pub fn new<H, T>(self, handler: H) -> Self { /* 加 GET /<name>/new */ }
    pub fn show<H, T>(self, handler: H) -> Self { /* 加 GET /<name>/{<name>_id} */ }
    pub fn edit<H, T>(self, handler: H) -> Self { /* 加 GET /<name>/{<name>_id}/edit */ }
    pub fn update<H, T>(self, handler: H) -> Self { /* 加 PUT 或 PATCH /<name>/{<name>_id} */ }
    pub fn destroy<H, T>(self, handler: H) -> Self { /* 加 DELETE /<name>/{<name>_id} */ }

    // 两个私有路径拼接函数
    fn index_create_path(&self) -> String { format!("/{}", self.name) }
    fn show_update_destroy_path(&self) -> String { format!("/{0}/{{{0}_id}}", self.name) }
}

impl<S> From<Resource<S>> for Router<S> {
    fn from(resource: Resource<S>) -> Self { resource.router }
}

REST 七个 convention verbs——方法名对应 URL 和 HTTP method。这来自 Rails 的 resourceful routing 传统——一致性让团队 onboarding 容易。

两个小限制:一是 Resource 没有 into_router() 方法——只有 From<Resource<S>> for Router<S>,所以要么 Router::from(users)、要么直接 .merge(users)(merge 接受 Into<Router<S>>)。二是路径形状写死成 /<name> 和 /<name>/{<name>_id}——对更复杂嵌套(/users/{user_id}/posts/{post_id})不适用;早期版本有过 Resource::nest / nest_collection,在 0.4.0 里被移除了(CHANGELOG 原话:转成 Router 后自己 nest)。复杂嵌套建议手写 route。

实战:生产 API 的 axum-extra 组合

把前面讨论的多个 feature 放到一个真实场景的 handler 里——用户管理 API:

use axum::{Router, Json, extract::State, routing::post, response::{Redirect, Html, IntoResponse}};
use axum_extra::{
    extract::{PrivateCookieJar, WithRejection, cookie::{Cookie, SameSite}},
    either::Either,
    routing::{RouterExt, TypedPath, Resource},
    TypedHeader,
    protobuf::Protobuf,
    response::Attachment,
};
use axum::http::{HeaderMap, header::ACCEPT};

// 路由模板
#[derive(TypedPath, serde::Deserialize)]
#[typed_path("/users/{user_id}")]
struct UserPath { user_id: u64 }

#[derive(TypedPath, serde::Deserialize)]
#[typed_path("/users/{user_id}/export")]
struct UserExportPath { user_id: u64 }

// 用户详情——根据 Accept 返 JSON、HTML 或 Protobuf
// headers 0.4 没有 Accept 类型,内容协商直接读 HeaderMap
async fn get_user(
    UserPath { user_id }: UserPath,
    jar: PrivateCookieJar,
    headers: HeaderMap,
    State(state): State<AppState>,
) -> Result<Either<Json<User>, Either<Html<String>, Protobuf<UserProto>>>, AppError> {
    let user = state.db.get_user(user_id).await?;

    let accept_str = headers.get(ACCEPT).and_then(|v| v.to_str().ok()).unwrap_or("");
    if accept_str.contains("application/x-protobuf") {
        Ok(Either::E2(Either::E2(Protobuf(user.into_proto()))))
    } else if accept_str.contains("text/html") {
        Ok(Either::E2(Either::E1(Html(render_user(&user)))))
    } else {
        Ok(Either::E1(Json(user)))
    }
}

// 导出用户数据——下载 CSV 文件
async fn export_user(
    UserExportPath { user_id }: UserExportPath,
    State(state): State<AppState>,
) -> Result<Attachment<Vec<u8>>, AppError> {
    let user = state.db.get_user(user_id).await?;
    let csv = to_csv(&user);
    Ok(Attachment::new(csv)
        .filename(format!("user_{user_id}.csv"))
        .content_type("text/csv"))
}

// 登录——错误走 WithRejection 转成 AppError
// 注意 WithRejection<Json<_>> 会消费 body,必须放在最后一个参数(放在 State 前面编译报 E0277)
async fn login(
    jar: PrivateCookieJar,
    State(state): State<AppState>,
    WithRejection(Json(creds), _): WithRejection<Json<Credentials>, AppError>,
) -> Result<(PrivateCookieJar, Json<User>), AppError> {
    let user = state.auth.authenticate(&creds).await?;
    let cookie = Cookie::build(("session", user.token.clone()))
        .http_only(true).secure(true).same_site(SameSite::Lax).build();
    Ok((jar.add(cookie), Json(user)))
}

let app = Router::new()
    .typed_get(get_user)
    .typed_get(export_user)
    .route("/login", post(login))
    .with_state(state);

一个文件里用了 7 个 axum-extra 特性:TypedPath、RouterExt、PrivateCookieJar、WithRejection、Either(嵌套两层)、Protobuf、Attachment。代码紧凑且意图清晰——每个类型都表达一个具体语义。

这种"多 feature 组合"是 axum-extra 设计的目标——提供一堆 building block、让项目按需组合。

Resource 的 REST 风格深入

Resource 的七个 verb 和 URL 的对应:

方法 URL(以 Resource::named("users") 为例) HTTP 含义
index /users GET 列表
show /users/{users_id} GET 详情
new /users/new GET 新建表单
create /users POST 创建
edit /users/{users_id}/edit GET 编辑表单
update /users/{users_id} PUT 或 PATCH(MethodFilter::PUT.or(MethodFilter::PATCH),axum-extra/src/routing/resource.rs:112-122) 更新
destroy /users/{users_id} DELETE 删除

来自 Rails 的 convention——适合传统 MVC 式 web 应用。对 JSON API 可能过分——通常只需要 index/show/create/update/destroy 五个(没 HTML form 页面)。

实际使用:

let users = Resource::named("users")
    .index(list_users)
    .show(get_user)
    .create(create_user)
    .update(update_user)
    .destroy(delete_user);

let posts = Resource::named("posts")
    .index(list_posts)
    .show(get_post)
    .create(create_post);  // 只支持创建、不支持修改/删除

let app = Router::new()
    .merge(users)      // 走 From<Resource<S>> for Router<S>
    .merge(posts);

每个 Resource 独立配 handlers——不用全部七个都提供。对 Resource nested(/users/{user_id}/posts)——需要手动写路径或用 TypedPath、Resource 目前不直接支持嵌套。

生态哲学:什么该放 extra

axum-extra 的模块选择反映一条设计原则——什么功能该进核心 axum、什么该进 extra?

核心 axum 应该有:

  • 绝大多数 web server 都需要的功能(路由、提取器、响应、middleware)
  • 没有合理替代方案的(比如 Request/Response 类型)
  • 必须由框架提供的(Handler trait、Router)

axum-extra 可以有:

  • 某些场景非常有用但不是普遍需要(Protobuf、JsonLines)
  • 有多种实现选择的(Cookie 可以用 tower-cookies 或其他)
  • 比较厚的依赖(需要 prost、headers crate)
  • 社区偏好不一致的(Resource 的 REST convention 有人喜欢有人不喜欢)

这个切分让 axum 核心保持精简、依赖少、编译快;axum-extra 按需加 feature——用户控制复杂度。

实际项目里 feature 使用大致分布如下(经验判断,不是统计数据):

  • 几乎必用:typed-header(轻量有用)、cookie-signed(session 必需)
  • 常用:typed-routing(大型项目)、multipart(文件上传)、form(HTML form)
  • 偶尔用:protobuf(gRPC 互通)、json-lines(大数据流)、with-rejection(错误定制)
  • 少用:cached(特定提取器性能)、either(多类型响应)

新项目可以从"几乎必用 + 你确定需要的"开始、之后按需增加。不要一开始就开所有 feature——编译时间会长很多。

axum-extra 的稳定性

axum-extra 比 axum 核心迭代更快——经常有新模块加入、旧模块调整。不同版本之间的 API breaking 比核心多,0.11.0 甚至因为一个意外的破坏性变更被整个 yank 掉。用 axum-extra 的 feature 时锁定版本(^0.12 而不是 *)很重要——防止 cargo update 意外破坏。

对比 axum 本体——每个大版本(0.6 → 0.7 → 0.8)才有 breaking——小版本向下兼容。axum-extra 的 break 节奏更快——是"实验田"的代价。

axum-extra 和 axum-macros 的分工

两个 "extra" 性质的 crate——容易混淆。对比:

维度 axum-macros axum-extra
类型 proc macro 库 运行时功能库
主要内容 derive 宏(FromRef/FromRequest)+ attribute(debug_handler) 具体 extractor / response / router helper
大小 小 中(功能多)
依赖 syn / quote cookie / headers / prost 等按 feature
和 axum 关系 密耦合(生成 axum trait impl) 依赖 axum、但是扩展
升级节奏 慢(API 稳定) 快(新功能不断加)

两者都是 "axum 的官方 extra"——但定位不同。axum-macros 是元编程工具、axum-extra 是功能扩展。大多数项目两者都装——不冲突、互补。注意其实 axum-extra 内部依赖 axum-macros(用来做 TypedPath 的 derive)——所以开 typed-routing feature 时 axum-macros 自动被拉进来——用户没感知。

axum-extra 常见选型指南

决定是否用某个 axum-extra 功能的 checklist:

  • 这个 feature 解决的问题我真遇到了吗?——不要 "一看文档觉得有用就开"
  • 核心 axum 能搞定这件事吗?——能的话别加依赖
  • 编译时间增加多少?——cargo build --timings 看
  • 依赖树多了什么?——cargo tree 看
  • 这个 feature 的 API 稳定吗?——看 CHANGELOG、看有没有 deprecated

大多数情况下用 axum-extra 是对的选择——它已经精心设计、经过考验。但"有就用"的心态会让项目不必要臃肿。有意识地选 feature 是工程习惯的一部分。

axum-extra 生态速览

axum-extra 的模块按主题分组:

flowchart TB
    classDef routing fill:#efe,stroke:#080
    classDef extract fill:#fef,stroke:#808
    classDef response fill:#eef,stroke:#066
    classDef util fill:#ffe,stroke:#b90
    subgraph routing["routing 模块"]
        TP["TypedPath + RouterExt"]:::routing
        RES["Resource"]:::routing
    end
    subgraph extract["extract 模块"]
        CK["CookieJar / Signed / Private"]:::extract
        WR["WithRejection"]:::extract
        CC["Cached"]:::extract
        QQ["Query / Form (多值参数)"]:::extract
        MP["Multipart"]:::extract
    end
    subgraph response["response 模块"]
        ATT["Attachment"]:::response
        FS["FileStream"]:::response
        EJ["ErasedJson"]:::response
        IE["InternalServerError"]:::response
    end
    subgraph other["其他"]
        EITHER["Either / Either3-8"]:::util
        TH["TypedHeader"]:::util
        PB["Protobuf"]:::util
        JL["JsonLines"]:::util
        MW["middleware (option_layer)"]:::util
    end

四大类——路由扩展、提取器增强、响应辅助、其他杂项。项目里按需启用 feature——通常开几个就够。

axum-extra 的依赖关系

用 axum-extra 前建议检查 feature 和依赖:

feature 引入依赖(照 0.12.6 的 Cargo.toml)
typed-routing routing + axum-macros, percent-encoding, serde_core, serde_html_form, form_urlencoded
routing axum(并开 axum 的 original-uri), rustversion
cookie cookie 0.18(axum-extra 已默认给它开 percent-encode)
cookie-signed / cookie-private cookie + 它的 signed / private feature
protobuf prost 0.14
typed-header headers 0.4
erased-json serde_core, serde_json, typed-json
multipart multer 3, fastrand
async-read-body / file-stream tokio, tokio-util

feature 选择决定依赖树和最终 binary 大小。小项目不开 protobuf 就不会把 prost 编进去。

开发体验:axum-extra 的 DX

axum-extra 的几个设计让它用着舒服:

一、和 axum 同步发布:axum 0.8 这一代对应 axum-extra 0.10 / 0.12(版本号不同但绑定发布;0.11.0 已被 yank)。axum-extra 0.12.6 的 Cargo.toml 里对 axum 的依赖写的是 version = "0.8.9"(即 ^0.8.9,要求 axum 至少 0.8.9)。cargo update axum 时需要一起升级——不会兼容性混乱。

二、文档显示 feature 要求:crate 在 docs.rs 构建时开了 doc_cfg(axum-extra/src/lib.rs:38),每个受 feature 控制的 API 页面都标着需要哪个 feature——不启用 feature 时 compile error 友好。

三、生态一致:和 axum 一样用 tracing log、一样的错误风格——无缝融入项目。

四、可以独立测试:不用搭完整 axum 应用——axum-extra 的每个模块在 crate 内有自己的 test——读源码能看到使用模式。

实战:一个 axum-extra 重度使用的 API

看一个综合用 axum-extra 多个 feature 的 handler:

use axum::{Router, Form, routing::post, response::{IntoResponse, Redirect, Html}};
use axum_extra::{
    extract::{PrivateCookieJar, WithRejection, cookie::{Cookie, SameSite}},
    either::Either,
    routing::{RouterExt, TypedPath},
    TypedHeader,
    protobuf::Protobuf,
};
use axum::http::{HeaderMap, header::ACCEPT};
use headers::UserAgent;

#[derive(TypedPath, serde::Deserialize)]
#[typed_path("/users/{user_id}/profile")]
struct ProfilePath { user_id: u64 }

async fn get_profile(
    ProfilePath { user_id }: ProfilePath,
    jar: PrivateCookieJar,
    TypedHeader(ua): TypedHeader<UserAgent>,
    headers: HeaderMap,
) -> Either<Html<String>, Protobuf<UserProto>> {
    // 根据 Accept 头返 HTML 或 Protobuf
    let user = db.get_user(user_id).await.unwrap();
    let accept = headers.get(ACCEPT).and_then(|v| v.to_str().ok()).unwrap_or("");

    if accept.contains("application/x-protobuf") {
        Either::E2(Protobuf(user.into_proto()))
    } else {
        Either::E1(Html(render_profile(&user)))
    }
}

async fn login(
    jar: PrivateCookieJar,
    WithRejection(Form(creds), _): WithRejection<Form<Credentials>, AppError>,
) -> Result<impl IntoResponse, AppError> {   // 函数体里用了 ?,返回类型必须是 Result
    let user = authenticate(&creds).await?;
    let cookie = Cookie::build(("session", user.session_token))
        .http_only(true).secure(true).same_site(SameSite::Lax).build();
    Ok((jar.add(cookie), Redirect::to("/dashboard")))
}

let app = Router::new()
    .typed_get(get_profile)
    .route("/login", post(login));

用到:

  • TypedPath:URL 和 struct 绑定
  • RouterExt::typed_get:类型化路由
  • PrivateCookieJar:加密 session
  • TypedHeader + UserAgent:类型化 header(Accept 在 headers 0.4 里没有类型,只能读 HeaderMap)
  • Either:根据 Accept 返 HTML 或 Protobuf
  • Protobuf:二进制 API
  • WithRejection:自定义 Form 的错误

一个 handler 用了六七个 axum-extra 特性——这是 extra 的真实价值。不用 extra 的话每一项都要自己实现或找三方库。

axum-extra::middleware::option_layer

常见需求——条件加 layer:

// axum-extra 的辅助工具(需要 middleware feature)
use axum_extra::middleware::option_layer;

let app = Router::new()
    .route("/", get(h))
    .layer(option_layer(
        should_auth.then(|| from_fn(auth_mw))
    ));

option_layer(Some(layer)) 应用 layer、option_layer(None) 不加。让"runtime 决定是否加 middleware"优雅——不用 if should_auth { router.layer(...) } 两分支。

实现就是前面说的 Either 的 Layer impl,签名是:

pub fn option_layer<L>(layer: Option<L>) -> Either<(ResponseAxumBodyLayer, L), Identity>

Some 走 E1、None 走 Identity(tower 的空 layer)。注意 0.12.0 的一处 breaking:Some 分支多套了一层 ResponseAxumBodyLayer,把响应 body 统一映射成 axum::body::Body——否则两个分支的 Service::Response 类型对不上。

axum 本体没这个 helper;tower 自己倒有一个 tower::util::option_layer(tower/src/util/mod.rs:1098,返回 Either<L, Identity>)。两者的区别 rustdoc 写得很明白:axum-extra 版保证输出的 body 是 axum::body::Body,也就是上面那层 ResponseAxumBodyLayer。生产里条件 layer 不罕见(feature flag、环境差异)——extra 提供便利。

不开 axum-extra 的极简场景

有些场景不开 axum-extra——只用核心:

  • Rust 学习项目:先熟悉 axum 核心 API、extra 后加
  • 极小 microservice:比如 health check server——只有几个 GET /healthz——核心 axum 够
  • 嵌入式 / WASM:编译产物敏感——每个 feature 都是大小
  • 教学代码:少依赖让 cargo run 快、例子简单

这些场景 axum 核心的几个 trait 已经涵盖——Router、handler、Json 就能做完。extra 是扩展需要时才加——不是默认必装。

版本演进时间表

按 axum-extra/CHANGELOG.md 摘几个能对上号的节点:

  • 0.1.0(2021-12-02):首个版本
  • 0.2.1:加入 PrivateCookieJar(PR #900)
  • 0.3.5:加入 JsonLines(PR #1093)
  • 0.4.0(2022-11-25,对应 axum 0.6):加入 Either*(PR #1263)与 WithRejection(PR #1262);移除 Resource::nest / nest_collection
  • 0.5.0:加入 option_layer,并给 Either 补上 Layer / Service impl(PR #1696)
  • 0.6.0:ProtoBuf 改名 Protobuf(PR #1595)
  • 0.9.0(2023-11-27,对应 axum 0.7):TypedHeader 从 axum 本体迁进 axum-extra(PR #1850)
  • 0.9.5:加入 RouterExt::typed_connect、json! 宏(0.10.0 的完整 changelog 里又列了一遍)
  • 0.10.0(对应 axum 0.8):加入 InternalServerError、FileStream
  • 0.11.0:已从 crates.io yank(引入了意料之外的破坏性变更,见 PR #3190)
  • 0.12.0:Cached / handler / middleware / routing / with-rejection 拆成必须显式开启的 feature;prost 升到 0.14

每个主版本大致对应 axum 的主版本——同步演进。升级项目时两个一起升。

和其他生态对比

Rust 其他 web framework 的扩展生态:

框架 核心 crate 扩展 crate
axum axum axum-extra, axum-macros, axum-server
actix-web actix-web actix-web-lab, actix-cors, actix-multipart
warp warp 少——大多功能在核心
rocket rocket rocket_contrib(0.4 时代)
tide tide tide-* 子 crate

axum 的"核心精简 + 扩展丰富"方式和 actix-web 类似——大型 ecosystem 都倾向这种分层。warp 把功能塞在核心——小项目方便、大项目 bundle 过大。rocket 的 contrib 设计类似 extra——但更早期、API 没那么稳定。

axum + extra + macros + server 四件套加起来覆盖了绝大多数 web 服务的日常需求——剩下的交给第三方 crate 生态(tower-http 的 compression、tracing,以及各类 auth / session crate)。整体依赖图清晰、每个 crate 定位明确——是 Rust 的 web 生态成熟的标志。

FAQ

Q:axum-extra 的版本怎么选?

看 axum-extra 自己声明的 axum 依赖版本(0.12.6 是 axum ^0.8.9)——最新 axum 对应 axum-extra 的最新版。用 cargo add axum-extra 自动解决版本。

Q:能不能只用 axum-extra 而不用 axum?

不能——axum-extra 依赖 axum。但 axum 不依赖 axum-extra——可以只用核心。

Q:axum-extra 的性能怎么样?

和核心 axum 一个量级——绝大多数类型是薄 wrapper。唯一有实际计算开销的是 SignedCookieJar / PrivateCookieJar 的签名与加解密(每个 cookie 一次),相对一次数据库或外部 API 调用可忽略。要确认具体数字请在自己的机器上用 criterion 实测。

Q:想贡献新 feature 到 axum-extra?

axum 项目欢迎 PR——但接受有标准,先读仓库根目录的 CONTRIBUTING.md。经验上要过的门槛是:适用面足够广、设计已经在真实项目里跑过、有测试和 rustdoc。小众需求建议发自己的 crate。

Q:axum-extra 某个 feature 坏了但其他 feature 还能用吗?

可以——cargo feature 是加法组合。关闭有问题的 feature、只开其他——不受影响。生产遇到 bug 可以用这种方式暂时绕过、等 fix。

Q:axum-extra 和 tower-http 的分工?

  • tower-http:通用 HTTP middleware(compression、trace、cors、timeout、set-header)——和 tower 生态兼容、不绑定 axum
  • axum-extra:axum 特定的扩展(TypedPath、Cookie、Protobuf)——依赖 axum

两者互补——都装。tower-http 是"HTTP 层"、axum-extra 是"axum 层"。

Q:axum-extra 会和 axum 本体合并吗?

目前没这计划——分离设计让核心保持精简。合并可能带来的问题:编译更慢、依赖更多、核心 API 膨胀。分开的状态有利于长期健康。

Q:有些 axum-extra 功能和 axum 核心功能重叠(比如 Form),怎么选?

核心已经提供的用核心(axum::Form)。0.8.9 上唯一的例外是需要同名多值参数(复选框、多选 <select>)——那时 axum 本体还用 serde_urlencoded,得用 extra 版本。主线上 extra 的 Query / Form 已标 deprecated,是因为 axum 本体也换成了 serde_html_form(#3594),extra 版本只留作分步迁移的过渡。

Q:axum-extra 相关 crate 的文档质量怎么样?

非常好——每个 module 都有示例代码 + API 文档 + 相关 link。docs.rs/axum-extra 是最权威的参考——比任何博客都准。

JsonLines 的流式详细

JsonLines 作为响应:

use axum_extra::json_lines::{JsonLines, AsResponse};
use futures_core::stream::Stream;

async fn export_users(State(db): State<PgPool>)
    -> JsonLines<impl Stream<Item = Result<User, BoxError>>, AsResponse>
{
    let stream = db.stream_users();  // 按游标流式返回
    JsonLines::new(stream)
}

每个 User 单独 serde_json::to_writer 成 JSON、再补一个 \n:

{"id":1,"name":"Alice"}
{"id":2,"name":"Bob"}
...

这里有个容易被文档误导的点:JsonLines 的响应不设 Content-Type。源码 json_lines.rs 的 into_response 里直接写着注释 "there is no consensus around mime type yet",链接指向 wardi/jsonlines issue #36——最后落到 Body::from_stream(...).into_response()——Body 的 IntoResponse 只是 Response::new(self),响应里根本没有 Content-Type 头(0.12.6 实测),而不是 application/octet-stream。要发 application/x-ndjson 得自己带上 header。

接收侧按行读——JsonLines<T, AsExtractor> 本身就是 Stream<Item = Result<T, axum::Error>>:

use futures_util::StreamExt;
async fn import(mut stream: JsonLines<User>) {
    while let Some(user) = stream.next().await {
        let Ok(user) = user else { break };   // 某一行解析失败(Err(axum::Error))就停止
        // 处理
    }
}

应用场景:

  • 大批量数据导出(100 万条记录的用户列表)
  • ETL 管道(一边从源 API 读、一边写 sink)
  • 日志流(服务间日志传输)
  • 爬虫结果(每爬到一条发一条)

比 Vec<User> 一次性响应——JsonLines 内存占用恒定(一条记录的大小)、延迟也更好(第一条记录立即发)。

哪些功能以后可能进入核心 axum?

常被问到能不能挪进核心的几个候选:

  • TypedHeader / CookieJar:使用率高、API 稳定——但依赖 headers / cookie crate(非核心依赖);TypedHeader 其实是 axum 0.7 时从本体挪进 axum-extra 的(PR #1850,见上文的版本表)
  • Either:通用、小——可能挪入
  • Resource:有争议——REST convention 不是每个项目都用

目前这些都留在 extra——axum 团队偏向"核心最小化"。extra 的存在让核心能专注——不必做所有人的朋友。

反过来某些东西 永远不会进核心:

  • Protobuf:依赖 prost——太重
  • 特定数据库集成(sqlx extractor 等):应用层责任
  • 中间件堆(tower-http 的 layer):已经有完善生态

这种边界清晰让社区能合理期待——知道自己需要的功能在哪找。

版本升级经验

axum-extra 的版本 break 比 axum 核心频繁。升级建议:

  1. 看 CHANGELOG:每次升级 extra 先读 CHANGELOG.md、标记哪些 API 改了
  2. 逐个 feature 升级:不要一次升所有——先升最依赖的 feature(比如 typed-routing)、跑测试、再升其他
  3. trybuild 挡一道:项目里写 trybuild 测试覆盖关键 API——升级后自动检查 error messages 是否符合预期
  4. 必要时 pin 到 patch 版本:axum-extra = "=0.12.6" 而不是 "^0.12"——避免 patch 引入意外改动

axum-extra 的快速迭代是特性不是 bug——新功能更快到用户手里。升级要投时间——但值。

一个常见痛点:feature 冲突

偶尔会遇到:两个 crate 都依赖 axum-extra、但开了冲突的 feature。cargo 会自动合并 feature(union)——但合并后的版本可能有新的约束。

例子:

[dependencies]
my_auth_lib = "1.0"       # 依赖 axum-extra features = ["cookie-signed"]
my_export_lib = "1.0"     # 依赖 axum-extra features = ["json-lines"]

最终 axum-extra 被开启两个 feature——正常工作。但如果两个 lib 依赖不同 major 版本的 axum-extra——cargo 要装两份——编译时间大增、可能出类型不匹配错误。

解决:

  • Lock 到一个 axum-extra major 版本——所有依赖 lib 都用同一版本
  • 跑 cargo tree——看依赖树有没有重复 axum-extra
  • 帮 lib 作者升级——开 issue 请求升级依赖版本

这不是 axum-extra 特有问题——任何分层 crate 生态都有。只是 axum-extra 因 feature 多、发版快、更容易触发。

小结:扩展 crate 的哲学

axum-extra 展现了 Rust 生态常见的"核心精简 + 扩展丰富"设计:

  • axum 核心:最小可行——所有项目都会用的功能
  • axum-extra:按需扩展——官方维护但可选

这种分工让用户自己决定复杂度——小项目只用 axum、大项目加 axum-extra 按需 feature。一个 hello-world API 和一个生产级 SaaS 都用同一个 axum——差别只在于各自开了哪些 feature、编译进了哪些依赖。

axum-extra 也是核心之外的缓冲区——拿不准要不要进核心的扩展先放在 extra。流向也不只是 extra → 核心:TypedHeader 就是在 0.7.0 从核心挪进 extra 的(axum CHANGELOG,#1850)。这让 axum 本体能保守演进、不被实验性功能拖累。

一张覆盖全章内容的图

把 axum-extra 的所有模块按"数据流向"归类:

flowchart LR
    classDef req fill:#efe,stroke:#080
    classDef state fill:#fef,stroke:#808
    classDef resp fill:#eef,stroke:#066
    REQ["Request"] --> E1["提取器 (extract)"]:::req
    E1 --> TYPHD["TypedHeader"]
    E1 --> COOKIE["Cookie 三件套"]
    E1 --> WR["WithRejection"]
    E1 --> CACHED["Cached"]
    E1 --> MULTI["Multipart"]
    E1 --> JL["JsonLines (input)"]
    COOKIE -.FromRef 取 Key.-> STATE["state 里的 Key"]:::state
    E2["handler 业务"] --> RESP["Response"]
    RESP --> EITHER["Either"]:::resp
    RESP --> PROTO["Protobuf"]:::resp
    RESP --> ATT["Attachment"]:::resp
    RESP --> EJ["ErasedJson"]:::resp
    RESP --> JL2["JsonLines (output)"]:::resp
    RESP --> FS["FileStream"]:::resp
    RESP --> IE["InternalServerError"]:::resp
    ROUT["路由层 (routing)"] --> TP["TypedPath + RouterExt"]
    ROUT --> RES["Resource (REST)"]
    MW["middleware"] --> OL["option_layer"]

四大类:extract(请求方向)、routing(路由层)、response(响应方向)、middleware(处理链)。每一类的 module 解决不同问题——组合起来覆盖 axum 核心没覆盖到的场景。读本章之后再看 axum-extra 的 docs.rs——所有 module 都会显得熟悉、知道该去哪找需要的 API。

Attachment 和 FileStream 的区别

两者都用于文件响应:

Attachment:一次性响应——body 是内存中的 Vec<u8> 或 Bytes:

use axum_extra::response::Attachment;

async fn small_file() -> Attachment<Vec<u8>> {
    let data = read_from_memory();  // 假设几 MB
    Attachment::new(data).filename("report.pdf")
}

FileStream:流式响应——注意 FileStream::new 收的是 Stream 而不是 File,文件要先用 ReaderStream 包一层(或者直接用 FileStream::from_path(path),它会顺带填好文件名和 Content-Length):

use axum_extra::response::file_stream::FileStream;
use tokio::fs::File;
use tokio_util::io::ReaderStream;

async fn large_file() -> Result<impl IntoResponse, AppError> {
    let file = File::open("/data/big.zip").await?;
    Ok(FileStream::new(ReaderStream::new(file)).file_name("big.zip"))
}

实例上只有三个方法:file_name、content_size(设 Content-Length),以及做 Range 响应的 into_range_response;构造函数除了 new 还有 from_path 和按路径直接出 Range 响应的 try_range_response。

选择标准:

  • 小文件、内存装得下:Attachment——一次性简单
  • 大文件、不想占内存:FileStream——流式

两者都设 Content-Disposition 头(attachment; filename="...")——客户端会下载而不是内联;0.12.6 修了一个安全问题:filename 里的反斜杠和双引号现在会被转义,防止 header 参数注入。Content-Type 两边都不做扩展名推断——FileStream 固定发 application/octet-stream,Attachment 要自己 .content_type(...)。

生态协作模式

axum-extra 体现 Rust 生态的协作模式——核心团队维护、社区贡献。每个新功能都是 issue 先讨论、PR 审查、测试覆盖、文档完善——严格但欢迎参与。这让 axum-extra 不是"官方的 kitchen sink"——而是精心筛选的扩展集合。用户用它时有 confidence:代码质量高、设计经过讨论。

相比一些 "kitchen sink" 式的扩展(把所有能想到的功能都塞一起)——axum-extra 保持克制。只有真正值得存在的功能才进 extra——没有通过标准的或者归属到更合适位置的东西(比如 tower-http 负责 HTTP 中间件通用功能)。这种克制让 axum-extra 不臃肿——依赖少、编译快、用户学习成本低。

从整个专栏角度看,axum 的生态由几层组成:axum 核心(路由、handler、提取器、响应、middleware)+ axum-macros(减少 boilerplate)+ axum-extra(扩展功能)+ axum-server(TLS 部署)+ tower/tower-http(通用 HTTP middleware)+ tokio/hyper(异步运行时和 HTTP 协议栈)。理解了这个层级,项目遇到需求知道该去哪找解——不会全都自己造。

这种分层也让 axum 成为一个可成长的生态——新的扩展 crate 不断加入、旧的逐渐成熟或被替代。生态活跃度高——值得投入学习。生产项目选 axum 就是选整条生态链——不只是一个 web 框架。

完整依赖组合

生产项目的 Cargo.toml 典型组合:

[dependencies]
axum = "0.8"
axum-extra = { version = "0.12", features = ["typed-routing", "cookie-signed", "typed-header", "protobuf"] }
axum-macros = "0.5"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "compression", "cors", "timeout"] }
tokio = { version = "1", features = ["full"] }
hyper = "1"
# ... 业务特定 crates

八九个 axum 相关 crate——每个解决一块问题——组合成完整生产部署。这种"多 crate 组合"在 Rust 生态是 idiom——比 Node/Python 的"一个巨型框架装所有东西"更模块化。理解 axum-extra 的模块划分、选 crate 就不盲目——需求清楚该用哪个 crate、哪个 feature、怎么组合。

feature flag 的组合检查

axum-extra 的 feature 之间有隐性依赖——比如 cookie-private 隐式依赖 cookie、typed-routing 依赖 axum 自身的某些 feature。开发时用 cargo hack check --feature-powerset 扫所有 feature 组合能编译——这个命令组合爆炸、CI 里只对 axum-extra 这层跑就够。漏掉某个组合、用户开启特定 feature 组合时才爆编译错误——难以 debug。

类似地、axum-extra 的 0.x → 0.y 升级要 diff CHANGELOG——小版本也可能调整 feature 划分(比如 0.3.0 起签名 / 加密 cookie 要从 cookie 里另开 cookie-signed / cookie-private,0.12.0 又把 cached、routing 等拆成需要显式开启的 feature)。升级前用 cargo tree -e features 看当前依赖的 feature 链、对照新版本文档调整。

下一章讲测试模式——ServiceExt::oneshot、mock state、axum-test crate——把前面机制汇总到"怎么写好测试"的视角。