Axum 设计与实现

第4章 嵌套、合并与回退:路由组合模式

作者 杨艺韬 · 13,093 字 · 发布于 · 更新于

从五条路由到五十条:模块化的三个问题

你在第2章搭建了第一个 Router,在第3章掌握了 MethodRouter 如何把 HTTP 动词分派到不同处理器。当项目还小,一个 Router::new() 链式调用 .route() 五次就够了。但现实项目不会停在五条路由——用户模块有 /users、/users/{id}、/users/{id}/posts;订单模块有 /orders、/orders/{id}/items;管理后台有 /admin/dashboard、/admin/users……五十条路由写在一个函数里,代码可读性急剧下降,模块边界模糊,团队协作时 merge 冲突频发。

更深层的问题在于认知负载。当你维护一个包含五十条路由的巨型 Router 时,每次修改都需要理解整个路由表的全貌——因为路由之间可能存在路径冲突、方法冲突、fallback 行为覆盖等隐式依赖。如果能把路由拆成独立模块,每个模块只关注自己的七八条路由,开发者的认知负载会大幅降低。这就是模块化的核心价值:不是代码行数的减少,而是关注点的隔离。

你需要把一个巨大的 Router 拆成多个子模块,然后在顶层组装。这引出三个具体问题:

  1. 前缀挂载:如何把一个子 Router 挂到某个路径前缀下?例如把 users_router 挂到 /api/users,使其内部路由 / 和 /{id} 自动变成 /api/users 和 /api/users/{id}。子模块的代码不需要知道自己被挂在哪里,路径前缀由顶层组装者决定。
  2. 扁平合并:如何把两个独立的 Router 合成一个,路由表平铺、前缀不变?例如 api_v1_router 和 api_v2_router 各自有完整路径,合并后共享同一个路由树。这种模式适合路由在不同 crate 或不同文件中定义,最终在 main 函数中汇总。
  3. 兜底处理:当请求不匹配任何已注册路由时,如何自定义 404 或其他响应?这在单页应用(SPA)场景中尤为常见——前端路由处理的路径不应该在后端返回 404。

Axum 分别用 nest、merge、fallback 三组 API 回答这三个问题。表面上看它们只是三个方法调用,但源码层面的设计选择——前缀剥离、fallback 丢弃、冲突检测——才是真正的知识密度所在。不理解这些底层机制,你会在遇到 panic 时不明所以,在遇到 fallback 不生效时无从排查。接下来我们逐个拆开。

nest:前缀挂载与路径剥离

宏观理解:nest 做了什么

Router::nest(path, router) 把 router 作为子路由挂载到 path 前缀下。一个典型的用例:

let users_router = Router::new()
    .route("/", get(list_users))
    .route("/{id}", get(get_user));

let app = Router::new()
    .nest("/api/users", users_router);

当请求 /api/users/42 到达时,nest 会先剥离前缀 /api/users,把剩余路径 /42 交给 users_router 处理。这个"剥离前缀"是 nest 区别于手动拼写完整路径的核心机制。子模块 users_router 的代码完全不包含 /api/users 这个前缀——它只关心自己的相对路径 / 和 /{id}。前缀在组装时才确定,模块在开发时无需感知。这种解耦让同一个子 Router 可以在不同前缀下复用,比如同一个 users_router 既挂在 /api/users 下对外的 API 使用,也挂在 /internal/users 下给内部微服务调用。

sequenceDiagram
    participant C as Client
    participant R as Router (顶层)
    participant N as nest 前缀匹配
    participant S as 子 Router
    participant H as Handler
    C->>R: GET /api/users/42
    R->>N: 匹配前缀 /api/users
    N->>N: 剥离前缀,剩余路径 = /42
    N->>S: 请求 URI 改写为 /42
    S->>S: 路由匹配 /{id}
    S->>H: 调用 get_user(id=42)
    H-->>C: 200 OK

源码入口:Router::nest

nest 的实现在 axum/src/routing/mod.rs:220-237。我们逐行拆解:

pub fn nest(self, path: &str, router: Self) -> Self {
    if path.is_empty() || path == "/" {
        panic!("Nesting at the root is no longer supported. Use merge instead.");
    }

    let RouterInner {
        path_router,
        default_fallback: _,
        catch_all_fallback: _,
    } = router.into_inner();

    tap_inner!(self, mut this => {
        panic_on_err!(this.path_router.nest(path, path_router));
    })
}

