Appearance
第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 选了一种(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 的 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;
}三件事:
PATH常量:静态字符串,比如"/users/{user_id}"Displayimpl:把 struct 格式化成实际 URL,比如format!("/users/{}", self.user_id)- 隐含的 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 注册 DELETEhandler 和路径强关联——路径定义在 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。
Cookie 生态:三级加密
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<'_>集合 - 响应 parts:
IntoResponseParts——把 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 慢一倍左右——但都在微秒级可忽略。
三者对比
| 特性 | CookieJar | SignedCookieJar | PrivateCookieJar |
|---|---|---|---|
| 客户端能看内容 | 是 | 是(明文 + 签名) | 否(加密) |
| 客户端能篡改 | 是(没保护) | 否(签名验证) | 否(加密认证) |
| 需要 Key | 否 | 是(Key) | 是(Key) |
| 性能 | 最快 | 中 | 稍慢 |
| 典型用途 | 非敏感 preference | user_id / role | session、API token |
Key 管理:cookie::Key 是 64 字节随机字节——生产用 Key::generate() 生成、持久化到安全存储(环境变量、KMS)。重启不能变——cookie 签名/加密会失效让用户登出。
生产 session 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 的语义——不重复。
Cookie 在 React SPA 项目里的典型结构
前后端分离的典型 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_response 或 E2::into_response 按 variant 分发。这保留了类型信息、签名里明确写出两种可能响应。
Either3-8:更多分支
Either3、Either4、... 到 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 name | Rust 类型 |
|---|---|
User-Agent | UserAgent |
Content-Type | ContentType |
Authorization | Authorization<Basic> / Authorization<Bearer> |
Cache-Control | CacheControl |
ETag | ETag |
If-None-Match | IfNoneMatch |
Accept | Accept |
Host | Host |
Origin | Origin |
Cookie | Cookie |
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-Id、X-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 的关键差异:
| 维度 | Json | Protobuf |
|---|---|---|
| 可读性 | 文本、人读友好 | 二进制、压缩紧凑 |
| 序列化速度 | 中 | 快 |
| 大小 | 大 | 小(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::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——简单 - 完整 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 都专注一件事。大项目通常组合 5-10 个这样的扩展——每个解决一个具体问题、不互相冲突。
怎么选第三方 crate:
- stars 数:github star 数是粗略指标——超 100 star 基本靠谱
- 最近 commit:6 个月没更新可能废弃
- docs 完善度:好文档 = 好设计
- 和 axum 版本兼容:看 Cargo.toml 要求
- 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::Query 和 Form——但 axum-extra 也有同名类型(启用 query 或 form 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 的对应:
| 方法 | URL | HTTP | 含义 |
|---|---|---|---|
index | /<name> | GET | 列表 |
show | /<name>/{id} | GET | 详情 |
new | /<name>/new | GET | 新建表单 |
create | /<name> | POST | 创建 |
edit | /<name>/{id}/edit | GET | 编辑表单 |
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-macros | axum-extra |
|---|---|---|
| 类型 | proc macro 库 | 运行时功能库 |
| 主要内容 | derive 宏(FromRef/FromRequest)+ attribute(debug_handler) | 具体 extractor / response / router helper |
| 大小 | 小(几 KB) | 中(功能多) |
| 依赖 | 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 的模块按主题分组:
四大类——路由扩展、提取器增强、响应辅助、其他杂项。项目里按需启用 feature——通常开 3-5 个就够。
axum-extra 的依赖关系
用 axum-extra 前建议检查 feature 和依赖:
| feature | 引入依赖 |
|---|---|
| typed-routing | axum-macros, percent-encoding, serde |
| cookie | cookie (percent-encode) |
| cookie-signed / private | cookie + hmac / aes |
| protobuf | prost |
| typed-header | headers |
| form | serde_html_form |
| multipart | multer |
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 |
|---|---|---|
| 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 四件套加起来覆盖 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 核心频繁。升级建议:
- 看 CHANGELOG:每次升级 extra 先读 CHANGELOG.md、标记哪些 API 改了
- 逐个 feature 升级:不要一次升所有——先升最依赖的 feature(比如 typed-routing)、跑测试、再升其他
- trybuild 挡一道:项目里写 trybuild 测试覆盖关键 API——升级后自动检查 error messages 是否符合预期
- 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 隐式依赖 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 名("cookie-signed-private" → "cookie" + 两个子 feature)。升级前用 cargo tree -e features 看当前依赖的 feature 链、对照新版本文档调整。
下一章讲测试模式——ServiceExt::oneshot、mock state、axum-test crate——把前面机制汇总到"怎么写好测试"的视角。