Skip to content

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

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

你在第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_routerapi_v2_router 各自有完整路径,合并后共享同一个路由树。这种模式适合路由在不同 crate 或不同文件中定义,最终在 main 函数中汇总。
  3. 兜底处理:当请求不匹配任何已注册路由时,如何自定义 404 或其他响应?这在单页应用(SPA)场景中尤为常见——前端路由处理的路径不应该在后端返回 404。

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

nest:前缀挂载与路径剥离

宏观理解:nest 做了什么

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

rust
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 下给内部微服务调用。

源码入口:Router::nest

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

rust
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 的设计立场是——根级别组合应该用 mergenest 只做前缀挂载。这不是技术限制,而是语义约束:nest 的核心语义是"剥离前缀再分派",如果前缀为空,剥离动作无意义,容易产生歧义。历史上 Axum 早期版本允许 nest("/", router),后来社区发现这个用法导致混淆——开发者以为 nest 会像 scope 一样隔离 fallback,但实际上根级别 nest 等价于 merge。所以 0.7 版本直接把它改成了 panic,强迫开发者显式选择语义正确的方法。

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

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 的精细考量。要理解它,需要先明白 catch_all_fallback 在路由分派中的角色。回顾 Router::call_with_staterouting/mod.rs:452-462),当 PathRouter 没有匹配到任何路由时,请求会被传给 catch_all_fallback。而 fallback 在 PathRouter 中是以两条特殊路由的形式存在的(//{*__private__axum_fallback})。如果子 Router 的 catch-all fallback 被继承到父级,它会以 /{path}/* 的形式存在,而 * 通配符不能匹配空路径——这意味着前缀本身(如 /api/users)反而无法命中 fallback,导致行为不一致。因此 Axum 选择在 nest 时直接丢弃子 Router 的 fallback,由父 Router 统一兜底。

这个设计选择有一个实际影响:你无法在子 Router 中设置 fallback 然后期望它只对子路由下的路径生效。如果你需要"子路由范围内的 404 自定义",目前 Axum 没有直接支持——你需要在子 Router 内部用通配符路由模拟这个行为。

PathRouter::nest 的路径重组

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

rust
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 函数(path_router.rs:466-477)的逻辑如下:

rust
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 都会被 StripPrefixSetNestedPath 两层中间件包裹。StripPrefix 在请求到达子路由处理器之前,把 URI 中的前缀剥离,使子路由处理器看到的路径与定义时一致。SetNestedPath 则在请求扩展中注入 NestedPath,让处理器知道自己在哪个前缀下被挂载。

注意 layer 是一个元组 (StripPrefix::layer(prefix), SetNestedPath::layer(path_to_nest_at))。在 Axum 的 Endpoint::layer 实现中,元组形式的 layer 会从左到右依次应用——请求先经过 StripPrefix(改写 URI),再经过 SetNestedPath(注入元数据),最后到达实际 handler。这个顺序很重要:如果 SetNestedPathStripPrefix 之前,handler 看到的 URI 就还是带前缀的,NestedPath 提取器也就失去了意义。

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

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

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

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

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

rust
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 中间件:前缀剥离的微观实现

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

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

rust
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 行):

rust
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 函数(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 检查是否匹配。如果匹配,累计已匹配的前缀长度;否则返回 None
  • First:路径比前缀多出片段,说明前缀已完全匹配——截断前缀长度后得到剩余路径,这就是子 Router 应该看到的路径。
  • Second:前缀比路径多出片段,说明路径太短不匹配——返回 None

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

rust
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_servicerouting/mod.rs:241-253)是 nest 的变体,区别在于它接受任意 Service 而非 Router。典型场景是挂载一个第三方服务(如 ServeDir、tonic 的 gRPC Routes):

rust
let app = Router::new()
    .nest_service("/assets", ServeDir::new("static"));

nest_servicenest 的第一个区别在入口处:当 path 为空或 / 时,它建议使用 fallback_service 而非 mergerouting/mod.rs:247-248):

rust
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_servicepath_router.rs:212-249)的实现与 nest 有本质不同。因为被挂载的不是 Router 而是 Service,没有"子路由列表"可以展开。Axum 的做法是注册一条通配符路由来捕获前缀下的所有路径:

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

NEST_TAIL_PARAM 定义在 routing/mod.rs:123,值为 __private__axum_nest_tail_param。这是一个双下划线开头的私有参数名,设计上不与用户定义的路径参数冲突。当 matchit 匹配到这条路由时,前缀之后的路径会被捕获到这个参数中,但 handler 通常不需要读取它——因为 nest_service 的 Service 接收的是原始请求(StripPrefix 已经把前缀剥离了)。

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

