Appearance
第7章 内置提取器:Path、Query、State、Json
第 6 章把 FromRequestParts 与 FromRequest 的机制讲透了——借用 &mut Parts 还是消费 Request、ViaParts 与 ViaRequest 如何让一致性规则放行、Rejection 为什么必须 IntoResponse。机制是钢筋骨架,这一章要看的是墙和屋顶——Axum 最常用的四个内置提取器如何把骨架填满,各自在真实工程里承担什么职责。
四个提取器按与 body 的关系天然分成两组:
Path / Query / State 在 handler 参数列表的任何位置都能放;Json 只能放最后。这条规则上一章讲过,这一章会看到它在每个提取器的 trait impl 里是怎么落地的。
四个提取器的 crate 归属也有讲究:State 是 axum-core 直接导出(加上 FromRef 配合)、Json 定义在 axum crate 的根 axum/src/json.rs(不在 extract 子模块里,因为它同时是 response)、Path 和 Query 在 axum::extract 子模块下。这种分布有历史原因——也有工程考虑:把"可能被第三方提取器 crate 复用"的 trait(FromRef、FromRequestParts)下沉到 axum-core,保证最小依赖;把"只在应用层有用"的具体提取器留在 axum 主 crate。读者从 use axum::extract::Path; 反查出处,能一眼看到这层分层。
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-178 的 get_params 函数展示了 Path 怎么从 extensions 里取:
rust
// axum/src/extract/path/mod.rs:166-178
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>共享(同一 handler 多次调用路径模板相同,Arc避免重复 clone),值用PercentDecodedStr已完成 URL 解码Some(UrlParams::InvalidUtf8InPathParam { key }):Router 匹配了但百分号解码后不是合法 UTF-8——比如/users/%C0%C0。这种情况下 Router 也把 key 放进 extensions(为了后续能报哪个参数的错),由 Path 提取器生成400 Bad RequestNone:extensions 里根本没有UrlParams,意味着当前路由不是通过有捕获段的 template 到达的。典型情形是 handler 挂在静态路径上但又声明了Path<T>参数——配置错误,返回MissingPathParams
这条链的关键是"解耦":Router 只负责产出匹配结果并放进 extensions,不直接调用提取器;Path 只负责从 extensions 读并反序列化,不关心匹配是怎么做的。两者通过一个共享类型 UrlParams 通信,这是第 6 章讲的"extensions 作为提取器间通信通道"的最典型案例。
自定义 serde Deserializer:PathDeserializer
Path 不直接用 serde_urlencoded 或 serde_json——它有自己的 Deserializer,定义在 axum/src/extract/path/de.rs。mod.rs:185 调用点:
rust
// 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_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_struct、deserialize_map三个方法里分别走不同的映射逻辑
自定义 Deserializer 是 Rust 生态里少见但威力巨大的模式——《Serde 元编程》第 3 章专门讨论过 Deserializer trait 的 visitor pattern,以及为什么它能同时支持 JSON、CBOR、YAML 这些格式各异的源数据。Path 这个例子让我们看到同一套 visitor pattern 也能消化"不是常规文本格式"的内存数据结构。
Option<Path<T>> 的特殊语义
mod.rs:192-215 里 OptionalFromRequestParts 的 impl 展示了一个精巧的语义:
rust
// axum/src/extract/path/mod.rs:192-215(节选)
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 复用在带参数和不带参数两条路由上:
rust
async fn profile(user_id: Option<Path<u64>>) -> impl IntoResponse {
match user_id {
Some(Path(id)) => /* 查询指定用户 */,
None => /* 返回当前登录用户 */,
}
}
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>——位置或字段名绑定到类型。但有时你想不事先知道多少捕获段、都叫什么名字:
rust
// 接受任意捕获段,键和值都是 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:503-518):
rust
// 返回 &[(Arc<str>, PercentDecodedStr)] 的包装
async fn raw(params: RawPathParams) {
for (key, value) in ¶ms {
// key: &str, value: &str(已百分号解码)
}
}RawPathParams 直接把 extensions 里的 UrlParams::Params 包装返回,跳过 Deserializer 这一环——节省一次 serde visitor 往返。适合中间件里只想 peek 某个参数存在与否、不做类型转换的场景。三种变体的开销递减:
工程里大多数 handler 用第一种——类型安全的收益远大于 serde 开销。但做"通用中间件"、"路由调试工具"这类场景,知道后两个变体能省下很多代码。
Query<T>:URL query 的 serde 化解析
Query 的 impl 干净利落,axum/src/extract/query.rs:43-53:
rust
// 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 的工作流
query.rs:78-86:
rust
// 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把k=v&k=v字符串转成 serde visitor 事件。为什么是serde_html_form而不是serde_urlencoded?因为前者支持同名键出现多次作为 Vec——?tag=rust&tag=axum能反序列化成tags: Vec<String>,这是 HTML form 和 URL query 的实际需要。serde_urlencoded只保留同名键的最后一次赋值serde_path_to_error::deserialize(...):这个 crate 在反序列化失败时把路径附加到错误里——错误消息会变成"n: invalid digit found in string",明确指出是n这个字段出错,而不是只说"反序列化失败"。测试用例query.rs:161-164验证了这条错误消息
多值字段与 serde_html_form 的工程细节
?tag=rust&tag=axum&tag=serde 应该解析成 Vec<String>,还是只保留最后一个 tag=serde?这是 URL query 解析最常踩的坑——两种行为都合理,不同库的选择不一样。
serde_urlencoded(rustc、hyper 都在用的老牌 crate)只保留最后一个值;serde_html_form(axum 0.8 用的)保留所有值作为 Vec<T>。Axum 选后者是因为它更贴合"HTML form 和前端 URL 的实际用法"——比如筛选页 ?filter=a&filter=b 表达多选集合、搜索页 ?tag=rust&tag=axum 表达多个标签。serde_urlencoded 的行为对这类场景是 bug。
工程上几个常见用法:
rust
#[derive(Deserialize)]
struct Filter {
tag: Vec<String>, // ?tag=a&tag=b → vec!["a","b"]
page: Option<u32>, // ?page=2 → Some(2),缺省 None
#[serde(default = "default_size")]
size: u32, // 缺省时用 default_size()
}
fn default_size() -> u32 { 20 }Vec<T>接收同名键的所有值Option<T>接收"字段可选"的单值——没带就是 None#[serde(default)]让字段缺失时走默认值而非报错- 字段带
#[serde(rename = "page_size")]可以把 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,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 的错误能点到"哪个键"——而不是模糊的"反序列化失败"。
State<S>:FromRef 的纯转发
和 Path/Query 的重量级对比,State 的 impl 极其轻薄,axum/src/extract/state.rs:303-317:
rust
// 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。开头下划线告诉 clippy 这是有意忽略的参数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 章提到的"签名驱动的子状态依赖"成为可能:Router<AppState> 和 State<PgPool> 通过 FromRef<AppState> 的存在与否来编译期协商。
State 这么薄的一个提取器能承载 Axum 整套子状态抽象,关键就在把工作外包给 FromRef——State 本身不知道怎么从 OuterState 抽 InnerState,它只负责调用 FromRef::from_ref。第 18 章讲 State 管理时会看到这个外包能发挥多深远——包括"库作者不知道应用的 state 结构,只要求 MyLibState: FromRef<S>"的通用模式。
Arc<Mutex> 共享可变状态
state.rs:256-296 官方示例给出了共享可变状态的标准 pattern:
rust
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,axum 的 multi-thread runtime 编译失败。持有期不跨 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 文档(state.rs:60-85)有详细示例,这里总结常见陷阱:
rust
// 场景:外层 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。.with_state(state) 之后才变 Router<()>(被消费、变成"state 已绑定"的最终 Router)
2. make_api() 必须返回 Router<AppState>——内层 Router 在被 .nest 时,类型系统要求它的 state 类型是 AppState。如果写成 Router<()>,nest 进去时编译器会报一个不太直观的"类型不匹配"错误
这条嵌套约束强制了"模块化 Router 必须对 state 类型有明确声明"。它看起来有点烦——但它让"多人协作时,不同模块之间的 state 依赖关系在编译期可见"成为可能。某个模块突然引入了新的 state 要求,其他模块不会默默坏掉——它会在 nest 的那一行编译失败。
对于 state 完全独立的子 Router,可以用 .with_state() 提前绑定:
rust
fn make_public_api() -> Router { // 不带 state 的独立子 Router
Router::new()
.route("/health", get(|| async { "ok" }))
.with_state(()) // 提前绑 state, 得到 Router<()>
}
fn make_app() -> Router {
Router::new()
.nest("/public", make_public_api()) // 直接 nest,state 已经解决
.route("/data", get(data_handler))
.with_state(AppState { /* ... */ })
}Router<AppState>::nest(path, Router<()>) 为什么允许?因为内层 Router 已经 .with_state(())——state 生命周期已在内层结束,外层只是把它当一个 opaque 的路由挂在某个前缀上。这是 Router::nest 的一个宽容之处——同类型或"已绑定"的子 Router 都可以 nest 进来。
State 收窄这一段信息量大、容易踩坑。第 18 章讲完整 State 管理时会有一张 mermaid 图展示整个嵌套路径;这里先知道"Router<()> 是已绑定终态、Router<AppState> 是等待绑定态、两者在 nest/merge 有不同的类型要求"就够了。
Deref 与 DerefMut 的副作用
state.rs:319-331 给 State<S> 实现了 Deref<Target = S> 和 DerefMut。这意味着 handler 里可以省略 .0:
rust
async fn h(state: State<AppState>) {
// state.0.db / state.db 都能用(Deref)
let pool = &state.db; // 自动 Deref
}这是 Axum 给 State<T>、Json<T>、Path<T>、Query<T>、Form<T> 统一的 __impl_deref! 宏(query.rs:88、json.rs:149、path/mod.rs:155)——所有单字段元组提取器都自动 Deref 到内部类型。好处是读起来像直接用内部类型;成本是 *state 能拿走所有权,DerefMut 能修改——如果 T 不希望被修改,要注意这层访问。
Json<T>:消费 body 的典型 FromRequest
Json 是四个提取器里唯一消费 body 的,axum/src/json.rs:99-114:
rust
// 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(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 上限,超限返回 PayloadTooLarge。如果缓冲过程失败(网络中断、客户端提前断开),Axum 的 body 错误会被转成 BytesRejection,通过 ? 向上透传——Bytes::from_request 的 Rejection 类型和 Json::from_request 的 JsonRejection 之间通过 From 转换自动对接(JsonRejection 是 composite_rejection! 生成的 enum,包含 BytesRejection variant)。
第三步:Self::from_bytes(&bytes)。这是 Json 真正的反序列化,json.rs:164-188 定义:
rust
// 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 扩展里定义的状态码,现代 API 普遍用它表示"请求体形式合法但语义不符合业务要求"。Axum 用 422 / 400 / 415 精细区分四种 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>> 的细节
json.rs:116-136 的 OptionalFromRequest impl:
rust
// 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:
rust
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-core 里定义。这意味着 handler 可以返回 Json(value) 作为响应:
rust
async fn h() -> Json<User> { Json(user) }响应时自动设 Content-Type: application/json,body 是 serde_json::to_vec(&value) 的结果。序列化失败(极罕见,比如非字符串 key 的 HashMap)会返回 500。第 10 章讲 IntoResponse 时会详讨论。
这是 Axum 的一个美学原则:同一个类型既是提取器也是响应——Json、Form、Html、Bytes、String 都如此。对称性让代码读起来像"数据来源 ↔ 数据去向"的自然映射,handler 签名直接表达"接收 JSON,返回 JSON"。
Json<T> 作为响应的 IntoResponse impl 逻辑:调 serde_json::to_vec(&value) 序列化、设 Content-Type: application/json、body 是字节数组。序列化失败怎么办?json.rs 实际处理了两种 serde_json 错误:
- IO 错误:不可能发生——我们写到
Vec<u8>,内存写入永远不会 IO 失败。源码用unreachable!()(debug)或 fallback 到 empty body(release) - Serialize 失败:极少数情况下
serde::Serialize::serialize可能主动返回错误,比如HashMap<NonStringKey, V>序列化为 JSON 时——JSON 要求 object key 是字符串。这时 Axum 返回 500 Internal Server Error,body 是错误消息的 UTF-8 字节
序列化失败是服务端的 bug(类型设计不当),所以 500 比 400 合适——4xx 暗示客户端问题,5xx 才是服务端问题。这是 Axum 把 HTTP 语义做细的又一例。
serde_path_to_error 的错误精度
Json、Query、Path 三个提取器都用了 serde_path_to_error 包装 Deserializer——它的价值在嵌套结构体场景下最明显。看两种错误消息对比:
假设目标类型:
rust
#[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 的错误:
text
invalid type: string "invalid", expected u32用了 serde_path_to_error:
text
profile.age: invalid type: string "invalid", expected u32前缀 profile.age 让客户端和日志系统一眼看出哪个字段出错。在深层嵌套(order.items[3].metadata.color: ...)时效果更明显——错误定位从"反序列化失败"变成"具体字段失败",修复路径清晰。
serde_path_to_error 不改变反序列化逻辑,只在 visitor 协议的每次下降时记录 stack,出错时把 stack 拼回路径。零拷贝、零运行时开销(只在错误路径上有开销)。这类"只增强错误、不改变语义"的 crate 是 Rust 生态里的一种典型工具层——Axum 大量使用它们,也是用户体验倾斜的又一例。
Form 提取器:Json 的 urlencoded 兄弟
axum/src/form.rs 定义了 Form<T>——结构上和 Json<T> 高度对称,只是媒体类型和反序列化器换成 urlencoded:
rust
// axum/src/form.rs(简化)
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> {
// GET/HEAD 请求从 query 提取;其他从 body 提取
if req.method() == Method::GET || req.method() == Method::HEAD {
let query = req.uri().query().unwrap_or_default();
// 用 serde_html_form 解析
} else {
// 校验 Content-Type: application/x-www-form-urlencoded
// Bytes::from_request + serde_html_form
}
}
}几个和 Json 对比的有趣差别:
- Form 对 HTTP 方法敏感:
GET/HEAD从 query string 提取(相当于 Query 的等价实现),其他方法从 body 提取(相当于 Json 的等价实现)。这对应传统 HTML form 的两种提交方式:<form method="get">把字段拼进 URL、<form method="post">放进 body - Content-Type 必须是
application/x-www-form-urlencoded(POST 场景) - Reuse 同一个 Deserializer (
serde_html_form)——无论数据源是 query 还是 body,urlencoded 格式相同
Form 和 Json / Query 在 axum 内部构成一个三角关系:
这是 Axum 内置提取器的完整"serde 化"全景:四个提取器 + 一个 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 温床,放进提取器更好。
rust
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:
rust
#[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)几乎都是这种模式。
第 8 章讲高级提取器时会看更复杂的组合案例——比如 TypedHeader<Authorization<Bearer>> 如何把三层嵌套(TypedHeader + Authorization + Bearer)用组合提取器链串起来。
更多 parts-only 提取器:生态补充
Path / Query / State 是用得最多的 parts-only 提取器,但 axum 生态里还有一批有用的"小工具"提取器,都走 FromRequestParts,适合了解一下,随手能用。
| 提取器 | 出处 | 用途 |
|---|---|---|
Method, Uri, Version, HeaderMap | http crate | 原始 HTTP 信息,最底层 |
Extension<T> | axum | 从 req.extensions 取类型为 T 的值(中间件注入) |
MatchedPath | axum::extract::matched_path | 当前匹配到的路径模板(如 /users/{id},不是真实 URI) |
OriginalUri | axum::extract::original_uri | 没经过 nest 前缀切除的原始 URI |
NestedPath | axum::extract::nested_path | 嵌套 Router 的当前前缀 |
ConnectInfo<T> | axum::extract::connect_info | 底层 TCP 连接信息(远端地址等) |
TypedHeader<T> | axum_extra | 类型安全的 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(通常 Arc 加一) | 5-20 ns |
Path<T> | extensions 查找 + serde visitor + 类型转换 | 几百 ns |
Query<T> | parts.uri.query() 拆分 + form_urlencoded 解码 + serde | 取决于字段数,每字段 ~100 ns |
Json<T> | body 缓冲 + serde_json 反序列化 | 取决于 body 大小,MB 级可到几 ms |
State 是开销最小的——因为它本质上是"clone 一个 Arc"。Path 有 extensions HashMap 查找的轻度开销、加 serde visitor 回调的一系列跳转。Query 按字段线性——字段越多越慢,但每字段的常数因子小。Json 的量级完全由 body 大小决定,serde_json 在现代 CPU 上大约 100-500 MB/s 的反序列化速度。
关键事实:Json 一般是最慢的,因为它做 I/O(读 body)加重计算(反序列化 + 验证)。其他三个都是轻计算,即便 100 个 handler 场景下总开销也只有几微秒。工程上性能调优的第一目标是控制 body 大小——DefaultBodyLimit 加严、业务 API 拒绝冗余字段——而不是去优化 Path/Query/State 这些微秒级的开销。
综合实战:一个多提取器的 handler
最后把四个提取器都用上,写一个 "更新文章" 的 handler,同时展示 state 设计:
rust
#[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 也能知道错在哪
这个 handler 几乎就是真实生产代码的典型形态——多 state 子依赖 + 路径参数 + 查询选项 + body。理解了 Path/Query/State/Json 四个提取器,你就能覆盖 axum 里 80% 以上的 handler 参数需求。剩余 20% 是下一章要讲的高级提取器——WebSocket、Multipart、ConnectInfo,它们每个都有独特的协议或运行时要求。
设计哲学小结
把本章讨论的四个提取器放一起看,Axum 设计 built-in extractor 的几条核心原则浮出水面:
一、每个提取器尽量薄,复杂度下沉到第三方 crate。Query 把 urlencoded 解析交给 form_urlencoded + serde_html_form;Json 把 body 缓冲交给 Bytes::from_request、反序列化交给 serde_json;State 把 state 抽取交给 FromRef。Axum 自己不重写任何通用能力,只做"从 HTTP 请求到这些工具的粘合"。这让提取器代码可读且稳定——Query::from_request_parts 的实质一行代码,变动空间极小。
二、错误信息优先。四个提取器的 Rejection 都精心映射 HTTP 状态码(Json 的 415/400/422/413 四分);都用 serde_path_to_error 让错误指向具体字段;都用 #[diagnostic::on_unimplemented] 让编译错误指向"extract 文档"。这套工程在每处都是小成本但累积起来让 Axum 的用户体验大幅领先一般框架。
三、类型系统驱动的组合规则。FromRequestParts / FromRequest / OptionalFromRequest / FromRef 这一套 trait 不是给用户看的 API——它们是 Axum 用来表达"提取器之间的组合约束"的编译期工具。"最后一个参数才能是 FromRequest"不是文档约定,而是 impl_handler 宏展开后类型系统直接拒绝;"State<T> 需要 T: FromRef<S>"不是运行时检查,而是 impl 约束。这套"约束显式化"的设计让 handler 签名本身就是正确性的证明——签名能过编译、运行时就不会有参数提取逻辑错误。
四、对称性。Json / Form / Html / 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 | 无(直接 clone) | serde_json |
| 错误定位 | serde_path_to_error | serde_path_to_error | 不会错 | serde_path_to_error |
| Rejection 类型 | PathRejection | QueryRejection | Infallible | JsonRejection |
| 典型失败 HTTP 码 | 400 | 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(json.rs:116-136)
Query 和 State 没有 Option 版本,因为它们的源总是存在(空 query 合法、state 总是存在),Option 无意义。想让 Query 可选,用 Query<T> 其中 T 的字段都是 Option——但这是 T 层面的选择,不是提取器层面的。
提取器在 handler 参数列表里的自由组合
第 6 章论证过"除最后一个外全都是 FromRequestParts,最后一个是 FromRequest"的规则。这四个提取器在这条规则下有具体的组合模式:
rust
// 最常见:路径 + 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 元编程》第 3 章讲 Deserializer trait 多态性的绝佳落地:
- Json 用
serde_json::Deserializer::from_slice——典型字节流 Deserializer,按 JSON 语法 token 化 - Query 用
serde_html_form::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 从 tokio 连接信息直接取。这些 case 展示了提取器框架的灵活性:并不强制走 serde,trait 本身只关心"能从 Request/Parts 产出 Self"。
下一章我们就深入这些"非 serde"提取器,看 Axum 如何把"WebSocket 升级"这样的协议级能力也塞进同一套提取器框架。