Axum 设计与实现
第19章 axum-macros:debug_handler、FromRequest derive、TypedPath
前面 18 章讨论的都是 axum 的类型机制——trait、泛型、生命周期。这一章换一个视角:宏。axum-macros 是一个独立的 proc-macro crate,提供几个关键的 derive 和 attribute 宏:
#[axum::debug_handler]:attribute 宏——给 handler 加上诊断、让 "trait bound 不满足" 的错误更精确#[derive(FromRequest)]/#[derive(FromRequestParts)]:derive 宏——让 struct 自动实现提取器 trait#[derive(FromRef)]:derive 宏——多字段 state 的 FromRef impl 自动生成(第 18 章讨论过)#[derive(TypedPath)]:derive 宏——给 struct 加路径模板、生成类型化路由。它的实现在 axum-macros,但从axum_extra::routing::TypedPath导出(axum-extra/src/routing/mod.rs:23的pub use axum_macros::TypedPath;),需要开 axum-extra 的typed-routingfeature;生成的代码也直接引用::axum_extra::routing::TypedPath,所以离开 axum-extra 用不了
这些宏有共同的哲学:不发明新语法、只减少 boilerplate。它们生成的代码和用户手写是一样的——宏只是把模板化代码自动化。本章深入这些宏的展开逻辑——理解了你就能写自己的类似宏、或者理解编译错误时发生了什么。
为什么要有 axum-macros
第 5 章讨论过 Handler trait 的一个痛点——trait bound 失败时编译器只说 handler 没实现 Handler、不告诉你哪个参数不对。第 18 章的 #[derive(FromRef)]——省下逐字段写 FromRef impl 的 boilerplate。第 6 章讲 FromRequest / FromRequestParts 可以通过 struct 组合。这些需求有共性:避免重复代码 + 改善错误信息——都是元编程(metaprogramming)的经典用途。
axum-macros 就是专门做这些事。它作为独立的 proc-macro crate,提供几个 derive 和 attribute 宏——每个宏针对一个具体 pain point:
- 编译错误不精确 →
#[debug_handler] - 多字段 state 写 impl 烦 →
#[derive(FromRef)] - 多提取器组合写 boilerplate 烦 →
#[derive(FromRequest)] - URL 模板和字段绑定 →
#[derive(TypedPath)]
每个宏的输出都是人能手写的 Rust 代码——只是工具帮你把重复部分自动化。读完本章你应该能:读宏源码、理解生成了什么、知道何时该写宏、避开宏的几个常见坑。
proc-macro 简要基础
读宏源码前先回顾 Rust 过程宏的关键概念:
proc_macro vs proc_macro2:前者是 Rust 标准库的 macro API、只能在 macro crate 里用;后者是第三方 crate(proc-macro2)包装、任何 crate 都能用(让测试 macros 更方便)。axum-macros 用 proc_macro2。
syn:parse Rust 代码成 AST——syn::ItemFn、syn::ItemStruct、syn::Expr 等类型。可以读 TokenStream 得到结构化的语法树。
quote:生成 TokenStream 的 DSL——quote! { fn #name() { ... } } 把 AST 块写成 Rust 语法、变量以 #var 插入。
TokenStream:Rust 代码的 tokens 序列——宏的输入和输出都是 TokenStream。
三者的典型使用流:
use proc_macro::TokenStream;
use quote::quote;
use syn::parse_macro_input;
#[proc_macro_derive(MyMacro)]
pub fn my_macro(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as syn::ItemStruct); // parse
let name = &ast.ident; // 检查 AST
quote! { // 生成新代码
impl MyTrait for #name {
fn do_thing() { /* ... */ }
}
}.into()
}
三步:parse → 分析 → 生成。axum-macros 的所有宏都遵循这个模式。
#[debug_handler]:把错误精确到参数
第 5 章讨论过 handler trait 的错误信息问题——如果一个参数不满足 FromRequest bound,编译器只报这个函数没实现 Handler<_, _>(0.8.9 已用 #[diagnostic::do_not_recommend] 收起了 blanket impl 候选列表),仍然不告诉你哪个参数错了。#[debug_handler] 就是为这个问题设计的。
看 axum-macros/src/debug_handler.rs:11-89 的 expand 函数:
// axum-macros/src/debug_handler.rs:11-89(简化,省略了 state 推导和泛型函数的分支)
pub(crate) fn expand(attr: Attrs, item_fn: &ItemFn, kind: FunctionKind) -> TokenStream {
let check_extractor_count = check_extractor_count(item_fn, kind);
let check_path_extractor = check_path_extractor(item_fn, kind);
let check_output_tuples = check_output_tuples(item_fn);
let check_output_impls_into_response = if check_output_tuples.is_empty() {
check_output_impls_into_response(item_fn)
} else {
check_output_tuples
};
let check_inputs_and_future_send = ;
let middleware_takes_next_as_last_arg =
matches!(kind, FunctionKind::Middleware).then(|| next_is_last_input(item_fn));
quote! {
#item_fn // 1. 保留原函数不动
#check_extractor_count // 2. 生成独立的 "检查器"
#check_path_extractor
#check_output_impls_into_response
#check_inputs_and_future_send
#middleware_takes_next_as_last_arg
}
}
顺带说明两件事:一是 expand 的 kind 参数区分 Handler 和 Middleware——同一套代码同时服务 #[debug_handler] 和 #[debug_middleware],后者多一条"Next 必须是最后一个参数"的检查(next_is_last_input,axum-macros/src/debug_handler.rs:826)。二是函数带泛型参数时,输入检查和 Future Send 检查整段被跳过,直接报 "#[axum_macros::debug_handler] doesn't support generic functions"(axum-macros/src/debug_handler.rs:73,源码里那句是 format!("#[axum_macros::debug_{kind}] doesn't support generic functions")(真实字面量里 #[..] 两侧还有一对反引号),kind 对 handler 就展成 handler)——因为泛型函数的参数类型没定下来,硬查只会叠加一堆没用的错误。
核心思想:宏展开后不改 handler 本身——只额外生成几段独立的 "检查器代码"。每段检查器验证 handler 的一个特定方面(参数数量、返回类型、每个参数的 trait bound、Future Send 性)。
每段检查器都是一个独立函数——这是关键。handler 真正的 Handler trait 失败时报一个笼统错误、但检查器失败时每个检查的错误独立定位到对应的参数或返回类型——用户看到的错误精确指向问题位置。
check_inputs_impls_from_request:逐参数检查
简化版本的代码:
// axum-macros/src/debug_handler.rs:223-339 的骨架(fn check_inputs_impls_from_request)
// 关键:用 WithPosition 迭代,区分「最后一个参数」和「其他参数」
let must_impl_from_request_parts = match &arg {
Position::First(_) | Position::Middle(_) => true, // 非最后 -> FromRequestParts
Position::Last(_) | Position::Only(_) => false, // 最后一个 -> FromRequest
};
let check_fn = format_ident!(
"__axum_macros_check_{}_{}_from_request_check", item_fn.sig.ident, idx, span = span);
let call_check_fn = format_ident!(
"__axum_macros_check_{}_{}_from_request_call_check", item_fn.sig.ident, idx, span = span);
quote_spanned! {span=>
#[allow(warnings)]
#[doc(hidden)]
fn #check_fn #check_fn_generics()
where
#from_request_bound, // 例如 #ty: ::axum::extract::FromRequestParts<#state_ty> + Send
{}
// we have to call the function to actually trigger a compile error
// since the function is generic, just defining it is not enough
#[allow(warnings)]
#[doc(hidden)]
fn #call_check_fn() { #check_fn(); }
}
有三个细节值得单独拎出来:
一、bound 按位置分派。非最后一个参数断言 FromRequestParts<S> + Send,最后一个(或唯一一个)断言 FromRequest<S> + Send——正好对应第 5 章讲的 impl_handler! 展开规则。宏还会用 request_consuming_type_name(axum-macros/src/debug_handler.rs:585-604)识别一张写死的名单——Json / RawBody / RawForm / Multipart / Protobuf / JsonLines / Form / Request / Bytes / String / Parts 共 11 种"吃 body"的已知类型,命中时直接用不带 marker 泛型的 FromRequest<S> bound,否则用 FromRequest<S, M>(M 是第 6 章讲的 marker 类型参数)。
二、每个参数生成两个函数。一个是带 where 子句的 ..._from_request_check,另一个是 ..._from_request_call_check 去调它。源码注释写得很清楚——check_fn 可能是泛型的(带 <M>),光定义一个泛型函数不会触发 bound 检查,必须调用它才会实例化。这是很容易忽略的一步。
三、函数名带上 handler 名和参数下标(__axum_macros_check_{fn名}_{idx}_from_request_check)——同一个模块里多个 handler 各自展开也不会撞名。
如果参数不满足 bound——编译器报错指向那个 where 子句——而 quote_spanned!(ty.span() => ...) 把 span 设成了原参数类型的位置,所以错误消息落回原 handler 的那个参数上。
这个技巧叫"where bound expansion"——把"一个方法要求多个 bound 都成立"拆解成"多个方法各要求一个 bound"——编译器错误消息从一个笼统变成多个独立。
span 信息的重要性
quote_spanned!(some.span() => ...) 是 debug_handler 的灵魂:
some.span()获取源代码中那段 token 的位置(行号、列号)- 生成的 TokenStream 带上这个 span
- 编译器在那个生成代码里报错时——指回原 token 位置
没有 span 的话,错误会指向 debug_handler 展开后的位置(用户看不到的生成代码)——debug 变 "don't debug"。span 是把错误反向映射回源码的关键。
其他检查器
debug_handler 还生成几种检查:
check_extractor_count(axum-macros/src/debug_handler.rs:143-162):确保 handler 参数数不超过 16(let max_extractors = 16;,正是 Handler trait 的 blanket impl 上限),超了就报 "...cannot take more than 16 arguments. Use (a, b): (ExtractorA, ExtractorA) to further nest extractors"。
check_path_extractor(axum-macros/src/debug_handler.rs:190-211):检查 Path<T> 只用一个——判据就是 path_extractors.len() > 1 时逐个报错;多个 Path 会互相覆盖 URL 参数、是 bug。
check_output_impls_into_response(axum-macros/src/debug_handler.rs:622):断言 handler 返回类型满足 IntoResponse bound(它同样有一张已知返回类型名单 well_known_last_response_type,axum-macros/src/debug_handler.rs:606-620)。返回类型错也能精确指位置。
check_future_send(axum-macros/src/debug_handler.rs:711):断言 handler 的 Future 是 Send——async multi-thread 必须。
每个检查都遵循"生成独立函数 + span 传播"的模式。组合起来让 handler 的所有常见错误都被精确定位。
flowchart TB
CODE["用户写:<br/>#[debug_handler]<br/>async fn h(a: PgPool, Json(b): Json<B>) -> impl IR"]
CODE --> EXPAND["axum-macros 展开"]
EXPAND --> ORIG["保留原函数 h"]
EXPAND --> CHK["生成若干检查器 fn"]
CHK --> C1["fn __check_param_0<br/>where PgPool: FromRequestParts"]
CHK --> C2["fn __check_param_1<br/>where Json<B>: FromRequest"]
CHK --> C3["check_extractor_count<br/>(不生成 fn:参数数 > 16 时直接 compile_error)"]
CHK --> C4["fn __check_output<br/>where RT: IntoResponse"]
CHK --> C5["fn __check_future_send<br/>where Fut: Send"]
C1 -.错误 span.-> FOUND["编译器报错精确指向 a: PgPool"]
C2 -.错误 span.-> FOUND2["或精确指向 Json<B>"]
错误就像 "PgPool: FromRequestParts<()> is not satisfied"、直接落在 a: PgPool 上(它不会替你猜出 State<PgPool>)——而不是 "Handler<_, _> not satisfied by fn(PgPool, Json<B>)"——诊断质量天壤之别。
#[debug_handler] 能捕获的错误类型
debug_handler 能精确报错的场景:
一、参数类型不是提取器:
#[axum::debug_handler]
async fn h(db: PgPool, ...) { }
// 错误: "PgPool: FromRequestParts<()> is not satisfied" 精确指向 db 参数
二、返回类型不是 IntoResponse:
#[axum::debug_handler]
async fn h() -> SomeCustomType { }
// 错误: "SomeCustomType: IntoResponse is not satisfied" 指向返回类型
三、多个 Path 参数:
#[axum::debug_handler]
async fn h(Path(a): Path<u64>, Path(b): Path<String>) { }
// 错误: 检测到两个 Path 参数、自动识别为错误
四、handler 的 Future 不是 Send:
#[axum::debug_handler]
async fn h(State(x): State<NotSend>) { }
// 错误: "future cannot be sent between threads safely"(参数检查同时会报 NotSend 不满足 Send/Sync)
这几类是 handler 最常见的类型错误。推荐:所有 handler 都直接加 #[axum::debug_handler],不需要自己再套一层条件编译:
#[axum::debug_handler]
async fn my_handler(/* ... */) { /* ... */ }
宏自己只在 debug 构建展开检查代码,release 构建下 #[cfg(not(debug_assertions))] 分支直接 return input(axum-macros/src/lib.rs:576-583)。开发时错误精确、发布时无额外开销——两全。
#[derive(FromRequest)]:struct 字段组合提取器
FromRequest derive 让 struct 自动实现提取器——字段由多个提取器组合而成:
use axum::extract::FromRequest; // 开 macros feature 后,trait 和同名 derive 一起导入
#[derive(FromRequest)]
struct MyExtractor {
state: State<AppState>,
path: Path<PathParams>,
query: Query<QueryParams>,
body: Json<Payload>, // 最后一个: FromRequest
}
// handler 直接提取组合类型
async fn handler(extractor: MyExtractor) -> impl IntoResponse {
// 用 extractor.state / extractor.path / etc
}
这是把多个提取器命名化组合的 pattern——handler 签名简洁(一个参数代替多个)、MyExtractor 本身可以在多处复用。
展开逻辑
axum-macros/src/from_request/mod.rs 有完整实现。核心逻辑:
// 概念等价代码(实际展开对每个 parts 字段都 into_parts / from_parts 一次,这里合并写)
impl FromRequest<AppState> for MyExtractor {
type Rejection = axum::response::Response;
async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
let (mut parts, body) = req.into_parts();
// 前 N-1 个字段走 FromRequestParts
let app_state = State::<AppState>::from_request_parts(&mut parts, state)
.await
.map_err(IntoResponse::into_response)?;
let path = Path::<PathParams>::from_request_parts(&mut parts, state)
.await
.map_err(IntoResponse::into_response)?;
let query = Query::<QueryParams>::from_request_parts(&mut parts, state)
.await
.map_err(IntoResponse::into_response)?;
// 最后一个走 FromRequest
let req = Request::from_parts(parts, body);
let body = Json::<Payload>::from_request(req, state)
.await
.map_err(IntoResponse::into_response)?;
Ok(Self { state: app_state, path, query, body })
}
}
生成的代码和第 5 章讲的 impl_handler! 宏展开几乎一样——前 N-1 个字段用 from_request_parts、最后一个用 from_request、失败统一 into_response 短路。
两种 derive 模式
#[derive(FromRequest)] 有两种使用模式:
模式一:字段组合(上面的例子)。每个字段都是一个提取器——derive 调用各自的 FromRequest/FromRequestParts。
模式二:via attribute。让整个 struct 当作一个已有提取器处理:
#[derive(FromRequest)]
#[from_request(via(axum::Json))]
struct MyData { /* ... */ }
// 等价于从 body 反序列化成 MyData 的 Json 提取
from_request/mod.rs 的 impl_struct_by_extracting_all_at_once 分支——标记了 #[from_request(via(X))] 时,生成的 impl 直接转发给 X<Self> 的 FromRequest:
// 对应 axum-macros/src/from_request/mod.rs:820-840 的展开形状
impl<S> ::axum::extract::FromRequest<S> for MyData
where
Json<Self>: ::axum::extract::FromRequest<S>,
S: Send + Sync,
{
type Rejection = <Json<Self> as ::axum::extract::FromRequest<S>>::Rejection;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
<Json<Self> as ::axum::extract::FromRequest<_, _>>::from_request(req, state)
.await
.map(|Json(value)| value)
.map_err(::std::convert::From::from)
}
}
版本口径:
type Rejection取被代理提取器自身的 rejection 类型,这是主线上的行为(PR #3261,CHANGELOG 里还挂在 Unreleased 段)。crates.io 上的 axum 0.8.9 / axum-macros 0.5.1 里,via生成的Rejection还是axum::response::Response——这是一个 breaking change,升级时要留意。
这让 FromRequest 可以"套"在 struct 外——比如自定义错误响应、或者给 struct 附加提取逻辑。
rejection 自定义
derive 支持自定义 rejection 类型(对提取失败的响应做定制):
#[derive(FromRequest)]
#[from_request(rejection(MyAppError))]
struct MyExtractor {
user: State<User>,
data: Json<Data>,
}
任何字段提取失败——生成代码把错误转成 MyAppError(via From bound)。注意 State 的 rejection 是 Infallible,所以 MyAppError 也得有 From<Infallible>,否则报 E0277。这让大项目里的错误响应统一——不同提取器的默认 rejection 不同(PathRejection、JsonRejection 等),用 MyAppError 聚合成单一类型。
#[derive(FromRequest)] 的几种模式可视化
flowchart TB
classDef input fill:#efe,stroke:#080
classDef mode fill:#fef,stroke:#808
U["用户写:<br/>#[derive(FromRequest)]<br/>struct X { a: State<A>, b: Json<B> }"]:::input
U --> MODE{选择模式}
MODE -->|默认: 字段组合| M1["字段分别调 from_request_parts/from_request<br/>最后组合成 Self"]:::mode
MODE -->|"#[from_request(via(T))]"| M2["整个 struct 走 T 的 FromRequest<br/>比如 T=Json"]:::mode
M1 --> OUT1["impl FromRequest<br/>依次提取字段"]
M2 --> OUT2["impl FromRequest<br/>转发给 T"]
STATE[state 推导]
STATE -->|有 State<AppState> 字段| CONCRETE["impl FromRequest<AppState>"]
STATE -->|无 State 字段| GENERIC["impl<S> FromRequest<S>"]
OUT1 -.-> STATE
OUT2 -.-> STATE
REJ[rejection 自定义]
REJ -->|"#[from_request(rejection(MyError))]"| UNIFIED["所有字段失败 map_err 到 MyError"]
REJ -->|默认| RESPONSE["rejection = Response<br/>(主线的 via 模式改用被代理提取器的 rejection)"]
默认行为 + 两个可选属性(via、rejection)——组合出四种 derive 模式。每种都自动推导 state 类型——用户不显式标 state 时 derive 看字段猜。
这种"基础 + 可选属性"的 API 设计是 Rust derive 宏的典型——默认合理、定制点清晰。用户不需要学全部可选项——只在需要时加 attribute。
#[derive(TypedPath)]:类型化路由
axum-extra 提供的 TypedPath——给 struct 配 URL 模板:
use axum_extra::routing::TypedPath;
#[derive(TypedPath, Deserialize)]
#[typed_path("/users/{user_id}/posts/{post_id}")]
struct PostPath {
user_id: u64,
post_id: u64,
}
// handler 签名直接用 TypedPath
async fn get_post(PostPath { user_id, post_id }: PostPath) -> impl IntoResponse {
format!("{}/{}", user_id, post_id)
}
TypedPath 解决几个问题:
一、url 模板和字段绑定:/users/{user_id}/posts/{post_id} 里的 placeholder 自动对应 struct 字段——字段改名、模板里也要改、编译期校验。
二、反向生成 URL:PostPath { user_id: 1, post_id: 2 }.to_string() 生成 /users/1/posts/2——适合构造重定向 / 生成 link。不用手动 format。
三、类型化路由:Router::new().typed_get(get_post)——路由系统能从 PostPath 读出 path、自动注册。
宏展开
axum-macros/src/typed_path.rs 的展开。核心输出三个 impl:
一、TypedPath::PATH 常量:
impl TypedPath for PostPath {
const PATH: &'static str = "/users/{user_id}/posts/{post_id}";
}
二、Display impl(axum-macros/src/typed_path.rs:117-135):
impl ::std::fmt::Display for PostPath {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
let Self { user_id, post_id, } = self;
write!(
f,
"/users/{user_id}/posts/{post_id}", // format_str_from_path 的产物
user_id = ::axum_extra::__private::utf8_percent_encode(
&user_id.to_string(), ::axum_extra::__private::PATH_SEGMENT),
post_id = ::axum_extra::__private::utf8_percent_encode(
&post_id.to_string(), ::axum_extra::__private::PATH_SEGMENT),
)
}
}
两个容易看漏的点:格式串用的是具名占位符({user_id} 而不是 {}),所以字段顺序和模板顺序不一致也没关系;每个值都经过 utf8_percent_encode(..., PATH_SEGMENT) 做百分号编码——所以 String 字段里含空格、/、中文时,to_string() 生成的 URL 会被正确转义(实测 "a b/中" 变成 a%20b%2F%E4%B8%AD),不用自己拼。
三、FromRequestParts impl:
impl<S> ::axum::extract::FromRequestParts<S> for PostPath
where
S: Send + Sync,
{
// 默认取 <Path<Self> as FromRequestParts<S>>::Rejection;
// 写了 #[typed_path("...", rejection(MyRejection))] 就换成 MyRejection + map_err
type Rejection = <::axum::extract::Path<Self> as
::axum::extract::FromRequestParts<S>>::Rejection;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
<::axum::extract::Path<PostPath> as ::axum::extract::FromRequestParts<S>>
::from_request_parts(parts, state)
.await
.map(|path| path.0)
}
}
直接通过 Path<Self> 转发——Deserialize impl 由用户自己 #[derive(Deserialize)] 提供(所以 TypedPath 的示例里总是 #[derive(TypedPath, Deserialize)] 成对出现)。
三个 impl 配合让 TypedPath 既能作为 URL 模板(TypedPath trait)、也能格式化成 URL(Display)、也能从请求提取(FromRequestParts)。一个 derive、三种能力。
parse_path:模板解析
typed_path.rs 里的 parse_path 函数解析 /users/{user_id} 这样的字符串:
// axum-macros/src/typed_path.rs:409-412——只有两个 variant
enum Segment {
Capture(String, Span), // "{user_id}",Span 用于错误定位
Static(String), // 字面段 "users"
}
// typed_path.rs:379-407
fn parse_path(path: &LitStr) -> syn::Result<Vec<Segment>> {
// 空串 / 不以 `/` 开头 -> 直接编译错误
path.value()
.split('/')
.map(|segment| {
if let Some(capture) = segment
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.and_then(|s| (!s.starts_with('{') && !s.ends_with('}')).then_some(s))
.map(|capture| capture.strip_prefix('*').unwrap_or(capture))
{
Ok(Segment::Capture(capture.to_owned(), path.span()))
} else {
Ok(Segment::Static(segment.to_owned()))
}
})
.collect()
}
没有单独的"通配"变体——通配符 {*rest} 只是在 Capture 分支里把前导的 * 剥掉,剩下 rest 当作普通捕获名。另外 {{ / }}(转义的花括号)会被那个 !s.starts_with('{') 判断挡掉,落到 Static 分支。
这个解析器是 编译期 跑的——在宏展开时处理字符串。生成的代码里模板是具体的——没有运行时字符串 parse。
TypedPath 的工程价值
和普通 Path<u64> + get("/users/{user_id}") 写法对比:
普通写法:
Router::new()
.route("/users/{user_id}/posts/{post_id}", get(get_post));
async fn get_post(Path((user_id, post_id)): Path<(u64, u64)>) { /* ... */ }
TypedPath 写法:
Router::new()
.typed_get(get_post);
#[derive(TypedPath, Deserialize)]
#[typed_path("/users/{user_id}/posts/{post_id}")]
struct PostPath { user_id: u64, post_id: u64 }
收益:
- 路径模板和 handler 绑定:PostPath struct 定义在 handler 旁边——改路径、改 handler 同步
- 命名参数:
PostPath { user_id, post_id }比 tuple 更清晰 - 反向 URL:前端模板里
href={PostPath{user_id, post_id}.to_string()}生成链接——不用拼字符串 - 编译期校验:模板和字段数不匹配编译失败——防止 "
/users/{user_id}/posts/{wrong_name}" 这种笔误
代价:代码略多、需要每个路由定义一个 struct。对大项目收益 > 成本——对小项目看风格偏好。
TypedPath 路由注册
TypedPath 配合 typed_get / typed_post / typed_put / typed_delete 等方法让 Router 注册更类型化。这几个都是 axum_extra::routing::RouterExt 这个 trait 上的方法——声明在 axum-extra/src/routing/mod.rs:119-131,实现在 axum-extra/src/routing/mod.rs:282-289,方法体就是一句 self.route(P::PATH, axum::routing::get(handler))。
特别注意不存在 TypedGet / TypedPost 这类独立 trait,别照着记忆去 import。
下面是使用侧的写法:
use axum_extra::routing::{TypedPath, RouterExt};
#[derive(TypedPath, Deserialize)]
#[typed_path("/users/{user_id}")]
struct UserPath { user_id: u64 }
async fn get_user(UserPath { user_id }: UserPath) -> impl IntoResponse {
format!("user {}", user_id)
}
async fn delete_user(UserPath { user_id }: UserPath) -> impl IntoResponse {
format!("deleted {}", user_id)
}
let app = Router::new()
.typed_get(get_user) // 从 UserPath 读取 PATH 常量注册
.typed_delete(delete_user);
typed_get(handler) 的源码就三行(axum-extra/src/routing/mod.rs:282-289):
fn typed_get<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
self.route(P::PATH, axum::routing::get(handler))
}
真正的机关在 T: SecondElementIs<P> 这个 bound 上。T 是 Handler<T, S> 里的那个参数元组类型——第 5 章讲过,它的第一位是私有的 marker 类型,从第二位起才是 handler 真正的提取器参数。所以 SecondElementIs<P>(axum-extra/src/routing/typed.rs:331)的语义是"handler 的第一个提取器参数是 P",它对 (M, P, ...)、(M, Option<P>, ...)、(M, Result<P, E>, ...) 三种形状分别 impl。编译器由此反推出 P = UserPath,再读它的 P::PATH 常量拿到路径字面量。整条链路全在编译期完成。
这种写法的收益在大型项目:路由和 handler 定义在一起——不用在 build_router 的地方重新列出路径和 handler 的对应关系。但小项目仍然 Router::new().route("/users/{id}", get(get_user)) 更直接。
TypedPath 的限制
derive(TypedPath) 不支持 generics(axum-macros/src/typed_path.rs:16-22 明确 reject,generics.params 非空或带 where 子句都会报 "#[derive(TypedPath)] doesn't support generics")。原因是 PATH 是 &'static str 常量——编译期确定——generic 参数在编译期不一定能决定 path 字面。
如果你需要多个"同 pattern 不同子路径"的 handler——需要为每种子路径写独立 struct:
#[derive(TypedPath, Deserialize)] // 少了 Deserialize,生成的 FromRequestParts impl 过不了编译
#[typed_path("/v1/users/{id}")]
struct V1UserPath { id: u64 }
#[derive(TypedPath, Deserialize)]
#[typed_path("/v2/users/{id}")]
struct V2UserPath { id: u64 }
这有点啰嗦——但明确。实际项目里 v1/v2 共存时每版本一套 struct 其实有好处——未来 v1/v2 divergence 时不用改类型。
axum-macros 的设计哲学
把三个宏放一起看,axum-macros 的整体风格:
一、最小魔法:每个宏只生成 trait impl 或检查函数——没有新语法、没有隐式行为。用户看宏输出的 cargo expand 就能完全理解——没有运行时 "我的 struct 为什么有这个 method"的困惑。
二、诊断友好:所有错误通过 quote_spanned! 传播 span——让编译错误指回原代码位置。axum 对"错误信息质量"的重视程度比大多数 Rust 库高。
三、零运行时代价:所有宏生成的 impl 都是编译期的具体代码——没有 reflection、没有运行时检查。和手写 impl 性能完全一样。
四、可读的输出:cargo expand 展开宏后的代码应该能读懂——#[derive(FromRef)] 产出的就是人类会写的 impl。宏的输出不应该是加密的 token 堆。
这四点让 axum-macros 的学习曲线平缓——看任何宏的输出就能理解其行为。相比一些"深度 DSL"式的宏库(比如编译期连数据库校验 SQL 的 sqlx::query!),axum-macros 保持朴素——只是模板化的 Rust 代码。
和 Serde macros 的对比
Serde 也有大量 derive——#[derive(Serialize, Deserialize)]。对比:
| 维度 | axum-macros | serde_derive |
|---|---|---|
| 生成代码复杂度 | 简单(几个 trait impl) | 复杂(visitor pattern、状态机) |
| 用户自定义空间 | 有(via、rejection attribute) | 极大(rename、skip、tag 等几十种 attr) |
| 对接的格式 | 只对接 HTTP 请求 | 任何 Serializer / Deserializer |
| 运行时多态 | 无(单态化) | 无(Visitor 是泛型参数,同样单态化) |
| 学习成本 | 低 | 中高 |
Serde 因为要支持"任意 Serializer"(JSON、YAML、CBOR、MessagePack)——derive 生成的代码更复杂、有 visitor 抽象层。axum 只需"从 Request 产出 Self"——单一场景、生成代码简洁。
两个库的 macro 风格各有特色——axum 偏"模板化、直接"、serde 偏"通用、抽象层多"。都是成熟设计——适用场景不同。
实战:写一个自己的 derive
写一个 #[derive(Logged)] 给 struct 自动生成"log 所有字段"的方法:
// log-derive crate (假想)
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemStruct};
#[proc_macro_derive(Logged)]
pub fn logged_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as ItemStruct);
let name = &ast.ident;
let fields = ast.fields.iter().map(|f| {
let ident = f.ident.as_ref().unwrap();
let ident_str = ident.to_string();
quote! {
tracing::info!(#ident_str = ?self.#ident);
}
});
quote! {
impl #name {
pub fn log_fields(&self) {
#(#fields)*
}
}
}.into()
}
// 用法
#[derive(Logged)]
struct Request {
id: u64,
path: String,
}
let req = Request { id: 1, path: "/users".into() };
req.log_fields();
// tracing 输出两条事件: id=1 和 path="/users"
这个简单例子展示了 axum-macros 风格的宏写法:parse struct、遍历字段、生成 impl。加上 quote_spanned(把字段位置 span 传给生成代码)就能做到"字段错时错误指向字段"——和 axum-macros 同样的诊断质量。
深入:FromRequest derive 的 state 推导
#[derive(FromRequest)] 有个巧妙的 state 推导——如果字段里有 State<T>,自动把 T 作为 state 类型:
#[derive(FromRequest)]
struct MyExtractor {
state: State<AppState>,
json: Json<Data>,
}
derive 检测到 State<AppState> 字段——生成的 impl 是 FromRequest<AppState>——不是泛型 S。这样 MyExtractor 只能在 Router<AppState> 里用、类型明确。
如果没有 State 字段——derive 生成泛型 impl<S> FromRequest<S> for MyExtractor where ...——泛用。
这个推导就在 axum-macros/src/from_request/mod.rs 里(State 这个内部枚举 + state_from_via / 字段扫描)——根据字段自动决定 impl 的泛型。用户不用手动标注 state 类型——derive 根据 struct 内容选择。
rejection 的统一错误类型
derive 生成的错误类型默认是 ::axum::response::Response(任何提取器失败都 IntoResponse::into_response 统一成 Response;特例是 struct 一个字段都没有时,rejection 直接是 Infallible)——也可以显式指定:
#[derive(FromRequest)]
#[from_request(rejection(MyError))]
struct MyExtractor { /* ... */ }
// 要求 From<PathRejection> for MyError
// 要求 From<JsonRejection> for MyError
// 有 State 字段时还要 From<Infallible> for MyError
// 等等
derive 生成的代码会对每个字段 .map_err(<MyError as From<_>>::from)?——把各种默认 rejection 转成统一的 MyError。MyError 自己 impl IntoResponse 决定最终响应。
这让大项目里所有提取错误走统一路径——日志格式、响应格式、错误码都一致。
宏错误的 debugging 技巧
写 / 用 proc macro 时 debug 的几个工具:
cargo expand:把所有宏展开成真实 Rust 代码——看到宏实际产生什么:
cargo install cargo-expand
cargo expand --bin my_server > expanded.rs
看 expanded.rs 能发现 derive 生成的具体 impl——很多 "为什么 trait impl 不满足" 的疑惑可以通过读展开代码解决。
syn::Error + into_compile_error():axum-macros 走的就是这条路——它的依赖只有 proc-macro2 / quote / syn 三个,没有引入 proc-macro-error 之类的错误库。想在自己的宏里做同样的事,syn::Error::new_spanned(node, "消息").into_compile_error() 就够。
trybuild:测试 proc macro 的标准工具——写预期编译失败的测试用例、比对编译输出和期望的错误信息。axum-macros 的 test suite 用 trybuild 保证错误信息质量。
RUSTFLAGS="-Zmacro-backtrace"(nightly):错误里显示宏展开的路径——虽然 nightly 限定,但调试 macro 问题值得一试。
一个经常被忽视的细节:hygiene
macro_rules! 有卫生性(hygiene)保护,宏里引入的局部变量不会和用户代码的变量互相看见;proc macro 生成的标识符默认用 Span::call_site(),没有这层保护,名字起得不好就会和用户代码冲突。axum-macros 用 format_ident! 生成独特名字:
let check_fn = format_ident!(
"__axum_macros_check_{}_{}_from_request_check",
item_fn.sig.ident,
idx,
span = span,
);
__axum_macros_check_ 前缀 + handler 函数名 + 参数下标——几乎不可能和用户的标识符冲突。这是 proc macro 的惯用做法——通常以 crate 名或下划线为前缀。
如果自己写宏没注意 hygiene:
// ❌ 可能和用户的变量 x 冲突
quote! { let x = #value; /* ... */ }
用户如果在宏 insertion 点已经有 x——生成的代码 compile error 或 shadow 用户的 x。安全做法是用唯一 ident:
// ✅ 不冲突
let unique = format_ident!("__my_macro_temp_{}", idx);
quote! { let #unique = #value; /* ... */ }
axum-macros 生成的顶层检查函数都带这个前缀;函数体里的 value、future 这类局部名则被关在生成的函数内部,碰不到用户代码。
axum-macros 生态全景
一张图展示 axum-macros 在整个 axum 生态里的位置:
flowchart TB
classDef core fill:#efe,stroke:#080
classDef macros fill:#fef,stroke:#808
classDef extra fill:#eef,stroke:#066
subgraph axum["axum crate"]
Router["Router<S>"]:::core
Handler["Handler trait"]:::core
Extract["FromRequest(Parts)"]:::core
Response["IntoResponse(Parts)"]:::core
end
subgraph axum_macros["axum-macros crate"]
DH["#[debug_handler]"]:::macros
FR["#[derive(FromRef)]"]:::macros
FREQ["#[derive(FromRequest)]"]:::macros
FREQP["#[derive(FromRequestParts)]"]:::macros
end
subgraph axum_extra["axum-extra crate"]
TP["#[derive(TypedPath)]"]:::extra
TH["TypedHeader"]:::extra
CK["CookieJar"]:::extra
end
DH -.改善诊断.-> Handler
FR -.减少 boilerplate.-> Router
FREQ -.组合.-> Extract
FREQP -.组合.-> Extract
TP -.扩展.-> Router
TP -.依赖.-> axum_macros
- axum crate:核心 trait 定义
- axum-macros:针对核心 trait 的 boilerplate reduction。
#[derive(TypedPath)]的代码其实也住在这里,只是对外从 axum-extra 露头 - axum-extra:扩展能力(TypedPath、TypedHeader、CookieJar 等)——
typed-routingfeature 会把 axum-macros 拉进来
三层分工清晰——用户按需依赖。核心 axum 对 axum-macros 的依赖是 optional 的(macros = ["dep:axum-macros"],默认不开)——保持简洁。
trybuild 测试:保证错误消息质量
写 proc macro 时最难测的是错误情况——"当输入是这样时、生成的编译错误是这样"。用 trybuild:
// axum-macros/src/lib.rs:796-828 的 run_ui_tests(简化)
#[cfg(test)]
fn run_ui_tests(directory: &str) {
#[rustversion::nightly] // 只在 nightly 上跑
fn go(directory: &str) {
let t = trybuild::TestCases::new();
t.compile_fail(format!("tests/{directory}/fail/*.rs"));
t.pass(format!("tests/{directory}/pass/*.rs"));
}
#[rustversion::not(nightly)]
fn go(_directory: &str) {}
go(directory);
}
// 各个宏的模块里各挂一个入口,比如 debug_handler.rs 末尾:
#[test]
fn ui_debug_handler() { crate::run_ui_tests("debug_handler"); }
axum-macros/tests/ 下按宏分目录(debug_handler、debug_middleware、from_ref、from_request、typed_path),每个目录再分 fail/ 和 pass/。注意那个 #[rustversion::nightly] 门——stable 上这些 UI 测试是空跑的,因为编译器的诊断文案在不同版本间会变,.stderr 快照只能钉在一个通道上。fail/ 里每个 .rs 文件是一个预期 compile error 的例子:
// axum-macros/tests/debug_handler/fail/argument_not_extractor.rs
use axum_macros::debug_handler;
#[debug_handler]
async fn handler(_foo: bool) {} // bool 不是提取器
fn main() {}
对应的 .stderr 文件存期望的错误消息(前几行;唯一的参数按「最后一个」处理,所以断言的是 FromRequest):
error[E0277]: the trait bound `bool: FromRequest<(), axum_core::extract::private::ViaParts>` is not satisfied
--> tests/debug_handler/fail/argument_not_extractor.rs:4:24
|
4 | async fn handler(_foo: bool) {}
| ^^^^ the trait `FromRequestParts<()>` is not implemented for `bool`
trybuild 跑这些测试——编译每个 fail 文件、对比实际输出和期望——不一致测试失败。这让错误消息是被测试覆盖的代码——改宏不会意外破坏错误消息质量。
这种 "error snapshot testing" 在 proc macro 开发里很有价值——因为错误消息才是用户体验的核心。
宏在开发体验中的作用
回头看 axum-macros 的整体价值——它是开发体验的工具而不是功能实现:
- 没有 axum-macros:axum 功能完整、但错误信息差、boilerplate 多
- 有 axum-macros:同样的功能、但 debug 容易、代码短
这个定位让 axum-macros 必须是可选的(通过 Cargo feature)——用户可以关掉 proc-macro 依赖、只用 axum 核心 trait 手写 impl。结果是不用宏的项目可以少编译一个 proc-macro crate 及其 syn 依赖——需要这些 DX 工具的项目再显式打开 macros feature(它不在默认 feature 里)。
这是 Rust 库设计的良好做法——核心 lean、扩展可选。用户决定自己需要多少工具。
过程宏和 macro_rules 的分工
Rust 有两种宏:声明宏(macro_rules!)和过程宏(proc_macro)。axum 生态里两者都用——分工不同:
macro_rules!:
- axum 自己的
impl_handler!、all_the_tuples!(生成 1-16 参数的 blanket impl)——重复生成代码 - 不需要外部 parse——输入已经是 tokens
- 快速、直接
- 限制:条件逻辑弱、不能随意读 AST 结构
proc_macro:
- axum-macros 的 derive 和 attribute 宏——需要读用户的 struct 结构
- 通过 syn parse tokens 得到 AST、做复杂逻辑
- 更灵活、但更重(编译时间长)
- 需要独立 crate(
proc-macro = true的 Cargo.toml)
选择:固定模式重复用 macro_rules、需要读用户代码结构用 proc_macro。axum 两者都有——16 参数 impl 用 macro_rules(模式固定)、FromRequest derive 用 proc_macro(读 struct 字段)。
axum-macros 内部的 helper
axum-macros 源码里有几个 helper 模块值得看:
with_position.rs:包装任意迭代器——让你知道当前元素是第一个、中间、还是最后一个。用法:
// 简化 API(crate 内部类型,没有 .with_position() 这样的扩展方法)
for item in WithPosition::new(items.iter()) {
match item {
Position::First(v) => /* 第一个 */,
Position::Middle(v) => /* 中间 */,
Position::Last(v) => /* 最后 */,
Position::Only(v) => /* 只有一个 */,
}
}
用途:debug_handler 要区分 "最后一个参数" 和 "其他参数"——断言的 bound 不同(FromRequest vs FromRequestParts),with_position 让这个区分代码清爽(FromRequest derive 做同样的区分,用的则是 fields.iter().next_back())。
attr_parsing.rs:解析 #[my_attr(key = value, key2)] 这种 attribute 语法的 helper。axum-macros 所有 derive 都用它——parse_attrs 函数。
axum_test.rs:实现 #[__private_axum_test](只在 __private feature 下暴露,axum 自己的测试用)。它做两件事:给测试函数补上 #[tokio::test];再用 syn::visit_mut 扫一遍函数体,函数体里有 .nest(a, b) 时改写成 .nest_service(a, b)、生成一个 xxx_with_nest_service 的孪生测试——一次编写、两条路径都测到。
这些 helper 不是用户能直接用的 API——但读源码时遇到它们能帮你理解。proc macro crate 通常有一堆 helper 模块——axum-macros 相对克制(三个 helper)、代码比较好读。
syn 和 proc-macro2 的 API
写 proc macro 绕不开 syn 和 proc-macro2。关键类型:
syn::ItemFn / syn::ItemStruct / syn::ItemEnum:对应 fn、struct、enum 定义的 AST。最外层 item。
syn::Fields:struct 的字段集合。Fields::Named(有名)/ Fields::Unnamed(tuple struct)/ Fields::Unit(unit struct)。
syn::Type:类型表达式——可能是 Path、Tuple、Reference 等。debug_handler 里扫描 type 判断是否是已知 extractor。
syn::Attribute:属性如 #[derive(X)]、#[my_attr(...)]。
proc_macro2::Span:源代码位置信息——span 传播的核心。quote_spanned!(some.span() => ...) 用这个。
quote::quote!:生成代码的 DSL——#var 插入变量、#(...)* 重复生成。
axum-macros 的代码绝大部分都在和这些类型打交道——parse + 分析 + 生成三步循环。看多了会发现 proc macro 其实是 "树变换"工作——AST input → AST output。
实用场景:自定义 rejection 的模式
生产项目里 #[derive(FromRequest)] 配合自定义 rejection 的模式:
// 统一错误类型
#[derive(Debug)]
pub enum AppError {
NotFound,
BadRequest(String),
InvalidPath,
InvalidQuery(String),
InvalidJson(String),
Internal,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
Self::InvalidPath => (StatusCode::BAD_REQUEST, "invalid path".into()),
Self::InvalidQuery(m) => (StatusCode::BAD_REQUEST, format!("query: {m}")),
Self::InvalidJson(m) => (StatusCode::UNPROCESSABLE_ENTITY, format!("json: {m}")),
Self::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal".into()),
};
(status, msg).into_response()
}
}
// 从各种提取器 rejection 转换
impl From<axum::extract::rejection::PathRejection> for AppError {
fn from(_: axum::extract::rejection::PathRejection) -> Self { Self::InvalidPath }
}
impl From<axum::extract::rejection::QueryRejection> for AppError {
fn from(e: axum::extract::rejection::QueryRejection) -> Self { Self::InvalidQuery(e.to_string()) }
}
impl From<axum::extract::rejection::JsonRejection> for AppError {
fn from(e: axum::extract::rejection::JsonRejection) -> Self { Self::InvalidJson(e.to_string()) }
}
// State 的 rejection 是 Infallible,少了这个 From 会报 E0277
impl From<std::convert::Infallible> for AppError {
fn from(e: std::convert::Infallible) -> Self { match e {} }
}
// 所有提取器用 derive 统一 rejection
#[derive(FromRequest)]
#[from_request(rejection(AppError))]
struct CreateUserInput {
state: State<AppState>,
path: Path<u64>,
query: Query<QueryParams>,
body: Json<CreateUser>,
}
// handler 清爽——所有可能失败统一成 AppError
async fn create_user(input: CreateUserInput) -> Result<Json<User>, AppError> {
// ... 业务
}
这种 pattern 让项目的错误响应体系一致——任何 API 的失败格式都走一个 impl IntoResponse for AppError。derive 帮你自动串联几个 From impl、不用手写 match。
宏相关 FAQ
Q:宏会让编译变慢吗?
会——每个宏调用都是编译期工作:syn 要 parse 一遍、quote 要生成一遍,生成出来的代码 rustc 还要再类型检查一遍。具体慢多少取决于宏数量和生成代码量,没有可以照搬的数字,用 cargo build --timings 看自己项目的实际分布。经验上单个 derive 的开销远小于它带来的 boilerplate 减少,值得。
Q:能调试宏展开吗?
cargo expand --bin myapp 展示展开后代码——读代码能发现"为什么生成了这个"。IDE(rust-analyzer)也能展示宏展开——光标放在 #[derive(FromRequest)] 上执行 "Expand macro recursively" 命令查看生成代码。
Q:宏里写错了怎么办?
axum-macros 用 syn::Error::into_compile_error()(以及直接生成 compile_error!)把内部错误转成编译期错误——你看到的是一条带精确位置的普通编译错误。debug 起来和普通代码一样。
Q:能自定义 #[derive(FromRequest)] 的具体行为吗?
不能直接修改 axum-macros——但可以写自己的 derive crate:parse 同样的 struct、生成不同逻辑的 impl。很多项目有自己的 custom derive——比如给所有 handler 自动加 tracing::info!、或者自动注册 metrics。
Q:proc-macro 的测试怎么写?
用 trybuild crate——写预期成功和预期失败的测试用例、比对编译输出。axum-macros 的测试 suite 在 axum-macros/tests/ 下——每个测试是一个完整的 Rust 文件、trybuild 尝试编译、检查错误消息是否符合预期。
Q:macros 能访问 handler 的 state 类型吗?
debug_handler 有 state 推导逻辑——扫描参数里的 State<T> 取 T 作为 state 类型。如果多个 State<T1>、State<T2> 类型不同——宏报错("can't infer state type")要求用户用 #[axum::debug_handler(state = MyState)] 显式指定;一个 State 参数都没有时则按 () 检查,同样需要显式指定。
Q:cargo expand 展开后代码太长怎么办?
用 cargo expand --bin myapp <path_to_fn> 只展开特定 item 的宏。或者用 cargo expand --lib 看整个 library 的展开。axum-macros 生成的代码相对小——一般不会是"几万行"级别——和用户代码一对一。
Q:宏错误消息本地化(中文)可能吗?
axum-macros 的错误消息是英文硬编码——proc macro 不容易本地化。如果需要中文错误消息——要 fork axum-macros 改 error strings。不建议这样做——英文错误消息在 Rust 生态是标准、社区能互相帮助。
宏的工程经验
用 axum-macros(或写自己的 derive 宏)的几条经验:
一、小宏胜大宏:一个 derive 解决一类问题——不要塞太多功能。FromRequest 只做字段组合、FromRef 只做 state 抽取、TypedPath 只做路径模板——职责单一。
二、默认合理、可选定制:derive 应该默认就能用——attribute 只在需要时加。axum-macros 的 #[from_request(via(...))]、#[from_request(rejection(...))]、#[typed_path("/path", rejection(MyRejection))] 都是"默认 + 可选加 attr"的好例子(注意这几个都是括号语法而不是 key = value)。
三、错误消息投资:宏生成错误的地方都加 quote_spanned! 带 span——让错误指回用户代码。这是用户体验投资——值得。
四、读 cargo expand 验证:写完宏后跑 cargo expand 确认生成代码合理——没生成无意义代码、没 capture 用户变量(hygiene)。
五、文档里展示展开结果:写宏的文档时带一个"展开后的代码"展示——让用户知道宏做什么。axum-macros 自己的文档只给用法示例、没有贴展开结果,本章前面几节手工展开补的就是这一块。
这些经验都是为了让宏"可理解"——Rust 宏的 power 在于可审查、不是像某些动态语言的反射那样"魔法"。
跨专栏关联:元编程生态
《Serde 元编程》专栏第 10 章(serde_derive 架构总览)深入讨论 derive macro 的设计——serde_derive 的复杂度远超 axum-macros、但核心技术一致(parse + analysis + generate)。读那个专栏后再看 axum-macros 会觉得后者简洁——因为 axum 的需求比 Serde 简单。
另一个相关专栏:《Tokio 源码深度解析》专栏第 14 章讨论 tokio::select! 的宏展开——那是 macro_rules! 的路子,和 axum-macros 的 proc-macro 路子正好互为对照。
三个 crate 的 macros 都遵循同一个 Rust 元编程哲学:生成你本来就会写的代码、不发明新范式。这让 Rust 的宏生态比 Python decorators、Ruby metaclass 等动态语言的元编程更容易理解——没有 runtime 魔法、所有行为都在编译期可见。
axum-macros 的版本演进
几个版本节点值得记住:
axum-macros 0.1.0(2022-01-31):首发就带了 #[debug_handler] 和 #[derive(FromRequest)] 两个宏——不是后来才加的。
axum-macros 0.1.1(2022-02-22):加 #[derive(TypedPath)],配合 axum-extra 当时新推出的 "type safe" routing API(#756)。宏本身在 axum-macros,但它生成的 impl 指向 axum_extra::routing::TypedPath——所以用它必须依赖 axum-extra。
axum-macros 0.2.x(2022 上半年):一波诊断增强——检查是否有多个 Path 提取器(#1035)、是否有多个吃 body 的提取器(#1036);以及 #[derive(FromRequest)] 支持 enum + via(...)(#1009)。
axum-macros 0.3.0(2022-11-25,配 axum 0.6):加 #[derive(FromRequestParts)](#1305)和 #[derive(FromRef)](#1430)——正好对应第 18 章讲的那套 state 机制在 0.6 落地。#[from_ref(skip)](#1537)在 0.3.0-rc.3 加入,同样随 0.3.0 发布。
axum-macros 0.4 / 0.5(配 axum 0.7 / 0.8):新增的宏只有 0.4.2 的 #[debug_middleware](#1993、#2725),其余改动集中在错误消息质量、适配 axum-core 的代码生成和 clippy 兼容上。第 18 章看到的那条 #[allow(clippy::clone_on_copy, clippy::clone_on_ref_ptr)] 是分两次加上的:clone_on_copy 来自 0.3.4 的 #1749(修 #[derive(FromRef)] 对 Copy 字段生成的 clippy 告警),clone_on_ref_ptr 来自 2024 年的 #2827。
这个演进节奏反映 Rust 库的成熟过程——先观察社区需求、后加工具。axum 没有一开始就发明一大堆 derive——只在明确用户痛点时加。这种克制让每个 macro 都有真实价值、避免"over-engineering"。
核心库 vs 扩展库的分工
axum-macros 里放了 FromRequest、FromRef、debug_handler——这些是和核心 axum 紧密耦合的,由 axum 的 macros feature 导出。TypedPath 的 derive 虽然也写在 axum-macros 里,却只从 axum-extra 导出、生成的代码也指向 axum-extra 的 trait——它更偏"便利工具"。
区分标准:
- 核心 macros (axum-macros):解决 axum trait 使用中的痛点(错误信息、trait impl boilerplate)
- 扩展 macros(经 axum-extra 导出):提供额外能力(类型化路由)
这种分层让 axum 核心保持精简——用户不用装 TypedPath 也能完整用 axum。需要时再加 axum-extra 依赖——不强制。
自定义 derive 的完整案例
一个实用例子——给 axum handler 自动加 tracing span:
// 假想的 tracing_handler_derive crate
use proc_macro::TokenStream;
use quote::{quote, ToTokens}; // to_token_stream() 来自 ToTokens,不导入会报 E0599
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn tracing_handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut item_fn = parse_macro_input!(item as ItemFn);
let fn_name = &item_fn.sig.ident;
let fn_name_str = fn_name.to_string();
// 原函数体——包进一个 span
let original_block = &item_fn.block;
let new_block = quote! {
{
let span = tracing::info_span!(#fn_name_str);
// 用全路径调用,免得要求调用方自己 use tracing::Instrument
::tracing::Instrument::instrument(async move #original_block, span).await
}
};
// 替换函数体
item_fn.block = syn::parse2(new_block).unwrap();
item_fn.to_token_stream().into()
}
// 用法
#[tracing_handler]
async fn create_user(State(db): State<PgPool>, Json(input): Json<Input>) {
// 业务逻辑——自动被 span 覆盖
}
这个宏的作用:给每个 handler 自动加一个 tracing span——用函数名作为 span 名——handler 内部的 log 都带上这个 span。
可以扩展——让 attribute 支持自定义 span name、fields、level:
#[tracing_handler(level = debug, fields(user_id = %user_id))]
async fn handler(/* ... */) { /* ... */ }
生产项目里这类 "项目级 derive / attribute" 很常见——axum-macros 展示了写法、各项目按需 copy pattern。
深入 debug_handler:check_input_order
debug_handler 还做一件事——检测参数顺序:
// ❌ FromRequest 参数不在最后
#[debug_handler]
async fn bad(Json(a): Json<A>, Path(b): Path<u64>) { /* ... */ }
debug_handler 会直接生成一条 compile_error!,原文是:
`Json<_>` consumes the request body and thus must be the last argument to the handler function
原因:这个错误的根本问题不是 bound、是位置——Json 吃掉整个 body,只能放最后。
实现逻辑在 axum-macros/src/debug_handler.rs:498-575,分三种情况:
- 恰好一个吃 body 的提取器、但不在最后一位 → 报上面那句,span 指向该参数的类型
- 正好两个吃 body 的提取器 → "Can't have two extractors that consume the request body.
Json<_>andForm<_>both do that." - 三个及以上 → "Can't have more than one extractor that consume the request body. ... all do that."
识别"这个类型会吃掉 body"靠的是一张写死的名单——request_consuming_type_name(axum-macros/src/debug_handler.rs:585-604)按类型名的最后一段匹配:Json、Form、RawForm、RawBody、Multipart、Protobuf、JsonLines、Request、Bytes、String、Parts。这是纯粹的名字匹配、不看真实类型——所以你自己写的 body 提取器不在名单里就不会被这条检查覆盖,只能退回普通的 trait bound 检查;反过来,你要是把某个无关的类型也叫 Json,也会被误伤。
这种"针对常见错误的专门检查"让 debug_handler 的诊断能力超越纯 bound check——它有先验知识知道哪些错误特别常见、为它们专门生成精准错误消息。
宏的权衡:什么时候不该写宏
写宏有成本——编译时间、debug 难度、学习曲线。建议:
该写宏的场景:
- 模板化代码重复率高(每个 struct 都要写同样的 impl)
- 代码和数据结构绑定(URL 模板和字段)
- 需要编译期验证(path 模板合法性)
不该写宏的场景:
- 一次性用的代码——就手写
- 小差异的变体——函数 + 泛型更合适
- 复杂逻辑——宏 debug 成本大于节省的代码量
axum-macros 是"刚好需要写宏"的典型——几个 derive 各解决一个具体 pattern、每个都有明显的 boilerplate reduction。写宏前问自己"手写会不会更好"——大部分情况答案是会,只有真正的 pattern repetition 才适合上宏。
小结
axum-macros 的三大主要宏 + 一个辅助(FromRef):
#[debug_handler]:通过生成独立检查器函数 + span 传播,让 Handler trait bound 错误精确到参数——诊断质量大幅提升。
#[derive(FromRequest)] / [FromRequestParts]:把 struct 字段组合成一个提取器——复用、命名化、可统一 rejection。
#[derive(TypedPath)](axum-extra):URL 模板 + struct 字段绑定——编译期校验、反向 URL 生成、类型化路由。
三个宏共享 axum 的宏哲学:生成标准 Rust impl、span 传播让错误友好、可以用 cargo expand 理解输出、零运行时代价。加上 #[derive(FromRef)](第 18 章已讨论)——就是 axum-macros 最主要的 4 个宏(此外还有 #[debug_middleware] 和 #[derive(FromRequestParts)] 两个同族变体)。每个 macros 的设计都克制——只做一件事、做好它。
这些宏让 axum 的日常开发少了大量 boilerplate、同时保留 Rust 类型系统的所有安全性——没有运行时反射、没有隐式行为、所有行为都是"用户本可以手写的代码"。这是 Rust 元编程的典型好处——自动化而不隐藏。
学完这一章,你应该能:
- 读懂 axum-macros 的源码(知道 debug_handler 怎么工作)
- 写自己的 derive 宏解决项目特定的 boilerplate
- debug "handler trait 不满足" 的错误(知道用
#[debug_handler]) - 设计统一的 rejection 类型(derive + From impl)
- 理解 cargo expand 的输出——不再被"宏里发生了什么"困惑
- 评估每个宏的使用权衡——知道什么时候用、什么时候不用
更深一层的收获是理解 Rust 生态的元编程品味——不发明语法糖、用 trait + derive 达到抽象。这条路线让 Rust 的宏系统既强大又可审查——用户能读源码完全理解工具做什么。这也是 Rust 和 Python/Ruby 元编程最大的区别。
生产经验:如何组织项目的 derive 使用
一个中大型 axum 项目里 derive 的使用模式:
// 必选
#[derive(Clone)] // 所有 state 字段 + 业务类型
#[derive(Debug)] // 调试用
// axum 相关
#[derive(Clone, FromRef)] // AppState
#[derive(Deserialize, Serialize)] // request/response 类型
#[derive(FromRequest)] // 多字段组合提取器
// 可选
#[axum::debug_handler] // 每个 handler 加;release 构建下宏自己原样返回,无需 cfg_attr
使用原则:
- 每个 handler 都
#[axum::debug_handler]——开发时诊断清晰,release 构建不展开 - AppState 一定用
#[derive(FromRef)]——不写 FromRef 意味着 handler 只能提取整个 state、失去子状态的灵活 - 有组合需求才用
#[derive(FromRequest)]——简单 handler 直接列多个 extractor 参数就够
过度使用 derive 也有风险——代码可读性降低、编译时间增加。规律:为重复 > 3 次的模式写 derive、一次性代码手写就好。
不写宏的替代:函数工厂
有时不需要 derive、一个工厂函数就够:
// 想给所有 handler 自动加 tracing——不用 macro、用 Tower Layer
let app = Router::new()
.route("/", get(h))
.layer(tower_http::trace::TraceLayer::new_for_http());
TraceLayer 包装所有 handler 加 span——和 attribute 宏同效、但更灵活(middleware 可组合)。这提醒:有时候 middleware / helper function 比 macro 更合适。宏不是万金油——中间件、泛型、helper 都是并行的选择。
选择 macros 的理由要明确——减少重复 struct/impl 的 boilerplate。如果能用函数、用 closure、用 middleware 达成同样效果——优先选非 macro 方案。macro 的调试难度和学习曲线让它不是最便宜的工具。
axum-macros 的另一个值得记的设计:核心 axum 不依赖它。你完全可以不用 macros 写 axum 代码——手写 FromRequest impl、手写 FromRef impl、不加 #[debug_handler]。功能完整、只是麻烦。macros 是可选的开发体验提升——不是必需的功能。这种分层让 axum 保持 "核心库不强依赖 proc-macro crate" 的轻量结构——不用宏的项目可以少编译一份 proc-macro 依赖。要不要打开 macros feature 由项目自己决定——多数情况下 DX 提升值这点额外依赖。
proc-macro 的编译代价
#[axum::debug_handler] 和 #[derive(FromRequest)] 在每个标注点展开一次——产生几十行额外代码(随参数、字段数增长)。编译单元变大、rustc 需要多做类型检查。项目里 handler 一多,增量编译的变慢是能感知到的——用 cargo build --timings 量一下自己的情况,别照搬别人的百分比。
两个缓解方案。第一是条件编译——#[debug_handler] 自带:release 构建下宏原样返回输入、不生成检查代码,不需要再套 cfg_attr;但日常 debug 构建的增量编译仍要付这份代价。第二是分 crate——把 handler 拆到单独的 crate(比如 app-handlers)——改动只需要重编译这个 crate、不影响其他模块。axum 本体也是这么拆的——axum、axum-core、axum-macros、axum-extra 几个 crate,而不是一个巨 crate;tower 生态同样拆成多个小 crate。
proc-macro crate 本身还有一个隐性成本——它必须单独编译成动态库、在编译期被 rustc 加载,而且它挡在依赖图的前面(用它的 crate 得等它编完)。syn 开了 full + parsing + extra-traits 特性(axum-macros 的 Cargo.toml 里就是这么写的),冷构建时这部分不便宜。cargo build --timings 能看到时间分布——如果 proc-macro 依赖占比高、可以考虑合并 derive crate 或用 macro_rules! 替代简单宏。
macro_rules! 与 proc-macro 的分工
axum 内部同时用两种宏。macro_rules! 写 all_the_tuples! 这种模式——为元组的 1~16 arity 批量生成 impl——代码短、展开快、无额外依赖。proc-macro 写 #[debug_handler] 这种需要解析 syn::ItemFn、按参数逐个生成检查代码的复杂逻辑——能力强、编译成本高。
判断标准:如果输入是固定模式的重复(比如元组展开)——macro_rules! 够用;如果需要理解 Rust 语法结构(解析函数、读字段、提取属性)——上 proc-macro。两者不是替代关系——是不同场景的工具。
下一章讲 axum-extra——和 axum-macros 平行的另一个扩展 crate——里面有 TypedPath(本章讲过)、Cookie、Typed routing helper、Form 的各种变种等。axum-extra 是 "不想放核心但有用" 的功能集合——社区贡献为主。第 20 章带你扫一遍 axum-extra 的内容清单、讨论生态的组织策略。