Skip to content

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

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 选了一种(cookieheaders)——如果放核心会强制所有用户用那种——不灵活。放 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 的 Cargo.toml 定义了几十个 feature flag——每个模块一个:

toml
[features]
default = ["tracing"]
async-read-body = ["dep:tokio", "tokio/io-util"]
attachment = []
cached = []
cookie = ["cookie/percent-encode"]
cookie-private = ["cookie", "cookie/private"]
cookie-signed = ["cookie", "cookie/signed"]
erased-json = []
form = ["dep:serde_html_form"]
json-lines = ["dep:tokio-util", "dep:serde_json"]
multipart = ["dep:multer"]
protobuf = ["dep:prost"]
routing = []
tracing = []
typed-routing = ["routing", "dep:axum-macros", "dep:percent-encoding", "dep:serde"]
typed-header = ["dep:headers"]
with-rejection = []
# 其他

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

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

这种细粒度 feature 的好处:小项目只付出小的编译成本。不启用 protobuf feature 就不依赖 prost crate——省几秒编译。

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

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

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

TypedPath trait

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

rust
pub trait TypedPath: std::fmt::Display {
    const PATH: &'static str;
}

三件事:

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

RouterExt::typed_get 等

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

rust
pub trait RouterExt<S>: Sized {
    fn typed_get<H, T, P>(self, handler: H) -> Self
    where H: Handler<T, S>, T: 'static, P: TypedPath;

    fn typed_post<H, T, P>(self, handler: H) -> Self where ...;
    fn typed_put<H, T, P>(self, handler: H) -> Self where ...;
    // typed_delete / typed_patch / typed_head / typed_options
}

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

使用方式:

rust
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 生成:

rust
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 下:

rust
use axum_extra::routing::Resource;

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

let app = Router::new().merge(users);

六个方法名对应 REST 约定的 CRUD——读代码一眼知道每个 handler 的 verb 和 path。适合 REST API 多的项目——比手工 route 组装少写字符串 URL。

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

CookieJar:基础

axum-extra::extract::CookieJar

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

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——从请求 Cookie 头解析成 Cookie<'_> 集合
  • 响应 partsIntoResponseParts——把 added / removed cookies 转成 Set-Cookie 头追加

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

SignedCookieJar:HMAC 签名

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

rust
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 带 HMAC
    // "user_id=123|HMAC_SIG"
    (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 签名——value 和签名一起发给客户端
  • :拆分 value 和签名——验证签名——通过才返回 cookie

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

PrivateCookieJar:AES 加密

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

rust
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、账号余额等。

加密实现用 AES-GCM(AEAD)——同时提供 confidentiality + authenticity。性能上比 SignedCookieJar 慢一倍左右——但都在微秒级可忽略。

三者对比

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

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

典型生产设置:

rust
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 天过期
    .path("/")                          // 作用域全站
    .build();

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

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

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

rust
#[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 返回类型根据条件不同:

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

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

rust
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:

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

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

Either3-8:更多分支

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

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

使用:

rust
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 不只是响应类型——也能作为提取器(either.rs:235 左右的 FromRequestParts impl):

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

Either 按顺序尝试——E1 成功返 E1、失败试 E2、都失败返 E2 的 rejection。让 handler 支持多种认证方式。

Either 的内部实现

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

rust
// axum-extra/src/either.rs:108-115
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

axum-extra/src/either.rs:235 之后的 macro 生成 Either3-8 的所有 impl——模板一致、只是 variant 数量不同。

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

Either 的限制

Either 对 FromRequest(body 消费型)不那么直接——因为 body 只能消费一次、E1 试失败后 body 已经被消耗。源码里 Either 的 FromRequest impl(如果有)需要特别处理——通常 via Parts 消费、再重构 Request。

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

TypedHeader:类型化 header

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

rust
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——提供 30+ 标准 header 的类型化实现(ContentType、UserAgent、Authorization、CacheControl、ETag 等等)。TypedHeader 把 headers::Header trait 桥接到 axum 的 FromRequestParts。

源码核心(简化):

rust
// axum-extra/src/typed_header.rs
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 header_values = parts.headers.get_all(T::name());
        T::decode(&mut header_values.iter())
            .map(Self)
            .map_err(|e| TypedHeaderRejection { /* ... */ })
    }
}