rust
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_pathpath_router.rs:445-464)在 nest 和 nest_service 中都被调用。它检查三条规则:

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

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

多层嵌套的路径累积

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

rust
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},每条路由带有两层 StripPrefix(一层剥离 /api,一层剥离 /api/users)。SetNestedPath 会把两层前缀累积为 /api/users——通过 trim_end_matches('/') 去掉 /api 的尾部斜杠,然后拼接 /users,得到 /api/users

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

merge:扁平合并与 fallback 冲突

merge 的语义

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

rust
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 更灵活——你可以把一个 Router<()> merge 到 Router<AppState> 中(只要 state 类型兼容),或者把其他实现了 Into<Router> 的类型直接传入。

源码解析:fallback 冲突检测

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

rust
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_fallbackRouterInner 的布尔字段(routing/mod.rs:100),标记当前 fallback 是否为默认的 NotFound。这个字段的存在是为了在 merge 时区分"用户显式设置的 fallback"和"系统默认的 404"——默认 fallback 是"透明"的,merge 时自动让位给自定义 fallback;但两个自定义 fallback 就产生了语义冲突,Axum 选择 panic 而非静默覆盖。

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

Fallback::mergerouting/mod.rs:720-727)的逻辑呼应了这个策略:

rust
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_fallbackrouting/mod.rs:384-389):

rust
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,不再与另一个冲突:

rust
let app = router_a.merge(router_b.reset_fallback());
// router_a 的 fallback 生效,router_b 的 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::mergepath_router.rs:146-170)做的是路由表的物理级合并:

rust
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::routepath_router.rs:66-93)在检测到已有同路径的 MethodRouter 时,调用 merge_for_path 合并方法,而不是报错:

rust
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)))
{
    let service = Endpoint::MethodRouter(
        prev_method_router
            .clone()
            .merge_for_path(Some(path), method_router)?,
    );
    self.routes[route_id.0] = service;
}

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

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

fallback:兜底处理的三个层次

为什么需要 fallback

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

rust
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>>;

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

NotFound 是一个 ZST(零大小类型),不携带任何状态,CloneCopy 都是零开销的。它作为 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::fallbackrouting/mod.rs:336-346)接受一个 handler:

rust
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_servicerouting/mod.rs:351-362)接受一个 Service:

rust
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_statePathRouter 返回 MatchError::NotFound 后调用。
  • fallback_endpointPathRouter 中注册特殊路由,这让 fallback 也参与路径匹配。为什么需要在 PathRouter 中注册?因为 PathRouter::call_with_state 只在 matchit 查找失败时返回 Err,而 matchit 的路由表是 fallback_endpoint 注册的目标。如果 fallback 不注册到 matchit 中,某些边界情况(如 CONNECT 方法的空路径请求)就无法正确路由到 fallback。

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

fallback_endpoint:两条隐式路由

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

rust
fn fallback_endpoint(self, endpoint: Endpoint<S>) -> Self {
    tap_inner!(self, mut this => {
        _ = this.path_router.route_endpoint(
            "/",
            endpoint.clone().layer(
                layer_fn(|service: Route| {
                    let mut service = Some(service);
                    service_fn(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(/* 同样的中间件 */),
        );

        this.default_fallback = false;
    })
}

为什么要注册两条路由?

  • "/" 处理请求路径恰好是根路径但未匹配到任何显式路由的情况。例如,如果你没有为 / 注册任何 handler,但设置了 fallback,那么访问 / 时应该触发 fallback。
  • FALLBACK_PARAM_PATH(值为 /{*__private__axum_fallback},定义在 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_errorrouting/mod.rs:63-73)是 fallback 路由中的安全阀:

rust
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 已经是 Nonetake 返回 Noneunwrap_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 的问题,但 clone 意味着每个请求都要复制整个 service 栈(包括所有嵌套的 Layer 和 handler 状态),这对性能是不可接受的。take 模式用"只能调用一次"的约束换取零 clone 开销。

method_not_allowed_fallback:方法级兜底

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

rust
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_fallbackpath_router.rs:95-105),遍历所有已有的 MethodRouter endpoint,给每个都设置 fallback handler:

rust
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:

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

Fallback 枚举的三态设计

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

rust
enum Fallback<S, E = Infallible> {
    Default(Route<E>),
    Service(Route<E>),
    BoxedHandler(BoxedIntoRoute<S, E>),
}
  • Default:系统默认的 404(NotFound),在 merge 时是"透明"的。它的特殊地位不是由枚举变体本身决定的,而是由 RouterInner::default_fallback 布尔值标记的。
  • Service:通过 fallback_service 设置的,已经是 ready 的 Route。它不依赖 state,可以直接处理请求。
  • BoxedHandler:通过 fallback 设置的,包含一个尚未注入 state 的 handler。它需要在 with_state 时才能转换为 Route

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

rust
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)),
    }
}

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

