Axum 设计与实现

第19章 axum-macros:debug_handler、FromRequest derive、TypedPath

作者 杨艺韬 · 12,748 字 · 发布于 · 更新于

前面 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-routing feature;生成的代码也直接引用 ::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-routing feature 会把 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<_> and Form<_> 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 的内容清单、讨论生态的组织策略。