T::name() 是 header 的名字(比如 Authorization::name() == "authorization")、T::decode() 从 header values 解析成 T。这让提取失败可以精细——"Authorization 头缺失" vs "Bearer 格式错" 各自独立错误。

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

TypedHeader 和 headers crate 的映射

headers crate 定义 40+ 标准 HTTP header 的类型化表示:

header nameRust 类型
User-AgentUserAgent
Content-TypeContentType
AuthorizationAuthorization<Basic> / Authorization<Bearer>
Cache-ControlCacheControl
ETagETag
If-None-MatchIfNoneMatch
AcceptAccept
HostHost
OriginOrigin
CookieCookie
Set-Cookie(ContentLocation、其他)

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

自定义 header 加入 ecosystem:

rust
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-IdX-Trace-Id 等)有用——让 handler 里 header 使用类型安全。

TypedHeader vs HeaderMap

对比两种读 header 的方式:

rust
// 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 对接的场景:

rust
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 读字节、用 prost crate 反序列化成 message、handler 返回时序列化成字节。Content-Type 是 application/x-protobuf

和 Json 的关键差异:

维度JsonProtobuf
可读性文本、人读友好二进制、压缩紧凑
序列化速度
大小小(2-5x)
生态几乎全宇宙主要 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::Protobuftonic gRPC
协议HTTP + Protobuf bodygRPC (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 codegRPC status code(在 trailers)

选择:

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

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

第三方 axum 扩展生态

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

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

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

怎么选第三方 crate

  1. stars 数:github star 数是粗略指标——超 100 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 更简洁:

rust
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:

rust
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

Json<Box<dyn erased_serde::Serialize>> 的 wrapper——让不同类型的 JSON 响应能用同一个 Response 类型:

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

避免 Either 的繁琐——代价是 vtable 调用(纳秒级)。适合返回类型完全动态的场景——但大多数场景 Either<Json<A>, Json<B>> 更合适(保留具体类型)。

ErasedJson 真正有用的场景:

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

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

InternalServerError

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

rust
use axum_extra::response::InternalServerError;

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

从 any error 自动转 500 响应 + 带 error log——比手写 map_err 少几行。

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

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

rust
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)] 配合 rejection = MyError 属性类似——但 WithRejection 是单提取器的包装器、derive 是复合提取器的。两者互补——前者适合偶尔想自定义一个提取器的错误、后者适合组合提取器统一错误类型。

Cached:缓存已提取的值

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

rust
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)常用于大数据流式传输:

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

axum-extra::JsonLines 提供流式读写:

rust
use axum_extra::json_lines::{JsonLines, AsResponse};

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

// 或作为提取器
async fn import(JsonLines(mut stream): JsonLines<impl Stream<Item = Result<User, Error>>>) {
    while let Some(user) = stream.next().await {
        // 处理每个 user
    }
}

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

Query / Form 的 axum-extra 变种

