Appearance
第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 宏(axum-extra)——给 struct 加路径模板、生成类型化路由
这些宏有共同的哲学:不发明新语法、只减少 boilerplate。它们生成的代码和用户手写是一样的——宏只是把模板化代码自动化。本章深入这些宏的展开逻辑——理解了你就能写自己的类似宏、或者理解编译错误时发生了什么。
为什么要有 axum-macros
第 5 章讨论过 Handler trait 的一个痛点——trait bound 错误报一片候选列表、不告诉你哪个参数不对。第 11 章的 #[derive(FromRef)]——省下逐字段写 FromRef impl 的 boilerplate。第 6 章讲 FromRequest / FromRequestParts 可以通过 struct 组合。这些需求有共性:避免重复代码 + 改善错误信息——都是元编程(元编程)的经典用途。
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。
三者的典型使用流:
rust
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 的 blanket impl 候选列表,不告诉你哪个参数错了。#[debug_handler] 就是为这个问题设计的。
看 axum-macros/src/debug_handler.rs:11-89 的 expand 函数:
rust
// axum-macros/src/debug_handler.rs:11-89 (简化)
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_impls_into_response = check_output_impls_into_response(item_fn);
let check_inputs_and_future_send = /* ... */;
quote! {
#item_fn // 1. 保留原函数不动
#check_extractor_count // 2. 生成独立的 "检查器"
#check_path_extractor
#check_output_impls_into_response
#check_inputs_and_future_send
}
}核心思想:宏展开后不改 handler 本身——只额外生成几段独立的 "检查器代码"。每段检查器验证 handler 的一个特定方面(参数数量、返回类型、每个参数的 trait bound、Future Send 性)。
每段检查器都是一个独立函数——这是关键。handler 真正的 Handler trait 失败时报一个笼统错误、但检查器失败时每个检查的错误独立定位到对应的参数或返回类型——用户看到的错误精确指向问题位置。
check_inputs_impls_from_request:逐参数检查
简化版本的代码:
rust
// debug_handler.rs 的精神
fn check_inputs_impls_from_request(item_fn: &ItemFn, state_ty: &Type, kind: FunctionKind) -> TokenStream {
let inputs = /* ... */;
let mut output = TokenStream::new();
for (idx, input) in inputs.iter().enumerate() {
let ty = &input.ty;
// 生成一个独立函数断言这个参数满足 FromRequestParts bound
let assertion = quote_spanned! { ty.span() =>
#[allow(warnings)]
fn __axum_macros_check_param_#idx()
where #ty: ::axum::extract::FromRequestParts<#state_ty> + Send
{}
};
output.extend(assertion);
}
output
}每个参数生成一个独立函数(名字 __axum_macros_check_param_0、__1、__2)——函数体为空、但 where 子句断言该参数类型满足提取器 trait。
如果参数不满足——编译器报错指向那个 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:确保 handler 参数数不超过 16(Handler trait 的 blanket impl 上限)。
check_path_extractor:检查 Path<T> 只用一个——多个 Path 会互相覆盖 URL 参数、是 bug。
check_output_impls_into_response:断言 handler 返回类型满足 IntoResponse bound。返回类型错也能精确指位置。
check_future_send:断言 handler 的 Future 是 Send——async multi-thread 必须。
每个检查都遵循"生成独立函数 + span 传播"的模式。组合起来让 handler 的所有常见错误都被精确定位。
错误就像 "PgPool 不是 FromRequestParts——你大概想 State<PgPool>"——而不是 "Handler<_, _> not satisfied by fn(PgPool, Json<B>)"——诊断质量天壤之别。
#[debug_handler] 能捕获的错误类型
debug_handler 能精确报错的场景:
一、参数类型不是提取器:
rust
#[axum::debug_handler]
async fn h(db: PgPool, ...) { }
// 错误: "PgPool: FromRequestParts<()> is not satisfied" 精确指向 db 参数二、返回类型不是 IntoResponse:
rust
#[axum::debug_handler]
async fn h() -> SomeCustomType { }
// 错误: "SomeCustomType: IntoResponse is not satisfied" 指向返回类型三、多个 Path 参数:
rust
#[axum::debug_handler]
async fn h(Path(a): Path<u64>, Path(b): Path<String>) { }
// 错误: 检测到两个 Path 参数、自动识别为错误四、handler 的 Future 不是 Send:
rust
#[axum::debug_handler]
async fn h(State(x): State<NotSend>) { }
// 错误: "Future is not Send because NotSend is not Send"这几类错误覆盖 handler 常见 90% 的类型错。生产推荐:所有 handler 加 #[axum::debug_handler]——通过 cfg_attr(debug_assertions, ...) 让它只在 debug 构建生效、release 不加(避免编译慢):
rust
#[cfg_attr(debug_assertions, axum::debug_handler)]
async fn my_handler(/* ... */) { /* ... */ }开发时错误精确、发布时无额外开销——两全。
#[derive(FromRequest)]:struct 字段组合提取器
FromRequest derive 让 struct 自动实现提取器——字段由多个提取器组合而成:
rust
use axum_macros::FromRequest;
#[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 有完整实现。核心逻辑:
rust
// 概念等价代码
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 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, path, query, body })
}
}生成的代码和第 5 章讲的 impl_handler! 宏展开几乎一样——前 N-1 个字段用 from_request_parts、最后一个用 from_request、失败统一 into_response 短路。
两种 derive 模式
#[derive(FromRequest)] 有两种使用模式:
模式一:字段组合(上面的例子)。每个字段都是一个提取器——derive 调用各自的 FromRequest/FromRequestParts。
模式二:via attribute。让整个 struct 当作一个已有提取器处理:
rust
#[derive(FromRequest)]
#[from_request(via(axum::Json))]
struct MyData { /* ... */ }
// 等价于从 body 反序列化成 MyData 的 Json 提取from_request_mod.rs 的 via 分支——如果标记了 #[from_request(via(X))]——生成的 impl 直接转发给 X 的 FromRequest:
rust
impl FromRequest<S> for MyData {
type Rejection = <Json<MyData> as FromRequest<S>>::Rejection;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(inner) = Json::<Self>::from_request(req, state).await?;
Ok(inner)
}
}这让 FromRequest 可以"套"在 struct 外——比如自定义错误响应、或者给 struct 附加提取逻辑。
rejection 自定义
derive 支持自定义 rejection 类型(对提取失败的响应做定制):
rust
#[derive(FromRequest)]
#[from_request(rejection(MyAppError))]
struct MyExtractor {
user: State<User>,
data: Json<Data>,
}任何字段提取失败——生成代码把错误转成 MyAppError(via From bound)。这让大项目里的错误响应统一——不同提取器的默认 rejection 不同(PathRejection、JsonRejection 等),用 MyAppError 聚合成单一类型。
#[derive(FromRequest)] 的几种模式可视化
默认行为 + 两个可选属性(via、rejection)——组合出四种 derive 模式。每种都自动推导 state 类型——用户不显式标 state 时 derive 看字段猜。
这种"基础 + 可选属性"的 API 设计是 Rust derive 宏的典型——默认合理、定制点清晰。用户不需要学全部可选项——只在需要时加 attribute。
#[derive(TypedPath)]:类型化路由
axum-extra 提供的 TypedPath——给 struct 配 URL 模板:
rust
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 常量:
rust
impl TypedPath for PostPath {
const PATH: &'static str = "/users/{user_id}/posts/{post_id}";
}二、Display impl:
rust
impl std::fmt::Display for PostPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "/users/{}/posts/{}", self.user_id, self.post_id)
}
}从 path 模板生成 format_str 和 captures——按占位符顺序写。
三、FromRequestParts impl:
rust
impl<S: Send + Sync> FromRequestParts<S> for PostPath {
type Rejection = /* ... */;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let Path(path) = Path::<Self>::from_request_parts(parts, _state).await?;
Ok(path)
}
}直接通过 Path<Self> 转发——Deserialize impl 由用户或 #[derive(Deserialize)] 提供。
三个 impl 配合让 TypedPath 既能作为 URL 模板(TypedPath trait)、也能格式化成 URL(Display)、也能从请求提取(FromRequestParts)。一个 derive、三种能力。
parse_path:模板解析
typed_path.rs 里的 parse_path 函数解析 /users/{user_id} 这样的字符串:
rust
enum Segment {
Static(String), // 字面段 "users"
Capture(String), // 捕获 "{user_id}" 对应 field "user_id"
CaptureAll(String), // 通配 "{*rest}"
}
fn parse_path(lit: &LitStr) -> Result<Vec<Segment>, Error> {
let s = lit.value();
// 按 / 拆分, 每段识别 {xxx} 或字面
}这个解析器是 编译期 跑的——在宏展开时处理字符串。生成的代码里模板是具体的——没有运行时字符串 parse。
TypedPath 的工程价值
和普通 Path<u64> + get("/users/{user_id}") 写法对比:
普通写法:
rust
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 写法:
rust
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 配合 axum-extra::routing::TypedGet(以及 TypedPost、TypedPut、TypedDelete 等)让 Router 注册更类型化:
rust
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) 背后的逻辑:
- 读 handler 的第一个参数类型——得到 UserPath
- 读 UserPath::PATH 常量——得到 "/users/{user_id}"
.route(path, get(handler))注册
一个扩展 trait 的方法。源码在 axum-extra——RouterExt::typed_get(self, handler) 方法内部调 self.route(P::PATH, get(handler))。
这种写法的收益在大型项目:路由和 handler 定义在一起——不用在 build_router 的地方重新列出路径和 handler 的对应关系。但小项目仍然 Router::new().route("/users/{id}", get(get_user)) 更直接。
TypedPath 的限制
derive(TypedPath) 不支持 generics(typed_path.rs:16-21 明确 reject)。原因是 PATH 是 &'static str 常量——编译期确定——generic 参数在编译期不一定能决定 path 字面。
如果你需要多个"同 pattern 不同子路径"的 handler——需要为每种子路径写独立 struct:
rust
#[derive(TypedPath)]
#[typed_path("/v1/users/{id}")]
struct V1UserPath { id: u64 }
#[derive(TypedPath)]
#[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-macros |
|---|---|---|
| 生成代码复杂度 | 简单(几个 trait impl) | 复杂(visitor pattern、状态机) |
| 用户自定义空间 | 有(via、rejection attribute) | 极大(rename、skip、tag 等几十种 attr) |
| 跨平台 | 只生成 Rust | 支持任何 Serializer |
| 运行时多态 | 无(单态化) | visitor trait object |
| 学习成本 | 低 | 中高 |
Serde 因为要支持"任意 Serializer"(JSON、YAML、CBOR、MessagePack)——derive 生成的代码更复杂、有 visitor 抽象层。axum 只需"从 Request 产出 Self"——单一场景、生成代码简洁。
两个库的 macro 风格各有特色——axum 偏"模板化、直接"、serde 偏"通用、抽象层多"。都是成熟设计——适用场景不同。
实战:写一个自己的 derive
写一个 #[derive(Logged)] 给 struct 自动生成"log 所有字段"的方法:
rust
// 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 类型:
rust
#[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 ...——泛用。
这个推导在 from_request/mod.rs 的 state.rs 逻辑里——根据字段扫描自动决定 impl 的泛型。用户不用手动标注 state 类型——derive 根据 struct 内容智能选择。
rejection 的统一错误类型
derive 生成的错误类型默认是 Response(任何提取器失败都统一转 Response)——但可以显式指定:
rust
#[derive(FromRequest)]
#[from_request(rejection(MyError))]
struct MyExtractor { /* ... */ }
// 要求 From<PathRejection> for MyError
// 要求 From<JsonRejection> for MyError
// 等等derive 生成的代码会 .map_err(MyError::from)?——把各种默认 rejection 转成统一的 MyError。MyError 自己 impl IntoResponse 决定最终响应。
这让大项目里所有提取错误走统一路径——日志格式、响应格式、错误码都一致。
宏错误的 debugging 技巧
写 / 用 proc macro 时 debug 的几个工具:
cargo expand:把所有宏展开成真实 Rust 代码——看到宏实际产生什么:
bash
cargo install cargo-expand
cargo expand --bin my_server > expanded.rs看 expanded.rs 能发现 derive 生成的具体 impl——很多 "为什么 trait impl 不满足" 的疑惑可以通过读展开代码解决。
proc-macro-error:让宏生成更详细错误消息的 crate。axum-macros 自己也用类似技巧。
trybuild:测试 proc macro 的标准工具——写预期编译失败的测试用例、比对编译输出和期望的错误信息。axum-macros 的 test suite 用 trybuild 保证错误信息质量。
RUSTFLAGS="-Zmacro-backtrace"(nightly):错误里显示宏展开的路径——虽然 nightly 限定,但调试 macro 问题值得一试。
一个经常被忽视的细节:hygiene
proc_macro 的 hygiene 是指"宏生成的变量名不会和用户代码的变量名冲突"。axum-macros 用 format_ident! 生成独特名字:
rust
let check_name = format_ident!("__axum_macros_check_param_{}", idx);__axum_macros_ 前缀 + index——几乎不可能和用户变量冲突。这是 proc macro 的惯用做法——通常以 crate 名或下划线为前缀。
如果自己写宏没注意 hygiene:
rust
// ❌ 可能和用户的变量 x 冲突
quote! { let x = #value; /* ... */ }用户如果在宏 insertion 点已经有 x——生成的代码 compile error 或 shadow 用户的 x。安全做法是用唯一 ident:
rust
// ✅ 不冲突
let unique = format_ident!("__my_macro_temp_{}", idx);
quote! { let #unique = #value; /* ... */ }axum-macros 所有生成的局部变量都遵守这条。
axum-macros 生态全景
一张图展示 axum-macros 在整个 axum 生态里的位置:
- axum crate:核心 trait 定义
- axum-macros:针对核心 trait 的 boilerplate reduction
- axum-extra:扩展能力(TypedPath、TypedHeader 等)——内部可能用 axum-macros 的技术
三层分工清晰——用户按需依赖。核心 axum 不依赖 macros(macros 是可选 feature)——保持简洁。
trybuild 测试:保证错误消息质量
写 proc macro 时最难测的是错误情况——"当输入是这样时、生成的编译错误是这样"。用 trybuild:
rust
// axum-macros/tests/debug_handler.rs
#[test]
fn compile_fails() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/debug_handler/fail/*.rs");
}测试目录 tests/debug_handler/fail/ 里每个 .rs 文件是一个预期 compile error 的例子:
rust
// tests/debug_handler/fail/not_extractor.rs
#[axum::debug_handler]
async fn h(db: sqlx::PgPool) {} // PgPool 不是 FromRequestParts对应的 .stderr 文件存期望的错误消息:
text
// tests/debug_handler/fail/not_extractor.stderr
error[E0277]: the trait bound `sqlx::PgPool: FromRequestParts<()>` is not satisfied
--> tests/debug_handler/fail/not_extractor.rs:3:13trybuild 跑这些测试——编译每个 fail 文件、对比实际输出和期望——不一致测试失败。这让错误消息是被测试覆盖的代码——改宏不会意外破坏错误消息质量。
这种 "error snapshot testing" 在 proc macro 开发里很有价值——因为错误消息才是用户体验的核心。
宏在开发体验中的作用
回头看 axum-macros 的整体价值——它是开发体验的工具而不是功能实现:
- 没有 axum-macros:axum 功能完整、但错误信息差、boilerplate 多
- 有 axum-macros:同样的功能、但 debug 容易、代码短
这个定位让 axum-macros 必须是可选的(通过 Cargo feature)——用户可以关掉 proc-macro 依赖、只用 axum 核心 trait 手写 impl。结果是 axum 核心库对极简依赖的场景友好(比如 embedded Rust 环境、WASM build)——而需要 DX 的生产项目默认开启 axum-macros。
这是 Rust 库设计的良好做法——核心 lean、扩展可选。用户决定自己需要多少工具。
proc-macro 2.0 和 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:Vec<T> 的迭代——让你知道当前元素是第一个、中间、还是最后一个。用法:
rust
// 简化 API
for item in items.iter().with_position() {
match item {
Position::First(v) => /* 第一个 */,
Position::Middle(v) => /* 中间 */,
Position::Last(v) => /* 最后 */,
Position::Only(v) => /* 只有一个 */,
}
}用途:生成 FromRequest derive 时需要区分 "最后一个字段" 和 "其他字段"——逻辑不同(FromRequest vs FromRequestParts)。with_position 让这个区分代码清爽。
attr_parsing.rs:解析 #[my_attr(key = value, key2)] 这种 attribute 语法的 helper。axum-macros 所有 derive 都用它——parse_attrs 方法。
axum_test.rs:给 #[crate::test] 宏加上运行时特性——让测试可以用 axum 特定的工具(比如 TestClient)。内部测试用。
这些 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 的代码约 95% 时间在用这些类型——parse + 分析 + 生成三步循环。看多了会发现 proc macro 其实是 "树变换"工作——AST input → AST output。
实用场景:自定义 rejection 的模式
生产项目里 #[derive(FromRequest)] 配合自定义 rejection 的模式:
rust
// 统一错误类型
#[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()) }
}
// 所有提取器用 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:宏会让编译变慢吗?
会——每个宏调用都是编译期工作。axum-macros 的 derive 每个大约增加几 ms 到几十 ms 编译时间。整个项目几十个 derive 加起来可能让编译慢 1-2 秒——可接受。
Q:能调试宏展开吗?
cargo expand --bin myapp 展示展开后代码——读代码能发现"为什么生成了这个"。IDE(rust-analyzer)也能展示宏展开——hover on #[derive(FromRequest)] 查看生成代码。
Q:宏里写错了怎么办?
axum-macros 用 syn::Error::into_compile_error() 把内部错误转成编译期错误——你看到的错误是中文 / 英文的 syntax 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> 类型不同——derive 报错要求用户用 #[axum::debug_handler(state = MyState)] 显式指定。
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 = ...)]、#[typed_path(rejection = ...)] 都是"默认 + 可选加 attr"的好例子。
三、错误消息投资:宏生成错误的地方都加 quote_spanned! 带 span——让错误指回用户代码。这是用户体验投资——值得。
四、读 cargo expand 验证:写完宏后跑 cargo expand 确认生成代码合理——没生成无意义代码、没 capture 用户变量(hygiene)。
五、文档里展示展开结果:写宏的文档时带一个"展开后的代码"展示——让用户知道宏做什么。axum-macros 的 #[derive(FromRef)] 文档就有"等价的手写 impl"示例。
这些经验都是为了让宏"可理解"——Rust 宏的 power 在于可审查、不是像某些动态语言的反射那样"魔法"。
跨书关联:元编程生态
《Serde 元编程》第 4 章深入讨论 derive macro 的设计——serde-derive 的复杂度远超 axum-macros、但核心技术一致(parse + analysis + generate)。读那本书后再看 axum-macros 会觉得后者简洁——因为 axum 的需求比 Serde 简单。
另一本相关:《Tokio 源码深度解析》第 16 章讨论 tokio 的 macros——#[tokio::main]、tokio::select!——和 axum-macros 风格类似(简单模板化)但用途不同。
三个 crate 的 macros 都遵循同一个 Rust 元编程哲学:生成你本来就会写的代码、不发明新范式。这让 Rust 的宏生态比 Python decorators、Ruby metaclass 等动态语言的元编程更容易理解——没有 runtime 魔法、所有行为都在编译期可见。
axum-macros 的版本演进
几个版本节点值得记住:
axum 0.5 之前:axum-macros 还不成熟——只有 #[debug_handler] 一个宏。用户对 handler error 的不满驱动了它的诞生。
axum 0.6:加入 #[derive(FromRef)]——因为多字段 state 场景越来越多、手写 impl 痛苦。
axum 0.7:加入 #[derive(FromRequest)]——大家发现多个提取器组合是常见 pattern、需要工具化。
axum-extra 稳定 TypedPath:作为 axum-extra 一部分——因为类型化路由不是人人需要、放在 axum-extra 让 axum 核心保持简洁。
axum 0.8:所有 macros 保持稳定——小改进集中在错误消息质量。
这个演进节奏反映 Rust 库的成熟过程——先观察社区需求、后加工具。axum 没有一开始就发明一大堆 derive——只在明确用户痛点时加。这种克制让每个 macro 都有真实价值、避免"over-engineering"。
核心库 vs 扩展库的分工
axum-macros 里放了 FromRequest、FromRef、debug_handler——这些是和核心 axum 紧密耦合的。但 TypedPath 放在 axum-extra——虽然也是 macro、但更偏"便利工具"。
区分标准:
- 核心 macros (axum-macros):解决 axum trait 使用中的痛点(错误信息、trait impl boilerplate)
- 扩展 macros (axum-extra):提供额外能力(类型化路由、typed headers)
这种分层让 axum 核心保持精简——用户不用装 TypedPath 也能完整用 axum。需要时再加 axum-extra 依赖——不强制。
自定义 derive 的完整案例
一个实用例子——给 axum handler 自动加 tracing span:
rust
// 假想的 tracing_handler_derive crate
use proc_macro::TokenStream;
use quote::quote;
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);
async move #original_block.instrument(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:
rust
#[tracing_handler(level = debug, fields(user_id = %self.user_id))]
async fn handler(/* ... */) { /* ... */ }生产项目里这类 "项目级 derive / attribute" 很常见——axum-macros 展示了写法、各项目按需 copy pattern。
深入 debug_handler:check_input_order
debug_handler 还做一件事——检测参数顺序:
rust
// ❌ FromRequest 参数不在最后
#[debug_handler]
async fn bad(Json(a): Json<A>, Path(b): Path<u64>) { /* ... */ }debug_handler 会报错——"FromRequest must be the last parameter"。原因:这个错误的根本问题不是 bound、是位置——Json 是 FromRequest 而不是 FromRequestParts、不能放中间位置。
简化的实现逻辑:
rust
// debug_handler.rs 的精神
fn check_input_order(item_fn: &ItemFn) -> Option<TokenStream> {
let inputs: Vec<_> = item_fn.sig.inputs.iter().collect();
for (i, input) in inputs.iter().enumerate().take(inputs.len() - 1) {
// 非最后参数——断言是 FromRequestParts 而不是 FromRequest
let ty = type_of(input);
if looks_like_from_request_only(&ty) {
return Some(quote_spanned! { ty.span() =>
compile_error!("FromRequest must be the last parameter");
});
}
}
None
}识别 "这个类型只实现 FromRequest 不实现 FromRequestParts" 不容易——需要启发式(知道哪些类型是常见 body-only 提取器)。debug_handler 的实际实现有一系列 heuristic——covers 常见 case(Json、Form、Bytes、String)。对未知类型不报——留给 trait bound check 处理。
这种"针对常见错误的专门检查"让 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 章已讨论)——完整的 4 个主要 macros 构成 axum-macros 的全部。每个 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 的使用模式:
rust
// 必选
#[derive(Clone)] // 所有 state 字段 + 业务类型
#[derive(Debug)] // 调试用
// axum 相关
#[derive(Clone, FromRef)] // AppState
#[derive(Deserialize, Serialize)] // request/response 类型
#[derive(FromRequest)] // 多字段组合提取器
// 可选
#[axum::debug_handler] // 每个 handler 加(cfg_attr cfg'd on debug_assertions)使用原则:
- 每个 handler 都
#[cfg_attr(debug_assertions, axum::debug_handler)]——开发时诊断清晰 - AppState 一定用
#[derive(FromRef)]——不写 FromRef 意味着 handler 只能提取整个 state、失去子状态的灵活 - 有组合需求才用
#[derive(FromRequest)]——简单 handler 直接列多个 extractor 参数就够
过度使用 derive 也有风险——代码可读性降低、编译时间增加。规律:为重复 > 3 次的模式写 derive、一次性代码手写就好。
不写宏的替代:函数工厂
有时不需要 derive、一个工厂函数就够:
rust
// 想给所有 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" 的轻量结构——适合 WASM、embedded 等极端环境。生产项目大多数开启 axum-macros feature——DX 提升值这点额外依赖。
proc-macro 的编译代价
#[axum::debug_handler] 和 #[derive(FromRequest)] 在每个标注点展开一次——产生几十到几百行额外代码。编译单元变大、rustc 需要多做类型检查。大型项目(几百个 handler)能感知到增量编译变慢 10%-30%。
两个缓解方案。第一是条件编译——#[cfg_attr(debug_assertions, axum::debug_handler)] 只在 debug 启用、release 构建不展开。第二是分 crate——把 handler 拆到单独的 crate(比如 app-handlers)——改动只需要重编译这个 crate、不影响其他模块。axum 本体和 tower 生态都走这条路——几十个小 crate 而非一个巨 crate。
proc-macro crate 本身还有一个隐性成本——它必须单独编译成 dylib 在编译期被 rustc 加载。冷构建时 syn + quote + proc-macro2 这三个依赖占几秒编译时间。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 的内容清单、讨论生态的组织策略。