Skip to content

第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::ItemFnsyn::ItemStructsyn::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 字段——字段改名、模板里也要改、编译期校验。

二、反向生成 URLPostPath { 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_strcaptures——按占位符顺序写。

三、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) 背后的逻辑:

  1. 读 handler 的第一个参数类型——得到 UserPath
  2. 读 UserPath::PATH 常量——得到 "/users/{user_id}"
  3. .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-macrosserde-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:13

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。结果是 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.rsVec<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:对应 fnstructenum 定义的 AST。最外层 item。

syn::Fields:struct 的字段集合。Fields::Named(有名)/ Fields::Unnamed(tuple struct)/ Fields::Unit(unit struct)。

syn::Type:类型表达式——可能是 PathTupleReference 等。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 里放了 FromRequestFromRefdebug_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 的内容清单、讨论生态的组织策略。

基于 VitePress 构建