第 7 章讲过 axum::extract::QueryForm——但 axum-extra 也有同名类型(启用 queryform feature)。差异:

  • axum 版本:固定错误响应(400 + 错误消息字符串)
  • axum-extra 版本:错误响应自定义(通过 WithRejection

axum-extra 0.9+ 这两个 feature 被标记 deprecated——因为 WithRejection<Query<T>, E> 已经能做同样的事、不需要独立 Query 类型。axum-extra 保留它们做兼容——新项目直接用 WithRejection 组合。

Cached 的内部机制

Cached 的实现:

rust
// axum-extra/src/extract/cached.rs (简化)
pub struct Cached<T>(pub T);

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

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

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

T 必须 Clone + Send + Sync——否则不能在多个 handler / middleware 间共享。

实用例:

rust
// 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):

rust
pub struct Resource {
    name: String,
    router: Router,
}

impl Resource {
    pub fn named(name: &str) -> Self { /* ... */ }

    pub fn index<H, T>(self, handler: H) -> Self { /* 加 GET /<name> */ }
    pub fn show<H, T>(self, handler: H) -> Self { /* 加 GET /<name>/{id} */ }
    pub fn create<H, T>(self, handler: H) -> Self { /* 加 POST /<name> */ }
    pub fn update<H, T>(self, handler: H) -> Self { /* 加 PUT /<name>/{id} */ }
    pub fn destroy<H, T>(self, handler: H) -> Self { /* 加 DELETE /<name>/{id} */ }
    pub fn new<H, T>(self, handler: H) -> Self { /* 加 GET /<name>/new (form page) */ }
    pub fn edit<H, T>(self, handler: H) -> Self { /* 加 GET /<name>/{id}/edit (form page) */ }
}

impl From<Resource> for Router { /* ... */ }

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

小限制:Resource 假定 URL 格式是 /<name>/{id}——对更复杂嵌套(/users/{user_id}/posts/{post_id})不适用。复杂嵌套建议手写 route。

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

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

rust
use axum::{Router, 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 headers::{ContentType, 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
async fn get_user(
    UserPath { user_id }: UserPath,
    jar: PrivateCookieJar,
    TypedHeader(accept): TypedHeader<Accept>,
    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 = accept.to_string();
    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
async fn login(
    jar: PrivateCookieJar,
    WithRejection(Json(creds), _): WithRejection<Json<Credentials>, AppError>,
    State(state): State<AppState>,
) -> 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(三层)、TypedHeader、Protobuf、Attachment。代码紧凑且意图清晰——每个类型都表达一个具体语义。

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

Resource 的 REST 风格深入

Resource 的七个 verb 和 URL 的对应:

方法URLHTTP含义
index/<name>GET列表
show/<name>/{id}GET详情
new/<name>/newGET新建表单
create/<name>POST创建
edit/<name>/{id}/editGET编辑表单
update/<name>/{id}PUT更新
destroy/<name>/{id}DELETE删除

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

实际使用:

rust
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.into_router())
    .merge(posts.into_router());

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

生态哲学:什么该放 extra

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

核心 axum 应该有

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

axum-extra 可以有

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

这个切分让 axum 核心保持精简、依赖少、编译快——几百 KB 的编译产物;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 比核心多。用 axum-extra 的 feature 时锁定版本(^0.11 而不是 *)很重要——防止 cargo update 意外破坏。

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

axum-extra 和 axum-macros 的分工

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

维度axum-macrosaxum-extra
类型proc macro 库运行时功能库
主要内容derive 宏(FromRef/FromRequest)+ attribute(debug_handler)具体 extractor / response / router helper
大小小(几 KB)中(功能多)
依赖syn / quotecookie / 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 的模块按主题分组:

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

axum-extra 的依赖关系

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

feature引入依赖
typed-routingaxum-macros, percent-encoding, serde
cookiecookie (percent-encode)
cookie-signed / privatecookie + hmac / aes
protobufprost
typed-headerheaders
formserde_html_form
multipartmulter

feature 选择决定最终 binary 大小。小项目不开 protobuf / prost——省几 MB binary。

开发体验:axum-extra 的 DX

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

一、和 axum 同步版本号:axum 0.8 对应 axum-extra 0.11(版本号不同但版本绑定)。cargo update axum 时需要一起升级——不会兼容性混乱。

二、文档显示 feature 要求:每个 API 的 rustdoc 里清楚标 "requires feature: cookie"——不启用 feature 时 compile error 友好。

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

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

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

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

rust
use axum::{Router, response::{IntoResponse, Redirect, Html}};
use axum_extra::{
    extract::{PrivateCookieJar, WithRejection, cookie::{Cookie, SameSite}},
    either::Either,
    routing::{RouterExt, TypedPath},
    TypedHeader,
    protobuf::Protobuf,
};
use headers::ContentType;

#[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(accept): TypedHeader<headers::Accept>,
) -> Either<Html<String>, Protobuf<UserProto>> {
    // 根据 Accept 头返 HTML 或 Protobuf
    let user = db.get_user(user_id).await.unwrap();

    if accept.to_string().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>,
) -> impl IntoResponse {
    let user = authenticate(&creds).await?;
    let cookie = Cookie::build(("session", user.session_token))
        .http_only(true).secure(true).same_site(SameSite::Lax).build();
    (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 + Accept:内容协商
  • Either:根据 Accept 返 HTML 或 Protobuf
  • Protobuf:二进制 API
  • WithRejection:自定义 Form 的错误

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

axum-extra::middleware::option_layer

常见需求——条件加 layer:

rust
// axum-extra 的辅助工具
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(...) } 两分支。

axum 本体没这个 helper——因为 Tower 的 Layer 是 builder-style、很少 conditional。但生产里条件 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 的几个重要版本:

  • extra 0.4(对应 axum 0.6):初版、Cookie + TypedPath + TypedHeader
  • extra 0.7(对应 axum 0.7):加入 Protobuf、JsonLines、WithRejection
  • extra 0.9:deprecate 独立 Query/Form(推 WithRejection<Query>
  • extra 0.10/0.11:小改进、bug 修复、社区贡献 module(Cached、Resource)

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

和其他生态对比

Rust 其他 web framework 的扩展生态:

框架核心 crate扩展 crate
axumaxumaxum-extra, axum-macros, axum-server
actix-webactix-webactix-web-lab, actix-cors, actix-multipart
warpwarp少——大多功能在核心
rocketrocketrocket_contrib(0.4 时代)
tidetidetide-* 子 crate

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

axum + extra + macros + server 四件套加起来覆盖 90% web 服务需求——剩下 10% 是第三方 crate 生态(tower-http 的 compression、tracing、auth 等)。整体依赖图清晰、每个 crate 定位明确——是 Rust 的 web 生态成熟的标志。

FAQ

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

找 axum 官方的版本兼容 table——最新 axum 对应 axum-extra 的最新版。用 cargo add axum-extra 自动解决版本。

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

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

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

和核心 axum 一个量级——都是零成本抽象。PrivateCookieJar 的加密有几 µs 开销(per cookie)——比普通 CookieJar 稍重、业务场景可忽略。

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

axum 项目欢迎 PR——但接受有标准。一般需要满足:广泛适用性(至少 20% 用户会用)、设计成熟(不是新提案)、代码质量高。小众需求建议发自己的 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)——不要 extra 版本。extra 的 Form 标记 deprecated 就是这原因——避免两个同名同功能的类型混淆。

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

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

Q:能贡献 mermaid 图到 axum-extra 文档吗?

rustdoc 支持 mermaid(通过 klite/mermaid-rs 扩展)——axum-extra 目前没大量用、但理论上可以。想贡献的话发 PR——文档改进通常容易被接受。

JsonLines 的流式详细

JsonLines 作为响应:

rust
use axum_extra::json_lines::{JsonLines, AsResponse};
use futures::stream::StreamExt;

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

响应 Content-Type 是 application/x-ndjson。每个 User 单独序列化成 JSON + \n

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

接收侧(client)按行读:

rust
use futures::StreamExt;
async fn import(JsonLines(mut stream): JsonLines<Result<User, Error>>) {
    while let Some(user) = stream.next().await {
        let user = user?;
        // 处理
    }
}

应用场景:

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

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

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

社区讨论过要把 axum-extra 的某些功能挪到核心——候选:

  • TypedHeader / CookieJar:使用率高、API 稳定——但依赖 headers / cookie crate(非核心依赖)
  • 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 到 minor 版本axum-extra = "=0.11.3" 而不是 "^0.11"——避免 patch 引入意外改动

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

一个常见痛点:feature 冲突

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

例子:

toml
[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——前者几千行编译产物、后者几十万——按需付费的编译时间和二进制大小。

axum-extra 也是生态测试田——新的扩展先进 extra、被广泛使用后稳定了再考虑挪到核心(或者保持 extra 状态)。这让 axum 本体能保守演进、不被实验性功能拖累。

一张覆盖全章内容的图

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

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

Attachment 和 FileStream 的区别

两者都用于文件响应:

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

rust
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:流式响应——从 AsyncRead 源(文件、网络流)流式发:

rust
use axum_extra::response::file_stream::FileStream;
use tokio::fs::File;

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

选择标准:

  • < 10MB、内存足够:Attachment——一次性简单
  • 大文件、不想占内存:FileStream——流式

两者都设 Content-Disposition 头——客户端会下载而不是内联。Content-Type 可以显式设或让 axum 根据 filename 扩展名推断。

生态协作模式

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 典型组合:

toml
[dependencies]
axum = "0.8"
axum-extra = { version = "0.11", 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 隐式依赖 cookietyped-routing 依赖 axum 自身的某些 feature。开发时用 cargo hack check --feature-powerset 扫所有 feature 组合能编译——这个命令组合爆炸、CI 里只对 axum-extra 这层跑就够。漏掉某个组合、用户开启特定 feature 组合时才爆编译错误——难以 debug。

类似地、axum-extra 的 0.x → 0.y 升级要 diff CHANGELOG——小版本也可能改 feature 名("cookie-signed-private""cookie" + 两个子 feature)。升级前用 cargo tree -e features 看当前依赖的 feature 链、对照新版本文档调整。

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

基于 VitePress 构建