Fallback::call_with_staterouting/mod.rs:751-759)是实际执行 fallback 的地方:

rust
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)
        }
    }
}

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

请求分派的完整路径

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

rust
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 能收到完整的请求信息。

PathRouter::call_with_statepath_router.rs:325-373)的匹配逻辑也很清晰:

rust
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 路由系统最精妙的性能优化:用构建时的展开换取运行时的 O(1) 查找。

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

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

选型原则:

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

与 Hyper/Tower 的设计呼应

Axum 的 nest 模式与 Hyper/Tower 生态中的服务委托模式有深层的设计呼应。在《Hyper与Tower》第22章中,我们看到 Hyper 的 MakeService 为每个连接创建一个独立的服务实例——外层服务(连接管理器)把请求委托给内层服务(请求处理器)。Axum 的 nest 做的是同构的事:外层 Router 匹配前缀后,把请求委托给内层 Router(或 Service)。

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

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

常见陷阱与排查指南

陷阱一:nest 后子 Router 的 fallback 不生效。这是最常见的新手困惑。你可能在子 Router 中设置了 .fallback(custom_404),然后 nest 到父 Router 下,却发现访问不存在的子路由时触发了父 Router 的 fallback 而不是子 Router 的。原因就是前面"Router::nest 源码入口"一节分析的:nest 会丢弃子 Router 的 catch_all_fallback。解决方案是在子 Router 中用通配符路由模拟 fallback 行为:

rust
let users_router = Router::new()
    .route("/", get(list_users))
    .route("/{id}", get(get_user))
    .route("/*rest", get(users_not_found));  // 模拟子路由范围的 fallback

陷阱二:merge 两个有自定义 fallback 的 Router 导致 panic。错误信息 "Cannot merge two Routers that both have a fallback" 直接告诉你问题所在。解决方案是对不需要保留 fallback 的那个 Router 调用 .reset_fallback()。如果你不确定哪个 Router 有自定义 fallback,可以在 merge 前打印 Router 的 debug 输出——default_fallback 字段会显示 truefalse

陷阱三:nest 的路径与 route 冲突。如果你先 .route("/api/users", get(handler_a)),然后 .nest("/api", users_router),而 users_router 中有 .route("/users", get(handler_b)),那么 /api/users 会同时匹配两条路由。matchit 对同一路径只保留最后一个注册的路由 ID,所以行为取决于注册顺序——更准确地说,会触发 matchit 的 InsertError。Axum 在 PathRouter::set_node 中用 panic_on_err! 宏处理这个错误,所以你会在启动时看到 panic。

陷阱四:method_not_allowed_fallback 的时序问题method_not_allowed_fallback 只影响调用时已存在的路由。如果你先调用此方法再注册路由,新路由不会继承。始终在所有路由注册完成后调用 method_not_allowed_fallback

小结

nest、merge、fallback 是 Router 的三种组合原语,覆盖了路由模块化的所有需求:前缀隔离、扁平合并、兜底处理。nest 的编译时展平策略带来 O(1) 的分派效率,但以丢弃子 Router fallback 为代价;merge 的 fallback 冲突检测强迫开发者显式决策,避免静默覆盖;fallback 的双路由注册(/ + 通配符)和 MatchedPath 移除保证了语义正确性。

三种原语的底层都依赖于 Tower 的 Layer 机制——StripPrefix 改写 URI、SetNestedPath 注入元数据、fallback 路由的 take 语义避免克隆。这些中间件不是"可选的装饰",而是路由组合的正确性保障。Axum 选择的"编译时展开"策略把运行时开销降到最低,但也带来了一些语义上的限制(如子 Router fallback 丢弃),理解这些限制的根源是正确使用路由组合的前提。

下一章我们将深入 MethodRouter 的内部结构,看一个路径上的多种 HTTP 方法是如何被组织、合并、分派的。

基于 VitePress 构建