第一个关键点:path 为空或 / 会直接 panic。Axum 的设计立场是——根级别组合应该用 merge,nest 只做前缀挂载。这不是技术限制,而是语义约束:nest 的核心语义是"剥离前缀再分派",如果前缀为空,剥离动作无意义,容易产生歧义。早期版本允许 nest("/", router),0.8.0 起(PR #3051)改成了 panic。PR 给出的理由很简短:这样写容易让人困惑,根上其实没有发生任何嵌套;merge 完全可以替代 nest("" | "/", ...),fallback_service 完全可以替代 nest_service("" | "/", ...)。改成 panic 就是强迫开发者显式选择语义正确的方法。

第二个关键点:子 Router 的 catch_all_fallback 被丢弃。注意解构时的 catch_all_fallback: _,以及注释(axum/src/routing/mod.rs:228-230):

we don't need to inherit the catch-all fallback. It is only used for CONNECT requests with an empty path. If we were to inherit the catch-all fallback it would end up matching /{path}/* which doesn't match empty paths.

这段注释揭示了 Axum 对 fallback 的精细考量,但要读懂它必须先分清 fallback 在 Router 里的两副身份。Router::fallback 干了两件事(axum/src/routing/mod.rs:336-346):一是给 RouterInner::catch_all_fallback 赋值,二是调 fallback_endpoint,往 PathRouter 里注册两条真实路由——/ 和 /{*__private__axum_fallback}。回顾 Router::call_with_state(axum/src/routing/mod.rs:452-462),catch_all_fallback 只在 PathRouter 连那两条兜底路由都没匹配上时才被调用。

被 nest 丢弃的只是第一副身份——catch_all_fallback 字段。第二副身份(PathRouter 里那两条路由)会跟着子 Router 的其他路由一起被展平进父路由表:path_for_nested_route("/api/users", "/") 得到 /api/users,/{*__private__axum_fallback} 得到 /api/users/{*__private__axum_fallback}。所以子 Router 的 fallback 对该前缀下的路径依然生效。

但主线上有一个例外。fallback_endpoint 注册这两条路由时用 _ = 丢掉了返回值(axum/src/routing/mod.rs:399、:419),插入冲突会被静默吞掉。matchit 0.9.2 认为同一层的 /{id} 与 /{*__private__axum_fallback} 相互冲突,所以子 Router 若在根一层已有 /{id} 这样的参数路由,它的通配兜底路由根本注册不上,挂在 /n 下之后,/n/a/b 这类路径会落到父 Router 的 fallback。反过来,先 .fallback()、再 .route("/{id}", ..),会在注册时 panic:Invalid route "/{id}": Insertion failed due to conflict with previously registered route: /{*__private__axum_fallback}。这两点都是主线实测,0.8.9 上都不会出现。

那丢掉 catch_all_fallback 影响的是什么?就是注释里说的那个场景:它只服务于"路径为空的 CONNECT 请求"。如果把它继承上来,它在父级会以 /{path}/* 的形式存在,而 * 通配符匹配不了空路径,等于白继承一场。

所以准确的行为是这样的:

  • 子 Router 有自己的 fallback → 前缀下未匹配的路径走子 fallback。测试 doesnt_inherit_fallback_if_overridden(axum/src/routing/tests/fallback.rs:96-110)断言 /foo/bar 拿到 "inner"、/ 拿到 "outer";nest_fallback_on_inner(同文件 179-195)断言 /foo/not-found 拿到 "inner fallback"
  • 子 Router 没有自己的 fallback(default_fallback 还是 true,fallback_endpoint 从没被调用过)→ 它没往 PathRouter 里注册任何兜底路由,请求会一路落到父 Router 的 fallback。测试 nested_router_inherits_fallback(同文件 84-94)断言的就是这个
  • 多层嵌套时,最近的一层带 fallback 的祖先胜出。测试 deeply_nested_inherit_from_middle(同文件 125-139)演示了中间层的 fallback 接管更深层的未匹配路径

版本口径:上面讲的「两副身份」——fallback 以 / 和 /{*__private__axum_fallback} 两条普通路由住在 PathRouter 里——是主线的实现,来自 PR #3158(CHANGELOG Unreleased 段第一条「breaking: Router fallbacks are now properly merged for nested routers」),不在 crates.io 的 axum 0.8.9 里。0.8.9 的 RouterInner 还有一个独立的 fallback_router: PathRouter<S, true> 字段,fallback_endpoint 把兜底路由注册在它里面;Router::call_with_state 先查 path_router,再查 fallback_router,最后才轮到 catch_all_fallback;nest 遇到设过 fallback 的子 Router,会把它的 fallback_router 也加上前缀并入父级。所以上面引用的三个测试在 0.8.9 里内容相同,也都能通过,子 Router 的 fallback 在前缀下同样生效。两版能观察到的差别在优先级:0.8.9 上,父 Router 的任何普通路由都排在子 fallback 前面。比如父级有 /{x}/{y},子 Router 挂在 /nest 下且只设了 fallback,那么 /nest/b 在 0.8.9 上命中 /{x}/{y},在主线上命中子 fallback(#3158 新增的测试 nesting_router_with_fallback,两版都实测过)。上一段说的参数路由冲突,以及下文 reset_fallback、fallback_service 在两版上的差别,也都源于这次改动。版本口径的总说明见专栏首页的「源码版本」一节。

PathRouter::nest 的路径重组

nest 的实际路径注册逻辑在 axum/src/routing/path_router.rs:172-210。这是整个嵌套路由的核心引擎:

pub(super) fn nest(
    &mut self,
    path_to_nest_at: &str,
    router: Self,
) -> Result<(), Cow<'static, str>> {
    let prefix = validate_nest_path(self.v7_checks, path_to_nest_at)?;

    let Self { routes, node, v7_checks: _ } = router;

    for (id, endpoint) in routes.into_iter().enumerate() {
        let route_id = RouteId(id);
        let inner_path = node
            .route_id_to_path
            .get(&route_id)
            .expect("no path for route id...");

        let path = path_for_nested_route(prefix, inner_path);

        let layer = (
            StripPrefix::layer(prefix),
            SetNestedPath::layer(path_to_nest_at),
        );
        match endpoint.layer(layer) {
            Endpoint::MethodRouter(method_router) => {
                self.route(&path, method_router)?;
            }
            Endpoint::Route(route) => {
                self.route_endpoint(&path, Endpoint::Route(route))?;
            }
        }
    }
    Ok(())
}

这段代码做了三件事,每一件都值得深入理解。

第一,路径拼接。遍历子 Router 的所有路由,用 path_for_nested_route 把前缀和子路由路径拼在一起。注意这里不是简单地 prefix + inner_path——尾部斜杠的边界情况需要仔细处理。path_for_nested_route 函数(axum/src/routing/path_router.rs:466-477)的逻辑如下:

pub(crate) fn path_for_nested_route<'a>(prefix: &'a str, path: &'a str) -> Cow<'a, str> {
    debug_assert!(prefix.starts_with('/'));
    debug_assert!(path.starts_with('/'));

    if prefix.ends_with('/') {
        format!("{prefix}{}", path.trim_start_matches('/')).into()
    } else if path == "/" {
        prefix.into()
    } else {
        format!("{prefix}{path}").into()
    }
}

三种情况分别处理:

  • 前缀以 / 结尾(如 /api/),子路径 /users 会去掉开头的 / 变成 users,拼成 /api/users——避免出现 /api//users 这种双斜杠路径。matchit 对双斜杠的处理是字面匹配,不会把 /api//users 和 /api/users 视为同一路径,所以必须在拼接时消除。
  • 子路径是 /(根路由),直接用前缀替代——/api + / = /api,而不是 /api/。这是因为 nest 下的根路由代表前缀本身。
  • 否则直接拼接——/api + /users = /api/users。

第二,中间件层叠。每个子路由的 endpoint 都会被 StripPrefix 和 SetNestedPath 两层中间件包裹。StripPrefix 在请求到达子路由处理器之前,把 URI 中的前缀剥离,使子路由处理器看到的路径与定义时一致。SetNestedPath 则在请求扩展中注入 NestedPath,让处理器知道自己在哪个前缀下被挂载。

注意 layer 是一个元组 (StripPrefix::layer(prefix), SetNestedPath::layer(path_to_nest_at))。在 Axum 的 Endpoint::layer 实现中,元组形式的 layer 会从左到右依次应用——请求先经过 StripPrefix(改写 URI),再经过 SetNestedPath(注入元数据),最后到达实际 handler。不过要说清楚:这里的先后并不影响结果。StripPrefix::call 只改写 URI、不碰 extensions(axum/src/routing/strip_prefix.rs:39-44),SetNestedPath::call 只往 extensions 里塞值、不碰 URI(axum/src/extract/nested_path.rs:94-108)——两者动的是请求里互不相干的两个部分,交换顺序 handler 看到的东西完全一样。写成这个次序是可读性上的约定,不是正确性要求。

第三,重新注册。拼接后的路径和包裹后的 endpoint 作为新路由注册到父 Router 的 PathRouter 中。这里有一个容易忽略的事实:nest 的本质不是"运行时分派到子 Router",而是"构建时把子路由展开到父路由表中"。子 Router 作为一个独立实体在 nest 调用后被完全消费(into_inner 拿走内部数据),它的路由被逐条注册到父 Router 的 matchit 树中。这是一个重要的性能决策——请求进来时只做一次 matchit 查找,而不是逐层前缀匹配。无论你嵌套了多少层,运行时都只有一次 matchit 查找(matchit 基于基数树,查找耗时取决于路径长度而非路由条数)。

flowchart TB
    subgraph before["nest 前的子 Router"]
        A1["/ → list_users"]
        A2["/{id} → get_user"]
    end
    subgraph after["nest 后的父 Router 路由表"]
        B1["/api/users → list_users + StripPrefix + SetNestedPath"]
        B2["/api/users/{id} → get_user + StripPrefix + SetNestedPath"]
    end
    A1 -->|path_for_nested_route prefix+'/'| B1
    A2 -->|"path_for_nested_route prefix+'/{id}'"| B2
    style B1 fill:#e8f4e8
    style B2 fill:#e8f4e8

SetNestedPath:让处理器感知挂载位置

SetNestedPath(axum/src/extract/nested_path.rs:66-109)是一个 Tower 中间件,它把 nest 时的前缀路径注入到请求的扩展中。这允许 handler 通过 NestedPath 提取器获取自己被挂载的前缀:

下面是调用侧用法示例,不是 axum 源码原文——list_users 只是示例自取的 handler 名。

NestedPath 本身是个普通提取器:它从请求扩展里取出 SetNestedPath 塞进去的那个前缀,as_str() 返回的就是这个前缀。

// 子模块中的 handler
async fn list_users(nested_path: NestedPath) -> impl IntoResponse {
    // 当此 handler 通过 .nest("/api", users_router) 挂载时
    // nested_path.as_str() == "/api"
}

SetNestedPath::call 的实现(axum/src/extract/nested_path.rs:94-108)处理了一个微妙的场景——多层嵌套时 NestedPath 的累积。如果请求已经携带了上一层的 NestedPath(比如外层 Router 嵌套在 /api 下,内层又嵌套在 /v2 下),新的 NestedPath 不是简单替换,而是拼接:

fn call(&mut self, mut req: Request<B>) -> Self::Future {
    if let Some(prev) = req.extensions_mut().get_mut::<NestedPath>() {
        let new_path = if prev.as_str() == "/" {
            Arc::clone(&self.path)
        } else {
            format!("{}{}", prev.as_str().trim_end_matches('/'), self.path).into()
        };
        prev.0 = new_path;
    } else {
        req.extensions_mut()
            .insert(NestedPath(Arc::clone(&self.path)));
    };
    self.inner.call(req)
}

外层前缀的尾部斜杠被 trim_end_matches('/') 去掉后再拼接内层前缀,确保不会出现 /api//v2 这种路径。而如果上一层的 NestedPath 是 "/"(根路径),则直接替换为当前层的前缀——根路径不需要保留,因为它不贡献任何有意义的路径前缀。

这个机制的典型用途是重定向。当你在子模块中需要生成一个 URL 时,需要知道自己被挂载在哪个前缀下,否则生成的 URL 会缺少前缀部分。NestedPath 让你不需要硬编码前缀,保持子模块的可复用性。

StripPrefix 中间件:前缀剥离的微观实现

StripPrefix(axum/src/routing/strip_prefix.rs)是一个 Tower 中间件,核心逻辑在 strip_prefix 函数(第 47-122 行)。它不仅能处理静态前缀(如 /api),还能处理带路径参数的前缀(如 /api/{version})。

StripPrefix 的 Service 实现(第 26-45 行)很直接——调用 strip_prefix 函数改写 URI,然后调用内层服务:

fn call(&mut self, mut req: Request<B>) -> Self::Future {
    if let Some(new_uri) = strip_prefix(req.uri(), &self.prefix) {
        *req.uri_mut() = new_uri;
    }
    self.inner.call(req)
}

如果前缀不匹配(strip_prefix 返回 None),URI 不做任何修改,请求照常传递给内层服务。这个行为看起来有点奇怪——前缀不匹配时不应该继续处理吗?实际上,在正常使用中 StripPrefix 只会被 nest 注册的路由使用,而那些路由只有在 matchit 匹配成功后才会被调用,所以到达 StripPrefix 的请求一定匹配了前缀。None 分支是一个安全网,确保在最坏情况下请求也不会被丢弃。

strip_prefix 函数的核心是逐段对比前缀和请求路径。处理带参数前缀的关键是 prefix_matches 函数(第 151-157 行):

fn prefix_matches(prefix_segment: &str, path_segment: &str) -> bool {
    if let Some((prefix, suffix)) = capture_prefix_suffix(prefix_segment) {
        path_segment.starts_with(prefix) && path_segment.ends_with(suffix)
    } else {
        prefix_segment == path_segment
    }
}

当 prefix 片段包含捕获组(如 {version}),capture_prefix_suffix 会提取捕获组前后的固定部分。例如 {version} 没有前后缀,所以 prefix="", suffix="",任何片段都能匹配——这是正确的,因为捕获组就是一个"匹配任何值"的占位符。而 v{version} 这种形式,prefix="v", suffix="",只有以 v 开头的片段才匹配——这允许前缀 /api/v{version} 匹配 /api/v1、/api/v2,但不匹配 /api/beta。

capture_prefix_suffix 函数(axum/src/routing/strip_prefix.rs:162-212)本身实现得相当精巧。它需要找到片段中第一个非转义的 { 和 },同时处理 {{ 和 }} 这种转义形式(在 matchit 中,双花括号表示字面量花括号,不是捕获组)。find_first_not_double 辅助函数跳过所有双花括号,找到第一个单独的花括号。在 debug 模式下,如果片段格式异常(有 { 但没有 },或者 { 出现在 } 之后),函数会 panic;在 release 模式下,它返回 None,让路径匹配失败而不是崩溃。这种 debug/release 分裂策略在 Axum 中多次出现——我们后面还会在 take_route_or_internal_error 中看到同样的模式。

strip_prefix 的主循环使用 zip_longest 逐段对比前缀和请求路径,产生三种结果:

  • Both:前缀和路径都有对应片段,调用 prefix_matches 检查是否匹配。如果匹配,累计已匹配的前缀长度;如果前缀片段为空(前缀以 / 结尾,如 /foo/),视为前缀已经匹配完,停止比较;否则返回 None。
  • First:路径比前缀多出片段,说明前缀已完全匹配——截断前缀长度后得到剩余路径,这就是子 Router 应该看到的路径。
  • Second:前缀比路径多出片段,说明路径太短不匹配——返回 None。

最终根据剩余路径是否以 / 开头和是否有查询参数,构造新的 URI:

let new_path_and_query = match (after_prefix.starts_with('/'), path_and_query.query()) {
    (true, None) => after_prefix.parse().unwrap(),
    (true, Some(query)) => format!("{after_prefix}?{query}").parse().unwrap(),
    (false, None) => format!("/{after_prefix}").parse().unwrap(),
    (false, Some(query)) => format!("/{after_prefix}?{query}").parse().unwrap(),
};

四个分支处理了剩余路径是否以 / 开头和是否有查询字符串的组合。注意 false 分支——如果前缀匹配但不以 / 结束、剩余路径也不以 / 开头(前缀恰好匹配到路径末尾,或前缀以 / 结尾时,都会走到这里),会自动补上 /,确保子 Router 始终收到以 / 开头的路径。这是因为 Axum 的路由系统要求所有路径以 / 开头(validate_path 函数会检查),如果子 Router 收到不以 / 开头的路径,后续路由匹配会失败。

nest_service:挂载任意 Service

Router::nest_service(axum/src/routing/mod.rs:241-254)是 nest 的变体,区别在于它接受任意 Service 而非 Router。典型场景是挂载一个第三方服务(如 ServeDir、tonic 的 gRPC Routes):

// 调用侧用法示例,不是 axum 源码
let app = Router::new()
    .nest_service("/assets", ServeDir::new("static"));

nest_service 与 nest 的第一个区别在入口处:当 path 为空或 / 时,它建议使用 fallback_service 而非 merge(axum/src/routing/mod.rs:247-248):

if path.is_empty() || path == "/" {
    panic!("Nesting at the root is no longer supported. Use fallback_service instead.");
}

这不同于 nest 的 "Use merge instead"——因为 nest_service 接受的是 Service 而非 Router,根级别挂载 Service 的语义就是"所有未匹配路由的请求都交给这个 Service",这正是 fallback_service 的职责。

PathRouter::nest_service(axum/src/routing/path_router.rs:212-249)的实现与 nest 有本质不同。因为被挂载的不是 Router 而是 Service,没有"子路由列表"可以展开。Axum 的做法是注册一条通配符路由来捕获前缀下的所有路径:

let path = if path.ends_with('/') {
    format!("{path}{{*{NEST_TAIL_PARAM}}}")
} else {
    format!("{path}/{{*{NEST_TAIL_PARAM}}}")
};

NEST_TAIL_PARAM 定义在 axum/src/routing/mod.rs:123,值为 __private__axum_nest_tail_param。这是一个双下划线开头的私有参数名,设计上不与用户定义的路径参数冲突。当 matchit 匹配到这条路由时,前缀之后的路径会被捕获到这个参数中,但 axum 写入 URL 参数时会把它过滤掉(axum/src/routing/url_params.rs:22,FALLBACK_PARAM 在下一行同样被过滤),handler 用 Path 读不到它。也不需要读:被挂载的 Service 收到的请求 URI 已经由 StripPrefix 剥掉了前缀,剩余路径直接从 URI 里就能拿到。

但通配符 * 不能匹配空路径。这意味着如果请求的路径恰好等于前缀(如 /assets),通配符路由不会命中。因此 nest_service 额外注册了两条路由:

self.route_endpoint(prefix, endpoint.clone())?;
if !prefix.ends_with('/') {
    self.route_endpoint(&format!("{prefix}/"), endpoint)?;
}

第一条注册前缀本身,第二条注册带尾部斜杠的变体。三条路由(前缀、前缀+/、前缀+/*)共同覆盖了所有可能的请求路径。你可以把这三条路由理解为"前缀本身"、"前缀后紧跟尾部斜杠"、"前缀后还有更多路径段"三种情况。

与 nest 不同,nest_service 只注册一个 endpoint(虽然注册了多次),因为这个 Service 需要处理所有到达该前缀的请求。而 nest 会为子 Router 中的每条路由分别注册一个 endpoint。

validate_nest_path:前缀的合法性校验

validate_nest_path(axum/src/routing/path_router.rs:445-464)在 nest 和 nest_service 中都被调用。它检查三条规则(启用 v7 检查时,还会和 validate_path 一样拒绝以 : 或 * 开头的旧语法片段):

  1. 前缀必须以 / 开头——与 validate_path 一致,所有 Axum 路径都必须以 / 开头。
  2. 前缀长度不小于 2(即不能是 / 本身)——根级别嵌套被禁止,需要使用 merge 或 fallback_service。
  3. 前缀中不能包含通配符({*...} 形式)——通配符会匹配不确定数目的路径段,这与前缀剥离的确定性语义矛盾。如果前缀包含通配符,StripPrefix 就无法确定"前缀在哪里结束",剥离操作变得模糊。

这三条规则保证了前缀剥离是一个确定性操作——给定一个前缀和请求路径,剥离结果唯一。确定性是 nest 机制可靠性的基础。

多层嵌套的路径累积

nest 支持多层嵌套,路径拼接是递归的。考虑以下代码:

let users_router = Router::new()
    .route("/", get(list_users))
    .route("/{id}", get(get_user));

let api_router = Router::new()
    .nest("/users", users_router);

let app = Router::new()
    .nest("/api", api_router);

请求 /api/users/42 的处理过程如下:第一层 nest 把子路由展开为 /api/users 和 /api/users/{id}——注意前者没有尾部斜杠:path_for_nested_route 在内层路径正好是 / 时直接原样返回前缀(axum/src/routing/path_router.rs:466-477)。每条路由带有两层 StripPrefix(外层先剥离 /api,内层再从剩下的路径里剥离 /users;实测 handler 看到的 URI 是 /42)。SetNestedPath 累积前缀时对已有值做 trim_end_matches('/') 再拼接(axum/src/extract/nested_path.rs:96-101),最终得到 /api/users。

虽然多层嵌套在功能上完全正常,但需要注意性能影响:每多一层嵌套,每个路由就多一层 StripPrefix 和 SetNestedPath 中间件。在请求处理时,每层中间件都会执行一次 URI 改写和扩展注入。对于大多数应用来说这个开销可以忽略,但在极致性能场景下,你可以考虑用 merge 替代多层 nest,把路径直接写完整,避免中间件栈的深度增长。

merge:扁平合并与 fallback 冲突

merge 的语义

Router::merge(axum/src/routing/mod.rs:258-293)把两个 Router 的路由表扁平合并。与 nest 不同,merge 不改变路径、不添加前缀、不剥离 URI——两条路由表直接拼到一起:

let v1 = Router::new()
    .route("/api/v1/users", get(list_users_v1));

let v2 = Router::new()
    .route("/api/v2/users", get(list_users_v2));

let app = v1.merge(v2);
// 等价于:
// Router::new()
//     .route("/api/v1/users", get(list_users_v1))
//     .route("/api/v2/users", get(list_users_v2))

merge 的泛型签名 merge<R>(other: R) where R: Into<Self> 值得注意。它不只接受 Router<S>,还接受任何能通过 Into 转换为 Router<S> 的类型。这个设计让 merge 更灵活——但它不能用来跨 state 类型。全仓唯一一个目标是 Router<S> 的 From impl 是 impl<S> From<Resource<S>> for Router<S>(axum-extra/src/routing/resource.rs:150),并不存在 From<Router<S1>> for Router<S2> 这种东西,两个 Router 要 merge 就必须是同一个 S。axum 自己的 merge 文档也是这么说的:state 类型不同时,先用 Router::with_state 把一边收敛掉。Into<Self> 这个 bound 真正的用处,是让 Resource 这类「能变成 Router 的东西」直接传进来。

源码解析:fallback 冲突检测

merge 的核心难点不在路由表合并本身,而在 fallback 冲突。看完整实现:

// axum/src/routing/mod.rs:258
pub fn merge<R>(self, other: R) -> Self
where
    R: Into<Self>,
{
    let other: Self = other.into();
    let RouterInner {
        path_router,
        default_fallback,
        catch_all_fallback,
    } = other.into_inner();

    map_inner!(self, mut this => {
        match (this.default_fallback, default_fallback) {
            (_, true) => {}           // other 有默认 fallback → 无冲突
            (true, false) => {        // this 有默认 fallback,other 有自定义 fallback → 采用 other 的
                this.default_fallback = false;
            }
            (false, false) => {       // 双方都有自定义 fallback → panic
                panic!("Cannot merge two `Router`s that both have a fallback")
            }
        };

        panic_on_err!(this.path_router.merge(path_router));

        this.catch_all_fallback = this
            .catch_all_fallback
            .merge(catch_all_fallback)
            .unwrap_or_else(|| panic!("Cannot merge two `Router`s that both have a fallback"));

        this
    })
}

注意 fallback 冲突检测出现了两次——一次在 default_fallback 布尔值的 match 中,一次在 Fallback::merge 的返回值中。这是双重保险:default_fallback 布尔值和 Fallback 枚举的 Default 变体是同一信息的两种表示。理论上只需一处检测,但两处都做确保了即使某处遗漏,另一处也能捕获冲突。

default_fallback 是 RouterInner 的布尔字段(axum/src/routing/mod.rs:100),标记当前 fallback 是否仍是系统装上的那个默认 404。这个默认值是 Router::new() 里塞进 catch_all_fallback 的 NotFound(axum/src/routing/mod.rs:167)。这个字段的存在是为了在 merge 时区分"用户显式设置的 fallback"和"系统默认的 404"——默认 fallback 是"透明"的,merge 时自动让位给自定义 fallback;但两个自定义 fallback 就产生了语义冲突,Axum 选择 panic 而非静默覆盖。

为什么 Axum 不选择"后者覆盖前者"的策略?因为 merge 的参数顺序是 self.merge(other),如果静默覆盖,开发者很难意识到 other 的 fallback 覆盖了 self 的——更糟的是,如果代码中交换了 merge 的顺序,行为就变了,而且很难排查。panic 强迫开发者显式处理冲突,虽然初体验不好,但长期看减少了隐性 bug。

Fallback::merge(axum/src/routing/mod.rs:720-727)的逻辑呼应了这个策略:

fn merge(self, other: Self) -> Option<Self> {
    match (self, other) {
        (Self::Default(_), pick) | (pick, Self::Default(_)) => Some(pick),
        _ => None,  // 双方都是 Service 或 BoxedHandler → 返回 None
    }
}

只要有一方是 Default,就取另一方;否则返回 None,上层调用者用 unwrap_or_else 转 panic。Default 在 merge 中的地位类似于"空值"——它存在但不参与冲突,总是让位给有实际内容的 fallback。

reset_fallback:合并前的显式降级

当你确实需要合并两个都有自定义 fallback 的 Router 时,Axum 提供了 reset_fallback(axum/src/routing/mod.rs:384-389):

pub fn reset_fallback(self) -> Self {
    tap_inner!(self, mut this => {
        this.default_fallback = true;
        this.catch_all_fallback = Fallback::Default(Route::new(NotFound));
    })
}

在合并前对其中一个 Router 调用 reset_fallback(),它的 fallback 就被重置为默认的 404,不再与另一个冲突:

let app = router_a.merge(router_b.reset_fallback());
// router_a 的 fallback 生效,router_b 的 fallback 被丢弃(0.8.9;主线见下文)

这个用法只在 0.8.9 上成立。 0.8.9 的 reset_fallback 还会把独立的 fallback_router 换成一个空的(this.fallback_router = PathRouter::new_fallback()),兜底路由随之清空。主线的 reset_fallback(上面那段代码)只复位两个字段,fallback_endpoint 早先注册进 path_router 的 / 和 /{*__private__axum_fallback} 两条路由原样留着。于是,单独调用 reset_fallback() 之后,这个 Router 对未匹配的路径照样返回原 fallback 的响应;拿它去 merge 时,这两条路由又会和对方的兜底路由撞在同一路径上。fallback(handler) 注册的是两个都带自定义 fallback 的 any(..) MethodRouter,合并时 panic:Cannot merge two `MethodRouter`s that both have a fallback;fallback_service 注册的是 Route,panic 信息是 Invalid route "/": Insertion failed due to conflict with previously registered route: /。以上都是主线实测;同样的写法在 0.8.9 上正常,未匹配的路径交给 router_a 的 fallback。在主线上,要把两个带 fallback 的 Router 合并,只能在构建时就不给其中一个设置 fallback。

reset_fallback 同时做了两件事:把 default_fallback 设为 true(标记为默认),把 catch_all_fallback 替换为 Fallback::Default(Route::new(NotFound))(恢复默认行为)。这两个操作必须同步——如果只改布尔值不改实际 fallback,Fallback::merge 的模式匹配就会出错;如果只改 fallback 不改布尔值,布尔值检测就会漏过冲突。

这个 API 的设计体现了 Axum 的哲学:不静默覆盖,而是让冲突可见。两个自定义 fallback 代表两种不同的错误处理策略,自动选择任何一个都可能让开发者困惑。panic 强迫你显式决定保留哪一个,reset_fallback 给你提供了"主动放弃"的手段。

PathRouter::merge:路由表的物理合并

PathRouter::merge(axum/src/routing/path_router.rs:146-170)做的是路由表的物理级合并:

pub(super) fn merge(&mut self, other: Self) -> Result<(), Cow<'static, str>> {
    let Self { routes, node, v7_checks } = other;

    // 如果任一Router启用了v7检查,合并后也启用
    self.v7_checks |= v7_checks;

    for (id, route) in routes.into_iter().enumerate() {
        let route_id = RouteId(id);
        let path = node
            .route_id_to_path
            .get(&route_id)
            .expect("no path for route id...");

        match route {
            Endpoint::MethodRouter(method_router) => self.route(path, method_router)?,
            Endpoint::Route(route) => self.route_service(path, route)?,
        }
    }
    Ok(())
}

注意它不是简单地把 other.routes 追加到 self.routes,而是逐条通过 self.route() 或 self.route_service() 重新注册。这个选择产生了两个重要行为:

第一,同路径 MethodRouter 自动合并。如果两个 Router 有相同路径但注册了不同 HTTP 方法,merge 后它们会合到同一个 MethodRouter 中。PathRouter::route 里的这一段(axum/src/routing/path_router.rs:73-86)在检测到已有同路径的 MethodRouter 时,调用 merge_for_path 合并方法,而不是报错:

if let Some((route_id, Endpoint::MethodRouter(prev_method_router))) = self
    .node
    .path_to_route_id
    .get(path)
    .and_then(|route_id| self.routes.get(route_id.0).map(|svc| (*route_id, svc)))
{
    // if we're adding a new `MethodRouter` to a route that already has one just
    // merge them. This makes `.route("/", get(_)).route("/", post(_))` work
    let service = Endpoint::MethodRouter(
        prev_method_router
            .clone()
            .merge_for_path(Some(path), method_router)?,
    );
    self.routes[route_id.0] = service;
}

这就是为什么你可以在不同 Router 上分别对同一路径注册 get 和 post,merge 后它们会合到同一个 MethodRouter 中。但如果你在两个 Router 上对同一路径注册了相同方法(如都注册了 get),merge_for_path 会返回错误,merge 调用会 panic。

第二,v7_checks 的传播。self.v7_checks |= v7_checks 表示如果任一 Router 启用了 v7 路径检查(禁止 :param 和 *wildcard 旧语法),合并后的 Router 也启用。这是一个保守策略——宁可多检查,也不要让旧语法路由漏进合并后的路由表。

flowchart LR
    subgraph "Router A"
        A1["/api/users → get(list)"]
    end
    subgraph "Router B"
        B1["/api/users → post(create)"]
        B2["/api/orders → get(list_orders)"]
    end
    subgraph "merge 结果"
        C1["/api/users → get(list) + post(create)"]
        C2["/api/orders → get(list_orders)"]
    end
    A1 --> C1
    B1 --> C1
    B2 --> C2

fallback:兜底处理的三个层次

为什么需要 fallback

当一个请求不匹配 Router 中的任何路由时,Axum 默认返回 404 Not Found。这个默认行为由 NotFound 服务实现(axum/src/routing/not_found.rs:18-34)——它对所有请求无条件返回 StatusCode::NOT_FOUND 转换的响应:

impl<B> Service<Request<B>> for NotFound
where
    B: Send + 'static,
{
    type Response = Response;
    type Error = Infallible;
    type Future = std::future::Ready<Result<Response, Self::Error>>;

    #[inline]
    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, _req: Request<B>) -> Self::Future {
        ready(Ok(StatusCode::NOT_FOUND.into_response()))
    }
}

NotFound 是一个 ZST(零大小类型),不携带任何状态,Clone 和 Copy 都是零开销的。它作为 Router::new() 的默认 fallback 存在于每个新创建的 Router 中。

但在实际项目中,你可能需要:

  • 返回自定义的 JSON 错误体,而非空 404。RESTful API 通常要求错误响应是统一的 JSON 格式,包含错误码和描述信息。
  • 把未匹配的请求代理到另一个服务。SPA 前端的 HTML 入口是最常见的场景——所有未匹配 API 路由的 GET 请求都应返回 index.html,让前端路由接管。
  • 对方法不匹配(如对只注册了 GET 的路由发送 POST)返回自定义响应。默认的 405 响应没有消息体,可能不符合 API 的错误格式规范。

Axum 提供了三个 API 来覆盖这些场景。

fallback 和 fallback_service

Router::fallback(axum/src/routing/mod.rs:336-346)接受一个 handler:

pub fn fallback<H, T>(self, handler: H) -> Self
where
    H: Handler<T, S>,
    T: 'static,
{
    tap_inner!(self, mut this => {
        this.catch_all_fallback =
            Fallback::BoxedHandler(BoxedIntoRoute::from_handler(handler.clone()));
    })
    .fallback_endpoint(Endpoint::MethodRouter(any(handler)))
}

Router::fallback_service(axum/src/routing/mod.rs:351-362)接受一个 Service:

pub fn fallback_service<T>(self, service: T) -> Self
where
    T: Service<Request, Error = Infallible> + Clone + Send + Sync + 'static,
    T::Response: IntoResponse,
    T::Future: Send + 'static,
{
    let route = Route::new(service);
    tap_inner!(self, mut this => {
        this.catch_all_fallback = Fallback::Service(route.clone());
    })
    .fallback_endpoint(Endpoint::Route(route))
}

两者都做了两件事:设置 catch_all_fallback,然后调用 fallback_endpoint。理解为什么要做两件事是掌握 fallback 机制的关键:

  • catch_all_fallback 存储在 RouterInner 中,是请求未匹配任何路由时的最终兜底。它由 Router::call_with_state 在 PathRouter 返回 MatchError::NotFound 后调用。
  • fallback_endpoint 在 PathRouter 中注册特殊路由,这让 fallback 也参与路径匹配。为什么需要在 PathRouter 中注册?因为 PathRouter::call_with_state 只在 matchit 查找失败时返回 Err,而 matchit 的路由表是 fallback_endpoint 注册的目标。注意别把这里的理由和 CONNECT 搞反了:空路径的 CONNECT 请求恰恰是 matchit 匹配不到的,那种情况靠的正是 catch_all_fallback 这个字段,而不是注册进 matchit 的那条路由——源码注释写得很明白——axum/src/routing/mod.rs:228 那行是「It is only used for CONNECT」,下一行接着说 requests with an empty path。把 fallback 也注册进 PathRouter,为的是让它能跟着 nest 展平前缀、被 layer / route_layer 统一施加、并在 merge 时按路径参与冲突检查。

fallback 用 any(handler) 创建一个接受所有 HTTP 方法的 MethodRouter,确保 fallback 不区分方法——无论请求是 GET、POST 还是 DELETE,只要路径不匹配,fallback 都会处理。

fallback_endpoint:两条隐式路由

fallback_endpoint(axum/src/routing/mod.rs:391-441)是 fallback 系统最精巧的部分。它注册了两条特殊路由:

    fn fallback_endpoint(self, endpoint: Endpoint<S>) -> Self {
        // TODO make this better.
        // We need the returned `Service` to be `Clone` and the function inside `service_fn` to be
        // `FnMut` so instead of just using the owned service, we do this trick with `Option`. We
        // know this will be called just once so it's fine. We're doing that so that we avoid one
        // clone inside `oneshot_inner` so that the `Router` and subsequently the `State` is not
        // cloned too much.
        tap_inner!(self, mut this => {
            _ = this.path_router.route_endpoint(
                "/",
                endpoint.clone().layer(
                    layer_fn(
                        |service: Route| {
                            let mut service = Some(service);
                            service_fn(
                                #[cfg_attr(not(feature = "matched-path"), allow(unused_mut))]
                                move |mut request: Request| {
                                    #[cfg(feature = "matched-path")]
                                    request.extensions_mut().remove::<MatchedPath>();
                                    let route = take_route_or_internal_error(&mut service);
                                    route.oneshot_inner_owned(request)
                                }
                            )
                        }
                    )
                )
            );

            _ = this.path_router.route_endpoint(
                FALLBACK_PARAM_PATH,
                endpoint.layer(
                    // …(这里是与上面 "/" 那条逐字相同的 layer_fn + service_fn,略去)
                )
            );

            this.default_fallback = false;
        })
    }

为什么要注册两条路由?

  • "/" 处理请求路径恰好是根路径但未匹配到任何显式路由的情况。例如,如果你没有为 / 注册任何 handler,但设置了 fallback,那么访问 / 时应该触发 fallback。
  • FALLBACK_PARAM_PATH(值为 /{*__private__axum_fallback},定义在 axum/src/routing/mod.rs:127)是一条通配符路由,匹配所有其他未命中路径。FALLBACK_PARAM 常量(__private__axum_fallback)同样使用双下划线前缀,与 NEST_TAIL_PARAM 的设计意图一致——避免与用户定义的路径参数冲突。

两条路由都包裹了同一层中间件,这个中间件做了两件事:

第一,移除 MatchedPath 扩展。在启用 matched-path feature 时,每个成功匹配的路由会在请求扩展中设置 MatchedPath,让 handler 可以知道自己匹配的路径模式。但 fallback 不应该被视为"已匹配"——它是兜底,不是正常路由。如果 fallback 设置了 MatchedPath,中间件或 handler 可能会误以为请求成功匹配了某条路由,导致逻辑错误。所以 fallback 路由在调用 handler 之前显式移除 MatchedPath。

第二,take_route_or_internal_error。这个函数我们在下一节详述。

最后,this.default_fallback = false 标记此 Router 不再使用默认 fallback。这个标记在 merge 时用于冲突检测——我们已经在前一节看到了它的作用。

take_route_or_internal_error:安全机制

take_route_or_internal_error(axum/src/routing/mod.rs:63-73)是 fallback 路由中的安全阀:

fn take_route_or_internal_error(service: &mut Option<Route>) -> Route {
    service.take().unwrap_or_else(|| {
        if cfg!(debug_assertions) {
            panic!("{TAKE_ONCE_ROUTE_PANIC_MSG}");
        }
        Route::new(service_fn(|_req: Request| async move {
            Ok::<_, Infallible>(http::StatusCode::INTERNAL_SERVER_ERROR.into_response())
        }))
    })
}

要理解这个函数,需要先理解为什么 fallback 路由要用 Option<Route> + take 的模式。在 fallback_endpoint 中,handler 被包装在 service_fn 闭包中,而 service_fn 要求闭包是 FnMut——每次调用时闭包必须能再次执行。但 Route 是一个 owned service,oneshot_inner_owned 会消费它。为了让 service_fn 闭包在第一次调用时能消费 Route,Axum 把 Route 放在 Option<Route> 中,用 take() 取出所有权。正常情况下每个 fallback 路由只被调用一次,take 必然成功。

但如果因为某种 bug 导致同一条 fallback 路由被调用第二次,Option 已经是 None,take 返回 None,unwrap_or_else 被触发:

  • debug 模式下直接 panic,错误信息为 TAKE_ONCE_ROUTE_PANIC_MSG("TakeOnceRoute called more than once..."),帮助开发者尽早发现问题。
  • release 模式下返回 500 Internal Server Error,避免生产环境崩溃。虽然 500 不理想,但比 panic 好得多。

这个设计的背后是一个权衡:Axum 本可以 clone Route 来避免 take 的问题。源码注释(axum/src/routing/mod.rs:392-397)说得很具体:这样做是为了省掉 oneshot_inner 里的那一次 clone,免得 Router 以及随之而来的 State 被 clone 太多次。Route::oneshot_inner 借用 &self,要先 self.0.clone() 再 oneshot;oneshot_inner_owned 按值拿走 Route,直接 oneshot(axum/src/routing/route.rs:49-58)。所以 take 模式用"只能调用一次"的约束换掉的是这一次 clone,而不是说其他地方完全不 clone。

method_not_allowed_fallback:方法级兜底

Router::method_not_allowed_fallback(axum/src/routing/mod.rs:366-375)解决的是一个更精细的场景:请求路径匹配了某条路由,但 HTTP 方法不对。默认行为是返回 405 Method Not Allowed,这个 API 允许你自定义响应格式:

pub fn method_not_allowed_fallback<H, T>(self, handler: H) -> Self
where
    H: Handler<T, S>,
    T: 'static,
{
    tap_inner!(self, mut this => {
        this.path_router.method_not_allowed_fallback(&handler);
    })
}

它调用 PathRouter::method_not_allowed_fallback(axum/src/routing/path_router.rs:95-105),遍历所有已有的 MethodRouter endpoint,给每个都设置 fallback handler:

pub(super) fn method_not_allowed_fallback<H, T>(&mut self, handler: &H)
where
    H: Handler<T, S>,
    T: 'static,
{
    for endpoint in self.routes.iter_mut() {
        if let Endpoint::MethodRouter(rt) = endpoint {
            *rt = rt.clone().default_fallback(handler.clone());
        }
    }
}

注意它只影响 MethodRouter 类型的 endpoint——Route 类型的 endpoint(通过 route_service 注册的)不受影响,因为 Route 本身就是全方法的服务,不存在"方法不允许"的概念。

这里有一个时序问题:method_not_allowed_fallback 只会影响调用时已存在的路由。如果你先调用 method_not_allowed_fallback,再调用 .route() 添加新路由,新路由不会继承之前设置的 method_not_allowed_fallback。所以这个方法通常应该在其他所有路由注册完成后调用。

fallback 与 method_not_allowed_fallback 的区别:前者在路径不匹配时触发(404 场景),后者在路径匹配但方法不匹配时触发(405 场景)。两者独立设置,互不干扰。你可以同时设置两者,让 404 和 405 都返回自定义 JSON:

let app = Router::new()
    .route("/users", get(list_users))
    .fallback(json_404_handler)
    .method_not_allowed_fallback(json_405_handler);

Fallback 枚举的三态设计

Fallback 枚举(axum/src/routing/mod.rs:710-714)有三个变体:

enum Fallback<S, E = Infallible> {
    Default(Route<E>),
    Service(Route<E>),
    BoxedHandler(BoxedIntoRoute<S, E>),
}
  • Default:系统默认的 404(NotFound),在 merge 时是"透明"的。Fallback::merge 按这个变体让位,RouterInner::default_fallback 布尔值另外把同一件事记了一遍,供 Router::merge 做第一处检查。
  • Service:通过 fallback_service 设置的,已经是 ready 的 Route。它不依赖 state,可以直接处理请求。
  • BoxedHandler:通过 fallback 设置的,包含一个尚未注入 state 的 handler。它需要在 with_state 时才能转换为 Route。

with_state 的处理(axum/src/routing/mod.rs:743-749)把 BoxedHandler 转为 Service,把状态注入 handler:

fn with_state<S2>(self, state: S) -> Fallback<S2, E> {
    match self {
        Self::Default(route) => Fallback::Default(route),
        Self::Service(route) => Fallback::Service(route),
        Self::BoxedHandler(handler) => Fallback::Service(handler.into_route(state)),
    }
}

Default 和 Service 不依赖状态,直接透传;BoxedHandler 调用 into_route(state) 完成从 handler 到 service 的转换。注意 with_state 后 Fallback 的类型参数从 S 变成了 S2——这意味着 state 类型在 with_state 调用时才确定。这就是为什么 Router<S> 在调用 with_state 后变成 Router<S2>,整个类型系统确保了 state 的一致性。

Fallback::call_with_state(axum/src/routing/mod.rs:751-759)是实际执行 fallback 的地方:

fn call_with_state(self, req: Request, state: S) -> RouteFuture<E> {
    match self {
        Self::Default(route) | Self::Service(route) => route.oneshot_inner_owned(req),
        Self::BoxedHandler(handler) => {
            let route = handler.into_route(state);
            route.oneshot_inner_owned(req)
        }
    }
}

Default 和 Service 都已经有 ready 的 Route,直接调用 oneshot_inner_owned;BoxedHandler 需要先调用 into_route(state) 转换为 Route。oneshot_inner_owned 表示这个 Route 只会被调用一次然后丢弃——与 take_route_or_internal_error 的 take 语义呼应。

请求分派的完整路径

把 nest、merge、fallback 放在一起,一个请求的完整分派路径如下(axum/src/routing/mod.rs:452-462):

pub(crate) fn call_with_state(&self, req: Request, state: S) -> RouteFuture<Infallible> {
    let (req, state) = match self.inner.path_router.call_with_state(req, state) {
        Ok(future) => return future,
        Err((req, state)) => (req, state),
    };

    self.inner
        .catch_all_fallback
        .clone()
        .call_with_state(req, state)
}

先尝试路径匹配,成功则直接返回;失败则把请求和状态传给 catch_all_fallback。注意 Err 变体携带了 (Request, S)——请求和状态在路径匹配失败时被原样返回,不会丢失。这保证了 fallback 能收到完整的请求信息。(这是主线的代码;0.8.9 在两步之间还要再查一次独立的 fallback_router,见前文版本口径注。)

PathRouter::call_with_state(axum/src/routing/path_router.rs:325-372)的匹配逻辑也很清晰:

match self.node.at(parts.uri.path()) {
    Ok(match_) => {
        // 设置 MatchedPath、URL 参数,调用 endpoint
        let endpoint = self.routes.get(id.0)...;
        match endpoint {
            Endpoint::MethodRouter(method_router) => {
                Ok(method_router.call_with_state(req, state))
            }
            Endpoint::Route(route) => Ok(route.clone().call_owned(req)),
        }
    }
    Err(MatchError::NotFound) => Err((Request::from_parts(parts, body), state)),
}

matchit 的 at 方法返回两种结果:匹配成功(包含路由 ID 和路径参数)或 MatchError::NotFound。注意 MatchError 目前只有一个变体 NotFound,但 Axum 用了显式的模式匹配而非通配符,注释说明这是为了在未来 matchit 添加新变体时强制处理。

因为 nest 在构建时已经把子路由展开到父路由表中,所以这里只需要一次 matchit 查找——嵌套层级再多也不影响分派效率。这是 Axum 路由系统最精妙的性能优化:用构建时的展开换取运行时的单次查找。

flowchart TD
    A["请求到达 Router::call_with_state"] --> B{"PathRouter::call_with_state<br/>matchit 路径匹配"}
    B -->|"匹配成功"| C{"Endpoint 类型"}
    C -->|"MethodRouter"| D["MethodRouter::call_with_state<br/>按 HTTP 方法分派"]
    C -->|"Route"| E["Route::call_owned<br/>直接调用 Service"]
    B -->|"MatchError::NotFound"| F{"catch_all_fallback"}
    F -->|"Default (NotFound)"| G["404 Not Found"]
    F -->|"Service"| H["自定义 fallback service"]
    F -->|"BoxedHandler"| I["自定义 fallback handler<br/>with_state 转换后调用"]
    D --> J{"方法是否匹配"}
    J -->|"匹配"| K["调用对应 handler"]
    J -->|"不匹配"| L{"method_not_allowed_fallback"}
    L -->|"已设置"| M["自定义 405 响应"]
    L -->|"未设置"| N["默认 405 Method Not Allowed"]
    style B fill:#fff3cd
    style F fill:#f8d7da
    style L fill:#e8d4f8

三种模式的横向对比与工程选型

维度 nest merge fallback
语义 前缀挂载,子路由路径相对前缀 扁平合并,路径不变 兜底未匹配请求
路径处理 自动拼接前缀,运行时剥离 不处理路径 注册 / 和 /{*__private__axum_fallback}
fallback 继承 子 Router 的 fallback 照常生效(只丢 catch_all_fallback 字段;主线上子 Router 根一层有 /{id} 这类参数路由时例外,见前文版本口径注);子 Router 没设时继承父 fallback 双方不能都有自定义 fallback 同时设 catch_all_fallback 与两条兜底路由
典型场景 模块化 API 分组 合并不同版本/模块的路由表 SPA 入口、自定义错误页
参数化前缀 支持(如 /api/{version}) 不涉及 不涉及
接受类型 只接受 Router 接受 Into<Router> handler 或 Service
构建时行为 展开子路由到父路由表 逐条重新注册路由 注册两条隐式路由
运行时行为 StripPrefix 改写 URI 无额外开销 MatchedPath 移除 + take 语义

选型原则:

  • 需要路径前缀隔离用 nest。每个模块只关心自己的相对路径,顶层 Router 负责拼前缀。这是最常见的模块化手段。
  • 路径已经完整用 merge。适合把独立开发的路由模块合到一个应用中。常见模式是每个模块文件导出一个 Router,main 函数中用 merge 汇总。
  • 需要处理未知路径用 fallback。SPA 的 HTML 入口是最经典的场景——所有未匹配 API 路由的请求都返回 index.html。
  • 需要挂载第三方 Service用 nest_service。与 nest 不同,nest_service 接受任意 Tower Service,适合挂载 ServeDir、tonic gRPC 服务等。
  • 不要用 nest 做根级合并——Axum 会 panic,这是有意为之的设计约束。
  • merge 前注意 fallback 冲突——如果两个 Router 都设了自定义 fallback,0.8.9 上先对其中一个调用 reset_fallback();主线上这样做仍会 panic,只能在构建时就不给其中一个设 fallback。

与 Hyper/Tower 的设计呼应

Axum 的 nest 模式与 Hyper/Tower 生态中的服务委托模式有深层的设计呼应。在《Hyper 与 Tower:工业级 HTTP 栈》专栏第 22 章中,我们看到 MakeService 模式为每个连接创建一个独立的服务实例——外层服务(连接管理器)把请求委托给内层服务(请求处理器)。(hyper 1.x 自身已不再提供 MakeService,每连接一个 Service 由 axum::serve 这样的上层自己组织。)Axum 的 nest 做的是同构的事:外层 Router 匹配前缀后,把请求委托给内层 Router(或 Service)。

两者都是"容器委托给内部服务"的模式,但委托粒度不同。Hyper 是连接级委托(每个 TCP 连接一个 Service 实例),Axum 是路径级委托(每个前缀一组路由)。MakeService 在每次新连接到来时被 call 一次,产出一个专门处理该连接的 Service;Axum 的 nest 则在构建时把所有子路由展平到一棵 matchit 树中,运行时没有"委托"的开销。

Tower 的 Layer 机制在这两种委托中都扮演了关键角色——Axum 的 StripPrefix 和 SetNestedPath 就是 Tower Layer 的具体应用,它们在请求到达内层服务前完成路径改写和元数据注入。在 Hyper 那一侧,Layer 同样用于在连接建立后、请求处理前插入中间件逻辑(如超时、限流)。这种分层组合的思想贯穿了整个 Rust 异步 Web 生态:Hyper 管连接,Tower 管中间件,Axum 管路由。理解了这三层的委托关系,就理解了为什么 Axum 能在 matchit 的一次查找中完成所有嵌套层级的路由分派——因为它在 nest 调用时已经把嵌套结构展平了,而展平操作依赖于 Tower Layer 来保证请求在到达最终 handler 时看到正确的路径和元数据。

常见陷阱与排查指南

陷阱一:误以为 nest 会丢掉子 Router 的 fallback。nest 的源码里 catch_all_fallback: _ 那行(axum/src/routing/mod.rs:231)很扎眼,容易让人以为"子 Router 设了 .fallback() 也白设"。前面已经分析过:被丢的只是 catch_all_fallback 字段(它只服务于空路径的 CONNECT 请求),而 Router::fallback 同时注册进 PathRouter 的那两条兜底路由会被正常展平到父路由表,所以下面这段代码是可以工作的:

let users_router = Router::new()
    .route("/", get(list_users))
    .route("/{id}", get(get_user))
    .fallback(users_not_found);   // 0.8.9:对 /api/users 前缀下未匹配的路径生效(主线见下文)

let app = Router::new()
    .nest("/api/users", users_router)
    .fallback(global_not_found);  // 只兜 /api/users 之外的路径

这段代码在 0.8.9 上的行为与注释一致:/api/users/42/x 拿到 users_not_found。主线上却不是这样。users_router 有 /{id},按前文 nest 一节说的例外,它的 /{*__private__axum_fallback} 与 /{id} 冲突,被 fallback_endpoint 静默跳过;/ 又已被 list_users 占用。结果子 fallback 一条兜底路由也没注册上,/api/users/42/x 落到了 global_not_found(两版都实测过)。在主线上想让子 fallback 生效,子 Router 的根一层就不能有参数路由或通配路由。

真正需要注意的是反过来的情形:如果子 Router 没设 fallback,前缀下未匹配的路径会一路落到父 Router 的 fallback——这在你希望 /api/* 统一返回 JSON 错误、而根路径返回 HTML 404 时会出乎意料。解法就是给子 Router 显式加一个 .fallback()。

陷阱二:merge 两个有自定义 fallback 的 Router 导致 panic。错误信息是带反引号的 Cannot merge two `Router`s that both have a fallback(axum/src/routing/mod.rs:280 与 289 各有一处——前者在 default_fallback 的 match 里(主线三个分支,0.8.9 四个)、后者在 catch_all_fallback.merge() 返回 None 时),直接告诉你问题所在。解决方案是对不需要保留 fallback 的那个 Router 调用 .reset_fallback()(axum/src/routing/mod.rs:384-389,它把 default_fallback 置回 true、catch_all_fallback 置回 Fallback::Default(Route::new(NotFound)))。这个办法只适用于 0.8.9;主线上 reset_fallback 清不掉已注册进 PathRouter 的兜底路由,merge 照样 panic,见前文 reset_fallback 一节。如果你不确定哪个 Router 有自定义 fallback,可以在 merge 前打印 Router 的 debug 输出——default_fallback 字段会显示 true 或 false。

陷阱三:nest 的路径与 route 冲突。如果你先 .route("/api/users", get(handler_a)),然后 .nest("/api", users_router),而 users_router 中有 .route("/users", get(handler_b)),那么两者展开后都落在 /api/users 上。PathRouter::route 发现该路径已经存在一个 MethodRouter,会尝试用 merge_for_path 把两者合并;由于双方都注册了 GET,合并返回 Err("Overlapping method route. Handler for `{name} {path}` already exists")(axum/src/routing/method_routing.rs:1102),Router::nest 外层的 panic_on_err! 把它变成启动期 panic。如果两侧注册的是不同方法(一个 get、一个 post),合并会成功,两条路由共存于同一个 MethodRouter。

陷阱四:method_not_allowed_fallback 的时序问题。method_not_allowed_fallback 只影响调用时已存在的路由——它的实现就是 for endpoint in self.routes.iter_mut() 遍历当前的 routes,逐个给 Endpoint::MethodRouter 换上 default_fallback(handler.clone())(axum/src/routing/path_router.rs:95-105;对外入口 axum/src/routing/mod.rs:366)。如果你先调用此方法再注册路由,新路由不会继承。始终在所有路由注册完成后调用 method_not_allowed_fallback。

小结

nest、merge、fallback 是 Router 的三种组合原语,覆盖了路由模块化最常见的三类需求:前缀隔离、扁平合并、兜底处理。nest 的构建时展平策略把多层嵌套压成一次 matchit 查找,代价只是丢掉子 Router 的 catch_all_fallback 字段(子 Router 的兜底路由照常展平生效,主线上的例外见版本口径注);merge 的 fallback 冲突检测强迫开发者显式决策,避免静默覆盖;fallback 的双路由注册(/ + 通配符)和 MatchedPath 移除保证了语义正确性。

三种原语的底层都依赖于 Tower 的 Layer 机制——StripPrefix 改写 URI、SetNestedPath 注入元数据、fallback 路由的 take 语义省掉一次克隆。这些中间件不是"可选的装饰",而是路由组合的正确性保障。Axum 选择的"构建时展开"策略把运行时开销降到最低,但也带来了一些语义上的限制(如根级 nest 被禁止、同路径同方法冲突会在启动期 panic),理解这些限制的根源是正确使用路由组合的前提。

下一章我们将深入 Handler trait,看一个普通的 async 函数是如何被泛型与宏展开变成可以挂到路由上的服务的。