Axum 设计与实现
第7章 内置提取器:Path、Query、State、Json
第 6 章把 FromRequestParts 与 FromRequest 的机制讲透了——借用 &mut Parts 还是消费 Request、ViaParts 与 ViaRequest 如何让一致性规则放行、Rejection 为什么必须 IntoResponse。机制是钢筋骨架,这一章要看的是墙和屋顶——Axum 最常用的四个内置提取器如何把骨架填满,各自在真实工程里承担什么职责。
四个提取器按与 body 的关系天然分成两组:
flowchart LR
classDef parts fill:#dfe,stroke:#080
classDef body fill:#fde,stroke:#800
subgraph parts-only[FromRequestParts - 只读 Parts]
P["Path<T>"]:::parts
Q["Query<T>"]:::parts
S["State<S>"]:::parts
end
subgraph body-consuming[FromRequest - 消费 body]
J["Json<T>"]:::body
end
P -->|"读 parts.extensions::<UrlParams>"| PARTS[&mut Parts]
Q -->|"读 parts.uri.query()"| PARTS
S -->|"读调用方传入的 &OuterState"| PARTS
J -->|"校验 Content-Type & 读 body"| REQ[Request]
Path / Query / State 在 handler 参数列表的任何位置都能放;Json 只能放最后。这条规则上一章讲过,这一章会看到它在每个提取器的 trait impl 里是怎么落地的。
四个提取器的代码归属也有讲究:四个具体类型全都定义在 axum 主 crate 里——Path 在 axum/src/extract/path/mod.rs、Query 在 axum/src/extract/query.rs、State 在 axum/src/extract/state.rs,Json 则定义在 crate 根的 axum/src/json.rs(不在 extract 子模块里,因为它同时是 response),再由 axum/src/extract/mod.rs:44 转出到 axum::extract。axum-core 里放的是框架 trait 而不是这四个提取器——FromRequest、FromRequestParts、OptionalFromRequest、OptionalFromRequestParts、FromRef,外加 DefaultBodyLimit 这个 Layer(String、Bytes、Method、HeaderMap 这类基础类型的提取实现也在 axum-core),axum/src/extract/mod.rs:21-24 把这些 trait 和 DefaultBodyLimit #[doc(inline)] 再导出一遍。这层分工是有意的:把"第三方提取器 crate 也要用"的 trait 下沉到 axum-core,保证它们的依赖最小;把具体实现留在 axum 主 crate。
Path<T>:从 Router 接力的 URL 捕获参数
Path 负责把 URL 里的捕获段(比如 /users/{user_id}/team/{team_id} 里的 user_id、team_id)解析成 Rust 类型。看上去像"从 URI 直接解析",但实际的数据路径绕得更远——它依赖第 2 章讲的 Router 在路径匹配完成后往 parts.extensions 里塞的一个 UrlParams 对象。
数据流:从 matchit 结果到 extensions
Router 在路径匹配成功后,把 (key, value) 对以一个特定类型写入 extensions。axum/src/extract/path/mod.rs:166-179 的 get_params 函数(它嵌套在 from_request_parts 内部,注释说明是为了让它对所有 T 只编译一次)展示了 Path 怎么从 extensions 里取:
// axum/src/extract/path/mod.rs:166-179
fn get_params(parts: &Parts) -> Result<&[(Arc<str>, PercentDecodedStr)], PathRejection> {
match parts.extensions.get::<UrlParams>() {
Some(UrlParams::Params(params)) => Ok(params),
Some(UrlParams::InvalidUtf8InPathParam { key }) => {
let err = PathDeserializationError {
kind: ErrorKind::InvalidUtf8InPathParam { key: key.to_string() },
};
Err(FailedToDeserializePathParams(err).into())
}
None => Err(MissingPathParams.into()),
}
}
三个分支对应三种状态:
Some(UrlParams::Params(params)):Router 已经匹配并做了百分号解码,params是Vec<(Arc<str>, PercentDecodedStr)>——键和值都是Arc<str>(PercentDecodedStr内部也是Arc<str>,axum/src/util.rs:13),每次匹配由Arc::from(k)新建(axum/src/routing/url_params.rs:24-30),之后再 clone 只加引用计数;值用PercentDecodedStr已完成 URL 解码Some(UrlParams::InvalidUtf8InPathParam { key }):Router 匹配了但百分号解码后不是合法 UTF-8——比如/users/%C0%C0。这种情况下 Router 也把 key 放进 extensions(为了后续能报哪个参数的错),由 Path 提取器生成400 Bad RequestNone:extensions 里根本没有UrlParams,意味着请求没有经过 Router 的路径匹配。只要经过匹配,Router 就会调用insert_url_params(axum/src/routing/path_router.rs:353)写入它,静态路径上写的是空列表——handler 挂在静态路径上又声明Path<u64>,走的是下文的WrongNumberOfParameters { got: 0 },而不是这一支(实测同为 500,报Expected 1 but got 0)。走到None的典型情形是把 handler 直接当 Service 调用、没经过 Router(实测 500,响应体No paths parameters found for matched route)。这是服务端配置错误而不是客户端的错,所以MissingPathParams的状态码是 500 Internal Server Error(axum/src/extract/rejection.rs:50-57)
sequenceDiagram
participant Client as 客户端
participant Router
participant Matchit as matchit trie
participant Ext as parts.extensions
participant Path as Path提取器
participant Serde as PathDeserializer
Client->>Router: GET /users/42/team/7
Router->>Matchit: match 路径模板
Matchit-->>Router: [(user_id,42), (team_id,7)]
Router->>Ext: insert UrlParams::Params
Router->>Path: 调用 handler 提取 Path 元组
Path->>Ext: extensions 查 UrlParams
Ext-->>Path: Some(Params)
Path->>Serde: T::deserialize PathDeserializer
Serde-->>Path: Ok 元组
Path-->>Client: handler 继续执行
这条链的关键是"解耦":Router 只负责产出匹配结果并放进 extensions,不直接调用提取器;Path 只负责从 extensions 读并反序列化,不关心匹配是怎么做的。两者通过一个共享类型 UrlParams 通信,这是第 6 章讲的"extensions 作为提取器间通信通道"的最典型案例。
自定义 serde Deserializer:PathDeserializer
Path 不直接用 serde_urlencoded 或 serde_json——它有自己的 Deserializer,定义在 axum/src/extract/path/de.rs。axum/src/extract/path/mod.rs:185 调用点:
// axum/src/extract/path/mod.rs:185-188
match T::deserialize(de::PathDeserializer::new(get_params(parts)?)) {
Ok(val) => Ok(Self(val)),
Err(e) => Err(failed_to_deserialize_path_params(e)),
}
为什么需要自定义 Deserializer?因为 Path 的源数据形态——一个 &[(Arc<str>, PercentDecodedStr)]——不是任何标准格式。serde_urlencoded 要求字节流 k=v&k=v;serde_json 要求 JSON 文本。Path 的数据已经被 Router 拆成 Rust 内存里的键值对数组,再走 urlencoded 解析等于把结构化数据先字符串化再解析,浪费且有二次解码的风险。
自定义 PathDeserializer 能做到:
- 按 serde visitor 协议直接喂值:反序列化一个
Uuid时,visitor 访问visit_borrowed_str(v);反序列化一个u32时,visitor 访问visit_u32(v.parse()?)——针对每种目标类型选最直接的 visitor 方法 - 类型不匹配时给出精细错误:
Path<u32>拿到"abc"时报ErrorKind::ParseError { value: "abc", expected_type: "u32" },而不是一句笼统的"反序列化失败" - 支持元组、结构体、HashMap:
Path<(u32, String)>按位置匹配捕获段,Path<Params>按字段名匹配,Path<HashMap<String, String>>获得所有捕获。PathDeserializer的deserialize_tuple先核对捕获段个数、再按位置逐个喂值,deserialize_map按键值对喂,deserialize_struct直接转给deserialize_map(axum/src/extract/path/de.rs:153-209)
自定义 Deserializer 是 Rust 生态里少见但威力巨大的模式——《Serde 元编程》专栏第 4 章专门讨论过 Deserializer 与 Visitor 的配合,以及为什么它能同时支持 JSON、CBOR、YAML 这些格式各异的源数据。Path 这个例子让我们看到同一套 visitor pattern 也能消化"不是常规文本格式"的内存数据结构。
Option<Path<T>> 的特殊语义
axum/src/extract/path/mod.rs:192-213 里 OptionalFromRequestParts 的 impl 展示了一个精巧的语义:
// axum/src/extract/path/mod.rs:192-213(节选)
impl<T, S> OptionalFromRequestParts<S> for Path<T>
where T: DeserializeOwned + Send + 'static, S: Send + Sync,
{
type Rejection = PathRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Option<Self>, Self::Rejection> {
match parts.extract::<Self>().await {
Ok(Self(params)) => Ok(Some(Self(params))),
Err(PathRejection::FailedToDeserializePathParams(e))
if matches!(e.kind(), ErrorKind::WrongNumberOfParameters { got: 0, .. }) =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
}
三种结果:
- 正常匹配且反序列化成功 →
Ok(Some(value)) - 路径里没有捕获段(
WrongNumberOfParameters { got: 0 })→Ok(None) - 其他失败(捕获段存在但类型不对、UTF-8 无效等)→
Err(...)
这允许同一个 handler 复用在带参数和不带参数两条路由上:
async fn profile(user_id: Option<Path<u64>>) -> impl IntoResponse {
match user_id {
Some(Path(id)) => format!("user {id}"), // 查询指定用户
None => "current user".to_owned(), // 返回当前登录用户
}
}
Router::new()
.route("/me", get(profile)) // 命中 None 分支
.route("/users/{user_id}", get(profile)); // 命中 Some 分支
Option<Path<T>> 不处理"捕获段存在但格式错"的情况——那仍然是 Err。两者的语义差别我们在第 6 章的对比表里已经看到。
全捕获:HashMap、Vec、RawPathParams
当路径模板是 /users/{user_id}/posts/{post_id},handler 通常写 Path<(u64, u64)> 或 Path<Params>——位置或字段名绑定到类型。但有时你想不事先知道多少捕获段、都叫什么名字:
// 接受任意捕获段,键和值都是 String
async fn generic(Path(params): Path<HashMap<String, String>>) {
for (k, v) in params { /* ... */ }
}
// 或按位置收集成 Vec
async fn positional(Path(segs): Path<Vec<String>>) { /* ... */ }
Path 的 PathDeserializer 的 deserialize_map 和 deserialize_seq 分别处理这两种形态——前者把 &[(Arc<str>, PercentDecodedStr)] 的键值对全塞进 HashMap,后者只保留值、忽略键,按顺序塞进 Vec。这种"通配全捕获"在开发时很方便,但生产中建议落成具体类型或结构体——强类型能让 handler 签名自我文档化,减少"这个 key 叫啥"之类的含糊。
再进一步,如果你连 serde 都不想走——原始百分号解码后的字节串就够用——可以用 RawPathParams(axum/src/extract/path/mod.rs:501-525):
// 返回 &[(Arc<str>, PercentDecodedStr)] 的包装
async fn raw(params: RawPathParams) {
for (key, value) in ¶ms {
// key: &str, value: &str(已百分号解码)
}
}
RawPathParams 跳过 Deserializer 这一环——节省一次 serde visitor 往返。但别把它想成「借用 extensions 里的切片」:它拿的是 Ok(Self(params.clone()))(axum/src/extract/path/mod.rs:523),自己持有一个 Vec<(Arc<str>, PercentDecodedStr)>(axum/src/extract/path/mod.rs:501),所以仍有一次 Vec 分配加每项两次 Arc 计数(键和值各一次)。省掉的是 serde,不是分配。适合中间件里只想 peek 某个参数存在与否、不做类型转换的场景。三种变体的开销大致递减(前两者谁更贵取决于参数类型——数字字段不需要分配 String,下图只是示意):
flowchart LR
subgraph "开销大致递减(示意)→"
A["Path<Struct>\n自定义 Deserializer + serde visitor + 类型转换"]
B["Path<HashMap<String,String>>\n自定义 Deserializer + HashMap 构建"]
C["RawPathParams\nclone 一份键值对 Vec,不走 serde"]
end
A --> B --> C
工程里大多数 handler 用第一种——类型安全的收益远大于 serde 开销。但做"通用中间件"、"路由调试工具"这类场景,知道后两个变体能省下很多代码。
Query<T>:URL query 的 serde 化解析
Query 的 impl 干净利落,axum/src/extract/query.rs:43-53:
// axum/src/extract/query.rs:43-53
impl<T, S> FromRequestParts<S> for Query<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = QueryRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Self::try_from_uri(&parts.uri)
}
}
整个 impl 一行业务代码——把 URI 传给一个静态方法 try_from_uri。这个分离让 Query 在 Axum 外也能用:你在中间件里拿到 Uri 但没有 Parts,也可以调 Query::<T>::try_from_uri(&uri) 得到解析结果。
try_from_uri 的工作流
axum/src/extract/query.rs:78-86(主线;0.8.9 这里是 serde_urlencoded::Deserializer):
// axum/src/extract/query.rs:78-86
pub fn try_from_uri(value: &Uri) -> Result<Self, QueryRejection> {
let query = value.query().unwrap_or_default();
let deserializer =
serde_html_form::Deserializer::new(form_urlencoded::parse(query.as_bytes()));
let params = serde_path_to_error::deserialize(deserializer)
.map_err(FailedToDeserializeQueryString::from_err)?;
Ok(Self(params))
}
三步:
value.query().unwrap_or_default():从Uri拆出 query 部分——"?foo=1&bar=2"里的"foo=1&bar=2"。没有 query 时用空字符串,允许 handler 声明Query<T>但请求可能没带 query(T的所有字段必须都是Option或有默认值,否则反序列化会失败)serde_html_form::Deserializer::new(form_urlencoded::parse(...)):form_urlencoded负责 URL 解码,serde_html_form把解码出的键值对转成 serde visitor 事件。为什么是serde_html_form而不是serde_urlencoded?因为前者支持同名键出现多次作为 Vec——?tag=rust&tag=axum能反序列化成tag: Vec<String>,这是 HTML form 和 URL query 的实际需要。serde_urlencoded做不到:同名键落到Vec<String>字段报expected a sequence,落到String字段报duplicate field,只有目标是HashMap时才是后一个值覆盖前一个(0.8.9 实测)。版本注意:这次切换来自 #3594(cae6bc37),只在主线上;已发布的 0.8.9 里Query和Form仍用serde_urlencoded,它的文档让需要多值的用户改用axum_extra::extract::Queryserde_path_to_error::deserialize(...):这个 crate 在反序列化失败时把路径附加到错误里——错误消息会变成"n: invalid digit found in string",明确指出是n这个字段出错,而不是只说"反序列化失败"。测试用例axum/src/extract/query.rs:162-165验证了这条错误消息(断言响应体正是Failed to deserialize query string: n: invalid digit found in string)
多值字段与 serde_html_form 的工程细节
?tag=rust&tag=axum&tag=serde 能不能解析成 Vec<String>?这是 URL query 解析最常踩的坑——不同库的处理不一样。
serde_urlencoded(Rust 生态里的老牌 urlencoded crate,0.8.9 仍在用)不能把同名键收成 Vec,上一节已经列了它的几种报错;serde_html_form(主线用的,见 axum/Cargo.toml 的 query / form feature)保留所有值作为 Vec<T>。#3594 的提交只写了 "Use serde_html_form for Query and Form",没有展开理由;从效果看,后者更贴合"HTML form 和前端 URL 的实际用法"——比如筛选页 ?filter=a&filter=b 表达多选集合、搜索页 ?tag=rust&tag=axum 表达多个标签。对这类场景,serde_urlencoded 只能报错。
工程上几个常见用法:
#[derive(Deserialize)]
struct Filter {
#[serde(default)]
tag: Vec<String>, // ?tag=a&tag=b → vec;缺省时为空 Vec
page: Option<u32>, // ?page=2 → Some(2),缺省 None
#[serde(default = "default_size")]
size: u32, // 缺省时用 default_size()
}
fn default_size() -> u32 { 20 }
Vec<T>接收同名键的所有值;不加#[serde(default)]时,请求里一个tag都没有会报missing field(实测)Option<T>接收"字段可选"的单值——没带就是 None#[serde(default)]让字段缺失时走默认值而非报错- 字段带
#[serde(rename = "pageSize")]可以把 Rust 下划线命名映射到前端 camelCase / 短名
URL 编码有个陷阱:?tag=rust+web 里的 + 代表空格(historical HTML form 规定),?tag=rust%20web 里的 %20 也是空格——两种编码都被 form_urlencoded::parse 统一解码成一个空格。你在 handler 里拿到的 tag 就是 "rust web",不用关心客户端用了哪种编码。这种"编码层无感"是 form_urlencoded 这个 crate 的价值。
Query vs Path:同用 serde,不同路径
Query 走 serde_html_form(0.8.9 是 serde_urlencoded),Path 走自己的 PathDeserializer——为什么不统一?
根本原因是数据形态:
- Query 的源是 URL 里的 query string——字符串,已经是 urlencoded 格式,用现成的
serde_urlencoded家族自然 - Path 的源是 Router 的 matchit 结果——
Vec<(Arc<str>, PercentDecodedStr)>,已经被拆成结构化数据;如果先序列化回字符串再用serde_urlencoded反序列化,既浪费也容易引入编码 bug("/" 是不是要编码、UTF-8 处理等)
这是 Axum 对"序列化/反序列化层"的一个明显工程偏好:源数据已经是什么形态,就用匹配那个形态的 Deserializer,不强行走同一条字符串通路。这让每种情况都能精确控制错误信息的质量——Path 的错误能点到"哪个捕获段",Query 的错误能点到"哪个键"——而不是模糊的"反序列化失败"。
flowchart LR
subgraph Query
URI["parts.uri.query()\n'foo=1&bar=2'"] -->|form_urlencoded::parse| KV1["键值对迭代器"]
KV1 -->|serde_html_form::Deserializer| D1["serde visitor 事件"]
D1 -->|serde_path_to_error| T1["T::deserialize"]
end
subgraph Path
EXT["parts.extensions::<UrlParams>\n[(key, PercentDecodedStr)]"] -->|结构化数据| KV2["键值对切片"]
KV2 -->|PathDeserializer| D2["serde visitor 事件"]
D2 -->|基于位置/字段名分发| T2["T::deserialize"]
end
State<S>:FromRef 的纯转发
和 Path/Query 的重量级对比,State 的 impl 极其轻薄,axum/src/extract/state.rs:303-317:
// axum/src/extract/state.rs:303-317
impl<OuterState, InnerState> FromRequestParts<OuterState> for State<InnerState>
where
InnerState: FromRef<OuterState>,
OuterState: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(
_parts: &mut Parts,
state: &OuterState,
) -> Result<Self, Self::Rejection> {
let inner_state = InnerState::from_ref(state);
Ok(Self(inner_state))
}
}
一共七行实质代码,三件事:
_parts: &mut Parts——State 完全不看 parts。开头下划线告诉编译器(unused_variableslint)这是有意忽略的参数InnerState::from_ref(state)——调用第 6 章讨论的FromReftrait,从OuterState(Router 的 state 类型)抽出InnerState(handler 声明的类型)type Rejection = Infallible——永远不失败。原因是FromRef::from_ref(&T) -> Self的签名不返回Result,它是纯函数式转换,不可能失败
OuterState 与 InnerState 的类型推导
impl<OuterState, InnerState> FromRequestParts<OuterState> for State<InnerState> 里两个泛型:OuterState 是 Router 持有的 state 类型(比如 AppState),InnerState 是 handler 参数 State<T> 里的 T。约束是 InnerState: FromRef<OuterState>——编译器要能证明 T 可以从 &AppState 产出。
最简单情形:T = AppState 且 AppState: Clone 时,AppState 通过 FromRef 的 blanket impl 自动满足 FromRef<AppState>——AppState::from_ref(&app_state) 等价于 app_state.clone()。所以 State<AppState> 在 Router<AppState> 下总是合法的。
进阶情形:T = PgPool 且 Router state 是 AppState { db: PgPool, ... },需要手写或 derive impl FromRef<AppState> for PgPool { fn from_ref(s: &AppState) -> Self { s.db.clone() } }。编译器看到 State<PgPool> on Router<AppState> 会查找 PgPool: FromRef<AppState>——找不到就编译错误。
这种约束表达让第 6 章「FromRef:State 的子状态抽取」一节讲的子状态依赖成为可能:Router<AppState> 和 State<PgPool> 通过 FromRef<AppState> 的存在与否来编译期协商。
flowchart TD
ROUTER["Router<AppState>\nwith_state(AppState {...})"]
H1["handler1(State(db): State<PgPool>)"]
H2["handler2(State(config): State<Arc<Config>>)"]
H3["handler3(State(state): State<AppState>)"]
ROUTER -->|"调度 handler"| H1
ROUTER -->|"调度 handler"| H2
ROUTER -->|"调度 handler"| H3
H1 -->|"需要"| IMPL1["impl FromRef<AppState> for PgPool"]
H2 -->|"需要"| IMPL2["impl FromRef<AppState> for Arc<Config>"]
H3 -->|"需要"| IMPL3["impl<T: Clone> FromRef<T> for T (blanket)"]
IMPL1 -.->|"derive(FromRef)\n或手写"| DERIVE["#[derive(Clone, FromRef)]"]
IMPL2 -.->|"derive(FromRef)\n或手写"| DERIVE
State 这么薄的一个提取器能承载 Axum 整套子状态抽象,关键就在把工作外包给 FromRef——State 本身不知道怎么从 OuterState 抽 InnerState,它只负责调用 FromRef::from_ref。第 18 章讲 State 管理时会看到这个外包能发挥多深远——包括"库作者不知道应用的 state 结构,只要求 MyLibState: FromRef<S>"的通用模式。
Arc<Mutex> 共享可变状态
axum/src/extract/state.rs:256-296 官方示例给出了共享可变状态的标准 pattern:
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct AppState {
data: Arc<Mutex<String>>,
}
async fn handler(State(state): State<AppState>) {
let mut data = state.data.lock().expect("mutex was poisoned");
*data = "updated".to_owned();
}
几个要点:
AppState整体必须Clone——with_state后每次请求 clone 一份 state。Arc<Mutex<T>>是Clone(Arc::clone 原子加一),所以包装后的AppState自动 Clone。这是State的隐性契约:你的 state 字段都得 Clone 或者包在 Arc 里- 选
std::sync::Mutex还是tokio::sync::Mutex:前者是同步互斥、锁获得后阻塞线程;后者是异步、锁等待时让出 tokio 任务。跨 await 持有锁必须用 tokio 的——std::sync::Mutex持有时的 guard 不是Send,Future 会变成非Send,handler 不再满足Handlertrait(它要求 Future 是Send),编译失败(实测报the trait Handler<_, _> is not implemented)。持有期不跨 await 时两者都能用,std::sync::Mutex更轻 - Poisoning:
std::sync::Mutex::lock()返回Result,因为如果持锁线程 panic 会"毒化"Mutex,后续lock()返回Err(PoisonError)。.expect()直接 panic 是最简单选择——production 里可能想.unwrap_or_else(|e| e.into_inner())接管毒化状态继续工作
共享可变状态是 Rust Web 服务最常见的设计难点之一——既要并发安全又要避免过度锁。第 18 章会详讨论"什么状态应该放 State、什么应该放数据库、什么应该放 Extension"的选型原则。
嵌套 Router 的 state 类型收窄
Router 带一个泛型参数 Router<S>,S 是 state 类型。当你嵌套两个 Router(Router::nest / Router::merge)时,S 必须一致,否则编译失败。这条约束在 state 文档(axum/src/extract/state.rs:60-85)有详细示例,这里总结常见陷阱:
// 场景:外层 Router 有 AppState,内层 Router 是独立模块
fn make_api() -> Router<AppState> { // 关键:必须是 Router<AppState>
Router::new().route("/posts", get(posts_handler))
}
fn make_app() -> Router { // 无泛型 = Router<()>
let state = AppState { /* ... */ };
Router::new()
.nest("/api", make_api()) // 要求类型相容
.with_state(state) // 然后 with_state 注入
}
这里有两个微妙点:
1. Router 不写泛型时就是 Router<()>——"不需要外部 state"的终态。Router<S> 上调用 .with_state(s: S) 会把 S 消费掉;注意它的签名是 fn with_state<S2>(self, state: S) -> Router<S2>,返回类型的 S2 是自由的,由调用点推导——写在函数末尾、函数返回类型标成 Router 时,S2 就被定死为 ()
2. make_api() 必须返回 Router<AppState>——内层 Router 在被 .nest 时,类型系统要求它的 state 类型和外层一致(nest 的签名是 fn nest(self, path: &str, router: Self),参数就是 Self)。如果写成 Router<()>,nest 进去时编译器会报一个不太直观的"类型不匹配"错误。axum 自己的文档(axum/src/extract/state.rs:86-115)用的正是这个例子
这条嵌套约束强制了"模块化 Router 必须对 state 类型有明确声明"。它看起来有点烦——但它让"多人协作时,不同模块之间的 state 依赖关系在编译期可见"成为可能。某个模块突然引入了新的 state 要求,其他模块不会默默坏掉——它会在 nest 的那一行编译失败。
如果内层 Router 有自己的、和外层无关的 state,做法是在内层先 .with_state() 把它绑掉。axum 的 nest 文档(axum/src/docs/routing/nest.md)给的就是这个写法:
#[derive(Clone)]
struct InnerState {}
#[derive(Clone)]
struct OuterState {}
async fn inner_handler(state: State<InnerState>) {}
async fn outer_handler(state: State<OuterState>) {}
let inner_router = Router::new()
.route("/bar", get(inner_handler))
.with_state(InnerState {}); // 内层 state 在这里就绑定完了
let app = Router::new()
.route("/", get(outer_handler))
.nest("/foo", inner_router) // 能 nest 进来
.with_state(OuterState {});
Router<OuterState>::nest(path, inner_router) 为什么允许?关键还是 with_state<S2>(self, state: S) -> Router<S2> 里那个自由的 S2:inner_router 的类型没有被写死,编译器在 nest 那一行把它推导成 Router<OuterState>——内层的 InnerState 已经在 with_state 里绑进闭包,外层只是把它当一个 opaque 的路由挂在前缀上。
反过来,一旦你把内层写成 fn make_public_api() -> Router(即 Router<()>),S2 就被钉死成 (),推导链断掉,nest 到 Router<AppState> 里会直接类型不匹配。要么让这个函数返回 Router<S> 泛型,要么就别急着写成独立函数。
State 收窄这一段信息量大、容易踩坑。第 18 章「嵌套 Router 的 state 约束」一节会展开;这里先记住两点:nest / merge 要求两侧 state 类型相同,而 with_state 返回类型里那个自由的 S2 是让"内层自带 state 的子 Router"能被挂进外层的关键。
Deref 与 DerefMut 的副作用
axum/src/extract/state.rs:319-331 给 State<S> 实现了 Deref<Target = S> 和 DerefMut。这意味着 handler 里可以省略 .0:
async fn h(state: State<AppState>) {
// state.0.db / state.db 都能用(Deref)
let pool = &state.db; // 自动 Deref
}
Json<T>、Path<T>、Query<T>、Form<T> 走的是 axum-core 的 __impl_deref! 宏(axum-core/src/macros.rs:280-297,调用点在 axum/src/json.rs:149、axum/src/extract/path/mod.rs:155、axum/src/extract/query.rs:88、axum/src/form.rs:132),State<S> 则是在 axum/src/extract/state.rs:319-331 手写的同样两个 impl。效果一致——所有这些单字段元组提取器都自动 Deref / DerefMut 到内部类型。好处是读起来像直接用内部类型。要拿走所有权得用 .0 或模式解构——let s = *state; 会报 E0507(不能从解引用里移出非 Copy 值,实测);DerefMut 改的是提取器自己持有的那份值,对 State 来说就是本次请求 clone 出来的副本,不会影响 Router 里的原始 state。
Json<T>:消费 body 的典型 FromRequest
Json 是四个提取器里唯一消费 body 的,axum/src/json.rs:99-114:
// axum/src/json.rs:99-114
impl<T, S> FromRequest<S> for Json<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = JsonRejection;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if !json_content_type(req.headers()) {
return Err(MissingJsonContentType.into());
}
let bytes = Bytes::from_request(req, state).await?;
Self::from_bytes(&bytes)
}
}
三步工作流:
第一步:Content-Type 校验。json_content_type(axum/src/json.rs:138-147)检查头是否是 application/json 或带 +json 后缀(比如 application/vnd.api+json)。不是则返回 MissingJsonContentType,HTTP 状态码 415 Unsupported Media Type。这一步把"客户端送错类型"和"客户端送对类型但 JSON 格式错"分开——前者 415、后者 400——让客户端能快速分辨错误原因。
第二步:Bytes::from_request(req, state).await?。这一步委托给 Bytes 提取器把整个 body 缓冲进内存。Bytes::from_request 实现在 axum-core/src/extract/request_parts.rs,它会遵守 DefaultBodyLimit(第 6 章讲过)——默认 2MB 上限,超限返回 LengthLimitError(413,实测响应体 Failed to buffer the request body: length limit exceeded)。如果缓冲过程失败(网络中断、客户端提前断开),Axum 的 body 错误会被转成 BytesRejection,通过 ? 向上透传——Bytes::from_request 的 Rejection 类型和 Json::from_request 的 JsonRejection 之间通过 From 转换自动对接(JsonRejection 是 composite_rejection! 生成的 enum,包含 BytesRejection variant)。
第三步:Self::from_bytes(&bytes)。这是 Json 真正的反序列化,axum/src/json.rs:164-188 定义:
// axum/src/json.rs:164-188(节选)
pub fn from_bytes(bytes: &[u8]) -> Result<Self, JsonRejection> {
fn make_rejection(err: serde_path_to_error::Error<serde_json::Error>) -> JsonRejection {
match err.inner().classify() {
serde_json::error::Category::Data => JsonDataError::from_err(err).into(),
serde_json::error::Category::Syntax | serde_json::error::Category::Eof => {
JsonSyntaxError::from_err(err).into()
}
// ... IO 分类不可能发生,省略
}
}
// ... 调用 serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_slice(bytes))
}
看点是 把 serde_json 错误分成两类:Category::Syntax / Category::Eof 归为 JsonSyntaxError(400 Bad Request,"不是合法 JSON"),Category::Data 归为 JsonDataError(422 Unprocessable Entity,"JSON 合法但类型不对")。HTTP 422 最早是 RFC 4918 WebDAV 扩展里定义的状态码,后来收进了 RFC 9110 的通用语义,现代 API 普遍用它表示"请求体形式合法但语义不符合业务要求"。Axum 用 415 / 400 / 422 / 413 精细区分四种 body 问题:
| 客户端错误 | Axum 返回 | 语义 |
|---|---|---|
没发 Content-Type: application/json |
415 + MissingJsonContentType |
服务端不接受这种媒体类型 |
| JSON 语法错误 | 400 + JsonSyntaxError |
根本不是合法 JSON |
| JSON 合法但字段不对 | 422 + JsonDataError |
JSON OK,语义不符 |
| body 过大 | 413 + LengthLimitError |
超过 DefaultBodyLimit |
精细的 HTTP 状态码映射不是锦上添花——它直接决定客户端 / 网关 / 监控系统能否正确处理这些错误。400 和 422 在常见的 retry policy 下都不会被重试,差别不在重试上;但如果全都是 400,日志分类会极其困难。Axum 的这份状态码表值得写 RESTful API 时参照。
Option<Json<T>> 的细节
axum/src/json.rs:116-136 的 OptionalFromRequest impl:
// axum/src/json.rs:116-136
impl<T, S> OptionalFromRequest<S> for Json<T>
where T: DeserializeOwned, S: Send + Sync,
{
type Rejection = JsonRejection;
async fn from_request(req: Request, state: &S) -> Result<Option<Self>, Self::Rejection> {
let headers = req.headers();
if headers.get(header::CONTENT_TYPE).is_some() {
if json_content_type(headers) {
let bytes = Bytes::from_request(req, state).await?;
Ok(Some(Self::from_bytes(&bytes)?))
} else {
Err(MissingJsonContentType.into())
}
} else {
Ok(None)
}
}
}
三分支对应三种情况:
- 完全没
Content-Type→Ok(None)(请求不打算发 JSON,handler 自行处理缺失情况) - 有
Content-Type但不是 JSON →Err(MissingJsonContentType)(客户端想发别的格式,我们不接受) - 是 JSON → 正常走 body 缓冲 + 反序列化,成功
Ok(Some(value))、失败Err(...)
这让 handler 可以写 body: Option<Json<Payload>>——"如果请求带 body 就解析,否则 None"。GET 请求普遍无 body,典型应用是两种方法复用同一个 handler:
async fn search(Query(q): Query<Q>, body: Option<Json<Filter>>) { /* ... */ }
Router::new()
.route("/search", get(search)) // 通过 query 搜
.route("/search", post(search)); // 通过 body 精细过滤
但注意"Content-Type 对但 JSON 格式错"仍然是 Err——Axum 不会"静默吞掉"解析失败。这和第 6 章对比表里的语义严格一致:Option<T> 只降级"源不存在",不降级"源错误"。
Json 也是 IntoResponse
json.rs 里 Json<T> 不只是提取器,还是响应类型——impl<T: Serialize> IntoResponse for Json<T> 就在同一个文件里(axum/src/json.rs:197-232)。这意味着 handler 可以返回 Json(value) 作为响应:
// 调用侧用法示例,不是 axum 源码
async fn h() -> Json<User> { Json(user) }
响应时自动设 Content-Type: application/json,body 是把值 serde_json::to_writer 进一个初始容量 128 字节的 BytesMut 得到的字节串(源码注释说明这个 128 是对齐 serde_json::to_vec 的做法)。序列化失败(极罕见,比如 key 是元组这类没法变成 JSON 字符串键的 HashMap)会返回 500。第 9 章讲 IntoResponse 时会详讨论。
这是 Axum 的一个美学原则:同一个类型既是提取器也是响应——Json、Form、Bytes、String 都如此(Html 只实现了 IntoResponse,不是提取器,axum/src/response/mod.rs:39)。对称性让代码读起来像"数据来源 ↔ 数据去向"的自然映射,handler 签名直接表达"接收 JSON,返回 JSON"。
响应侧的错误处理比提取侧简单:into_response 只看 serde_json::to_writer 的返回值,Ok(()) 就发 200 + application/json,Err(err) 就发 500 + text/plain; charset=utf-8,body 是 err.to_string()(同时打上一个内部的 IntoResponseFailed 标记扩展——这个标记只在主线上有,不在已发布的 0.8.9 里,见第 9 章)。什么时候会 Err?极少数情况下 serde::Serialize::serialize 会主动返回错误,比如 HashMap<(u8, u8), V> 序列化为 JSON 时——JSON 要求 object key 是字符串,serde_json 能把整数、布尔这类 key 转成字符串,元组、结构体就不行(实测 500,响应体 key must be a string;HashMap<u32, _> 则正常 200)。写入目标是内存里的 BytesMut,所以 IO 类错误在这条路径上不会发生。
(对称的一处细节在提取侧:Json::from_bytes 的 make_rejection 要对 serde_json 的错误分类做穷尽匹配,其中 Category::Io 分支在 debug 下直接 unreachable!()、release 下退化成 JsonSyntaxError——因为 axum 总是先把 body 缓冲成字节再解析,不走 from_reader。)
序列化失败是服务端的 bug(类型设计不当),所以 500 比 400 合适——4xx 暗示客户端问题,5xx 才是服务端问题。这是 Axum 把 HTTP 语义做细的又一例。
serde_path_to_error 的错误精度
Json、Query(还有下文的 Form)都用了 serde_path_to_error 包装 Deserializer;Path 没用它,靠 PathDeserializer 自己的 ErrorKind(ParseErrorAtKey / ParseErrorAtIndex 带上键名或下标)定位。serde_path_to_error 的价值在嵌套结构体场景下最明显。看两种错误消息对比:
假设目标类型:
#[derive(Deserialize)]
struct CreateUser {
profile: Profile,
}
#[derive(Deserialize)]
struct Profile {
email: String,
age: u32,
}
客户端发 {"profile": {"email": "a@b.com", "age": "invalid"}}——age 是字符串而不是数字。不用 serde_path_to_error 的错误:
invalid type: string "invalid", expected u32 at line 1 column 49
用了 serde_path_to_error:
profile.age: invalid type: string "invalid", expected u32 at line 1 column 49
(axum 返回的响应体还会在前面加上 Failed to deserialize the JSON body into the target type: ,状态码 422,实测。)前缀 profile.age 让客户端和日志系统一眼看出哪个字段出错。在深层嵌套(order.items[3].metadata.color: ...)时效果更明显——错误定位从"反序列化失败"变成"具体字段失败",修复路径清晰。
serde_path_to_error 不改变反序列化逻辑,只是在 visitor 协议每次下降进子结构时,在调用栈上挂一个记录当前 key / index 的 Chain 节点(节点通过引用指向父节点),出错时顺着链拼回路径。成本不全是零:map / 结构体的每个 key 都会被复制成一个 String 存进节点,成功路径上也有这笔小分配(serde_path_to_error 0.1.20 的 CaptureKey、Chain::Map { key: String });拼接路径字符串只在出错时发生。这类"只增强错误、不改变语义"的 crate 是 Rust 生态里的一种典型工具层——Axum 大量使用它们,也是用户体验倾斜的又一例。
Form 提取器:Json 的 urlencoded 兄弟
axum/src/form.rs 定义了 Form<T>——结构上和 Json<T> 高度对称,只是媒体类型和反序列化器换成 urlencoded(下面是主线源码,0.8.9 用的是 serde_urlencoded):
// axum/src/form.rs:73-98(节选)
impl<T, S> FromRequest<S> for Form<T>
where T: DeserializeOwned, S: Send + Sync,
{
type Rejection = FormRejection;
async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
let is_get_or_head =
req.method() == http::Method::GET || req.method() == http::Method::HEAD;
match req.extract().await {
Ok(RawForm(bytes)) => {
let deserializer =
serde_html_form::Deserializer::new(form_urlencoded::parse(&bytes));
let value = serde_path_to_error::deserialize(deserializer)
.map_err(|err| -> FormRejection {
if is_get_or_head {
FailedToDeserializeForm::from_err(err).into()
} else {
FailedToDeserializeFormBody::from_err(err).into()
}
})?;
Ok(Self(value))
}
// ... 把 RawFormRejection 映射成 FormRejection
}
}
}
"从哪拿字节"这件事 Form 自己不做,而是委托给另一个提取器 RawForm(axum/src/extract/raw_form.rs:38-52):
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if req.method() == Method::GET {
if let Some(query) = req.uri().query() {
return Ok(Self(Bytes::copy_from_slice(query.as_bytes())));
}
Ok(Self(Bytes::new()))
} else {
if !has_content_type(req.headers(), &mime::APPLICATION_WWW_FORM_URLENCODED) {
return Err(InvalidFormContentType.into());
}
Ok(Self(Bytes::from_request(req, state).await?))
}
}
几个和 Json 对比的有趣差别:
- Form 对 HTTP 方法敏感:只有
GET从 query string 提取(相当于 Query 的等价实现),包括HEAD在内的其他方法都走 body 分支(相当于 Json 的等价实现)。这对应传统 HTML form 的两种提交方式:<form method="get">把字段拼进 URL、<form method="post">放进 body。注意Form里那个is_get_or_head标志只用来挑选 rejection 的种类(FailedToDeserializeFormvsFailedToDeserializeFormBody),不参与"从哪读字节"的判断——真正的分流在RawForm里,而它只认GET - Content-Type 必须是
application/x-www-form-urlencoded(非 GET 场景) - Reuse 同一个 Deserializer (
serde_html_form)——无论数据源是 query 还是 body,urlencoded 格式相同(与 Query 一样,0.8.9 里这里仍是serde_urlencoded)
Form 和 Json / Query 在 axum 内部构成一个三角关系:
flowchart TD
subgraph "源数据形态"
QS["URL query string\n?k=v&k=v"]
JB["body: application/json\n{...}"]
FB["body: application/x-www-form-urlencoded\nk=v&k=v"]
end
subgraph "提取器"
Q["Query<T>"]
J["Json<T>"]
F["Form<T>"]
end
subgraph "Deserializer"
SH1["serde_html_form\n0.8.9: serde_urlencoded"]
SJ["serde_json"]
SH2["serde_html_form\n0.8.9: serde_urlencoded"]
end
QS --> Q --> SH1
JB --> J --> SJ
FB --> F --> SH2
这是 Axum 内置提取器的完整"serde 化"全景:Path / Query / Json 三个提取器加上 Form 共享同一套"数据源 → Deserializer → T::deserialize"模式,差别仅在每种源数据用的 Deserializer。这种模式让第三方也很容易实现:想支持 MessagePack?写个 Msgpack<T> 走 rmp-serde,复用 Bytes::from_request + Content-Type 校验的模板。
扩展 Json:做一个 ValidatedJson
看一个实际场景的扩展——基于 Json 做一个带输入校验的 ValidatedJson<T>。Web 后端几乎每个 JSON 接口都需要"反序列化 OK,但还要 check 业务规则"(邮箱格式、密码长度、枚举合法等)。把这段 check 塞进每个 handler 是 bug 温床,放进提取器更好。
use axum::extract::{FromRequest, Request};
use axum::Json;
use serde::de::DeserializeOwned;
use validator::Validate;
pub struct ValidatedJson<T>(pub T);
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
// 先走 Json 的正常反序列化
let Json(payload) = Json::<T>::from_request(req, state)
.await
.map_err(AppError::Json)?;
// 再跑 validator 校验
payload.validate().map_err(AppError::Validation)?;
Ok(Self(payload))
}
}
AppError 是自定义错误类型,包含 Json(JsonRejection) 和 Validation(ValidationErrors) 两个 variant,各自映射到合适的 HTTP 响应(422 Unprocessable Entity 是 validation error 的常用选择)。
用户侧的 handler:
#[derive(Deserialize, Validate)]
struct CreateUser {
#[validate(email)]
email: String,
#[validate(length(min = 8))]
password: String,
}
async fn create(ValidatedJson(payload): ValidatedJson<CreateUser>) {
// payload 已经反序列化且 validation 通过
}
这是第 6 章讲的"提取器可以组合"的典型应用——ValidatedJson 内部复用 Json::from_request,不重复写一遍 body 缓冲和 content-type 校验。只在那层基础上加业务校验。这种"内层提取器复用"让 Axum 生态有极强的可扩展性——你看到的每个第三方提取器 crate(axum-extra、axum-valid、axum-jsonschema)几乎都是这种模式。
第 20 章讲 axum-extra 时会看到另一种组合——TypedHeader<Authorization<Bearer>> 里只有外层的 TypedHeader 是提取器,Authorization<Bearer> 是 headers crate 的类型化 header,TypedHeader 负责把 headers::Header 桥接到 FromRequestParts。
更多 parts-only 提取器:生态补充
Path / Query / State 是用得最多的 parts-only 提取器,但 axum 生态里还有一批有用的"小工具"提取器,都走 FromRequestParts,适合了解一下,随手能用。
| 提取器 | 出处 | 用途 |
|---|---|---|
Method, Uri, Version, HeaderMap |
类型来自 http crate,FromRequestParts impl 在 axum-core/src/extract/request_parts.rs:19、30、41、57 |
原始 HTTP 信息,最底层 |
Extension<T> |
pub struct Extension<T>(pub T),axum/src/extension.rs:72(注意在 extension 模块,不在 extract 下) |
从 req.extensions 取类型为 T 的值(中间件注入) |
MatchedPath |
axum::extract::MatchedPath,axum/src/extract/matched_path.rs:57(内部是 Arc<str>,字段私有) |
当前匹配到的路径模板(如 /users/{id},不是真实 URI) |
OriginalUri |
axum::extract::OriginalUri,axum/src/extract/original_uri.rs:68 |
没经过 nest 前缀切除的原始 URI |
NestedPath |
axum::extract::NestedPath,axum/src/extract/nested_path.rs:40 |
嵌套 Router 的当前前缀 |
ConnectInfo<T> |
axum::extract::ConnectInfo,axum/src/extract/connect_info.rs:136 |
底层 TCP 连接信息(远端地址等) |
TypedHeader<T> |
axum_extra,axum-extra/src/typed_header.rs:55 |
类型安全的 header 提取(Authorization、ContentType) |
其中 MatchedPath 特别有用——日志 / Tracing 打 span 时不想把 /users/42、/users/43 当成两个 span,而是统一成 /users/{id} 这个路径模板。从 MatchedPath 取值就能做这类聚合。OriginalUri 在 nest 场景下关键——Router::nest("/api", sub_router) 之后,sub_router 里的 handler 拿到的 Uri 已经切掉了 /api 前缀,想看原始 URI 得走 OriginalUri。
Extension<T> 和 State<T> 看起来像——都是"拿某个全局值"——差异是:State 是 Router 级绑定、编译期类型约束;Extension 是运行时 extensions HashMap 查找、没编译期保证。两者职责:Router 配置阶段知道的、跨整个 Router 稳定不变的数据用 State;中间件在 call 时往 extensions 里塞的"请求级别"数据用 Extension。第 13 章讲中间件时会看 Extension<User> 和认证中间件配合的典型用法。
开销剖析:四个提取器各自贵在哪
做个收尾——每个提取器的开销来自哪里、随什么增长?
| 提取器 | 主要开销来源 | 随什么增长 |
|---|---|---|
State<T> |
FromRef::from_ref 的一次 clone |
取决于子 state 的 Clone 成本;包 Arc 时是一次原子加一,是四者里最轻的 |
Path<T> |
extensions 查找 + PathDeserializer 的 visitor 往返 + 类型转换 |
捕获段个数 |
Query<T> |
parts.uri.query() + form_urlencoded 解码 + serde_html_form(0.8.9:serde_urlencoded)visitor |
query 字段个数与字符串长度 |
Json<T> |
Bytes::from_request 缓冲整个 body + serde_json 反序列化 |
body 字节数,线性 |
结构上的结论很清楚:State / Path / Query 的成本都被"参数个数"这个小常数框住,而 Json 的成本随 body 字节数线性增长——它既要等 body 读完(真实 IO 等待),又要做整段反序列化。所以调优的第一杠杆是控制 body 大小:收紧 DefaultBodyLimit、让 API 拒绝冗余字段、大对象改走分页或流式,而不是去抠 Path/Query/State 的常数。
想知道在你自己的机器和负载下这些数字究竟是多少,用 criterion 对目标 handler 做基准测试——脱离具体 CPU、编译选项和 payload 形态的绝对数字没有参考价值。
综合实战:一个多提取器的 handler
最后把四个提取器都用上,写一个 "更新文章" 的 handler,同时展示 state 设计:
#[derive(Clone, FromRef)]
struct AppState {
db: PgPool,
analytics: Arc<AnalyticsClient>,
}
#[derive(Deserialize)]
struct UpdateOptions {
notify: Option<bool>, // query: ?notify=true
}
#[derive(Deserialize)]
struct UpdatePayload {
title: Option<String>,
body: Option<String>,
}
// PUT /posts/{post_id}?notify=true
// Content-Type: application/json
// {"title":"...","body":"..."}
async fn update_post(
State(db): State<PgPool>, // 子 state
State(ana): State<Arc<AnalyticsClient>>, // 另一个子 state
Path(post_id): Path<u64>, // URL 捕获段
Query(opts): Query<UpdateOptions>, // query 参数
Json(payload): Json<UpdatePayload>, // body(最后)
) -> Result<StatusCode, AppError> {
let updated = db.update_post(post_id, &payload).await?;
if opts.notify.unwrap_or(false) {
ana.record("post_updated", updated.id).await;
}
Ok(StatusCode::NO_CONTENT)
}
几件值得注意的事:
- 两个
State(...)并列:同一个 handler 可以多次State<T>,分别抽取AppState的不同字段。#[derive(FromRef)]为每个字段生成FromRef<AppState>impl,编译器按类型自动分发 - 参数顺序:State 靠前,Path / Query 次之,Json 最后。类型系统实际只要求"Json 是最后一个",但按依赖广度排序读起来更自然
Result<StatusCode, AppError>:返回Result让业务错误用?短路——AppError: IntoResponse负责映射到响应。这是第 12 章详细讨论的错误处理模型的一个预览- 每个提取器失败的 HTTP 码不一样:Path 失败(
post_id非数字)→ 400;Query 失败(notify不是合法布尔)→ 400;Json 失败(Content-Type 错)→ 415、语法错 → 400、字段错 → 422。客户端不读响应 body 也能分出错误的大类,具体是哪个提取器、哪个字段还得看 body
这个 handler 几乎就是真实生产代码的典型形态——多 state 子依赖 + 路径参数 + 查询选项 + body。理解了 Path/Query/State/Json 四个提取器,绝大多数 handler 的参数需求就都能覆盖了。剩下的少数场景是下一章要讲的高级提取器——WebSocket、Multipart、ConnectInfo,它们每个都有独特的协议或运行时要求。
设计哲学小结
把本章讨论的四个提取器放一起看,Axum 设计 built-in extractor 的几条核心原则浮出水面:
一、每个提取器尽量薄,复杂度下沉到第三方 crate。Query 把 urlencoded 解析交给 form_urlencoded + serde_urlencoded(主线 #3594 起换成 serde_html_form);Json 把 body 缓冲交给 Bytes::from_request、反序列化交给 serde_json;State 把 state 抽取交给 FromRef。Axum 自己尽量不重写通用能力(Path 是个例外,它有自己的 Deserializer),主要做"从 HTTP 请求到这些工具的粘合"。这让提取器代码可读且稳定——Query::from_request_parts 的实质一行代码,变动空间极小。
二、错误信息优先。会失败的三个提取器的 Rejection 都精心映射 HTTP 状态码(Json 的 415/400/422/413 四分);Query / Json 用 serde_path_to_error、Path 用自己的 ErrorKind 让错误指向具体字段;FromRequestParts / FromRequest 两个 trait 上的 #[diagnostic::on_unimplemented] 让编译错误指向"extract 文档"。这套工程在每处都是小成本但累积起来让 Axum 的用户体验大幅领先一般框架。
三、类型系统驱动的组合规则。FromRequestParts / FromRequest / OptionalFromRequest / FromRef 这一套 trait 不是给用户看的 API——它们是 Axum 用来表达"提取器之间的组合约束"的编译期工具。"最后一个参数才能是 FromRequest"不是文档约定,而是 impl_handler 宏展开后类型系统直接拒绝;"State<T> 需要 T: FromRef<S>"不是运行时检查,而是 impl 约束。这套"约束显式化"的设计让 handler 签名本身成了一份编译期检查过的契约——提取器的顺序、state 的类型对不上,都在编译期暴露。它管不到的是运行时才知道的事:Path 元组个数与路由模板不符、Extension 没被中间件插入,仍然要到请求来了才以 500 报出来。
四、对称性。Json / Form / Bytes / String 都同时是提取器和响应——signature 对称、代码对称、心智模型对称。第 9 章讲 IntoResponse 时会看到这份对称是如何通过一对 trait 系统化实现的。
这四条原则从内置提取器延伸到整个 Axum 的 API 设计——第 13 章的中间件、第 15 章的 Serve 都循着相似轨迹。一旦抓住"薄、错误优先、类型约束、对称"这四个词,读 axum 后续章节时会发现每个决策都能被这四个词解释。
四个提取器一览
把四个提取器的关键属性放在一起对比:
| 属性 | Path<T> |
Query<T> |
State<S> |
Json<T> |
|---|---|---|---|---|
| trait | FromRequestParts |
FromRequestParts |
FromRequestParts |
FromRequest |
| 数据源 | parts.extensions::<UrlParams> |
parts.uri.query() |
调用方传入的 &OuterState |
req.body() |
| 前置依赖 | Router 匹配时写入 extensions | 无 | InnerState: FromRef<OuterState> |
Content-Type: application/json |
| Deserializer | 自定义 PathDeserializer |
serde_html_form(0.8.9:serde_urlencoded) |
无(直接 clone) | serde_json |
| 错误定位 | 自有 ErrorKind(带键名 / 下标) |
serde_path_to_error |
不会错 | serde_path_to_error |
| Rejection 类型 | PathRejection |
QueryRejection |
Infallible |
JsonRejection |
| 典型失败 HTTP 码 | 400(类型/UTF-8 错);500(捕获段个数不匹配、类型不受支持) | 400 | 不可能 | 415 / 400 / 422 / 413 |
| 可在参数位置 | 任何位置 | 任何位置 | 任何位置 | 仅最后一个 |
有 Option<T> 语义 |
是(无捕获段时 None) |
否(用 Result) |
否 | 是(无 Content-Type 时 None) |
最后一行值得展开。Path 和 Json 都实现了 OptionalFromRequestParts / OptionalFromRequest,允许 handler 写 Option<Path<T>> / Option<Json<T>>,语义是"源数据存在与否"的感知:
Option<Path<T>>:路由没带捕获段时None;带了但类型错还是ErrOption<Json<T>>:请求没带Content-Type时None;带了但格式错还是Err(axum/src/json.rs:116-136)
Query 和 State 没有 Option 版本,因为它们的源总是存在(空 query 合法、state 总是存在),Option 无意义。想让 Query 可选,用 Query<T> 其中 T 的字段都是 Option——但这是 T 层面的选择,不是提取器层面的。
提取器在 handler 参数列表里的自由组合
第 6 章论证过"除最后一个外全都是 FromRequestParts,最后一个是 FromRequest"的规则。这四个提取器在这条规则下有具体的组合模式:
// 最常见:路径 + body
async fn create(Path(id): Path<u64>, Json(payload): Json<Payload>) { /* ... */ }
// 路径 + query + state + body(最后一个必须是 body 消费型)
async fn search(
State(db): State<PgPool>,
Path(owner): Path<String>,
Query(filter): Query<Filter>,
Json(body): Json<Body>,
) { /* ... */ }
// 全 parts-only:无 body
async fn list(
State(db): State<PgPool>,
Path(owner): Path<String>,
Query(filter): Query<Filter>,
) { /* ... */ }
// 只有 body
async fn upload(Json(data): Json<Payload>) { /* ... */ }
最后一个"只有 body"的例子展示 Json 作为唯一参数时的合法性——它是"最后一个参数"的特例,也是第一个。这条路径在 impl_handler! 宏的 1-参数展开里直接处理。
Path / Query / State 之间的顺序可以任意,因为它们都是 FromRequestParts,互不抢占资源。一条工程经验是"让逻辑上更外层的 state 更靠前":State<PgPool> 比 Path<u64> 更"全局",前者写在前面让签名读起来像"以这个依赖为上下文、处理这个具体请求"。但这是可读性习惯,不是类型系统约束。
跨专栏关联:Deserializer 的多形态
这四个提取器里三个用了 serde——Path / Query / Json 各自搭配不同的 Deserializer。这是《Serde 元编程》专栏第 4 章讲 Deserializer 与 Visitor 协议的绝佳落地:
- Json 用
serde_json::Deserializer::from_slice——典型字节流 Deserializer,按 JSON 语法 token 化 - Query 用
serde_html_form::Deserializer(0.8.9 是serde_urlencoded::Deserializer)——键值对迭代器驱动的 Deserializer,把k=v&k=v包装成 visitor 事件 - Path 用 Axum 自己的
PathDeserializer——直接吃&[(Arc<str>, PercentDecodedStr)]的内存结构
三个 Deserializer 背后的源数据形态完全不同(JSON 文本 / urlencoded 字符串 / 结构化数组),但对 T: DeserializeOwned 而言完全透明——T 类型自己只知道"有人在按 serde visitor 协议给我喂数据",不关心喂数据的人从哪来、怎么拿。这是 serde Deserializer trait 的核心威力——反序列化的目标类型和源数据格式彻底解耦。
第 8 章讲高级提取器(WebSocket、Multipart、ConnectInfo)时我们会看到 axum 在非 serde 场景下的提取器实现——WebSocket 不走 serde(它消费的是升级握手 + 字节帧),Multipart 自己实现流式解析,ConnectInfo 从 serve 建立连接时写进 extensions 的连接信息里取。这些 case 展示了提取器框架的灵活性:并不强制走 serde,trait 本身只关心"能从 Request/Parts 产出 Self"。
下一章我们就深入这些"非 serde"提取器,看 Axum 如何把"WebSocket 升级"这样的协议级能力也塞进同一套提取器框架。