Appearance
第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 拆成多个子模块,然后在顶层组装。这引出三个具体问题:
- 前缀挂载:如何把一个子 Router 挂到某个路径前缀下?例如把
users_router挂到/api/users,使其内部路由/和/{id}自动变成/api/users和/api/users/{id}。子模块的代码不需要知道自己被挂在哪里,路径前缀由顶层组装者决定。 - 扁平合并:如何把两个独立的 Router 合成一个,路由表平铺、前缀不变?例如
api_v1_router和api_v2_router各自有完整路径,合并后共享同一个路由树。这种模式适合路由在不同 crate 或不同文件中定义,最终在 main 函数中汇总。 - 兜底处理:当请求不匹配任何已注册路由时,如何自定义 404 或其他响应?这在单页应用(SPA)场景中尤为常见——前端路由处理的路径不应该在后端返回 404。
Axum 分别用 nest、merge、fallback 三组 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 的设计立场是——根级别组合应该用 merge,nest 只做前缀挂载。这不是技术限制,而是语义约束: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_state(routing/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 都会被 StripPrefix 和 SetNestedPath 两层中间件包裹。StripPrefix 在请求到达子路由处理器之前,把 URI 中的前缀剥离,使子路由处理器看到的路径与定义时一致。SetNestedPath 则在请求扩展中注入 NestedPath,让处理器知道自己在哪个前缀下被挂载。
注意 layer 是一个元组 (StripPrefix::layer(prefix), SetNestedPath::layer(path_to_nest_at))。在 Axum 的 Endpoint::layer 实现中,元组形式的 layer 会从左到右依次应用——请求先经过 StripPrefix(改写 URI),再经过 SetNestedPath(注入元数据),最后到达实际 handler。这个顺序很重要:如果 SetNestedPath 在 StripPrefix 之前,handler 看到的 URI 就还是带前缀的,NestedPath 提取器也就失去了意义。
第三,重新注册。拼接后的路径和包裹后的 endpoint 作为新路由注册到父 Router 的 PathRouter 中。这里有一个容易忽略的事实:nest 的本质不是"运行时分派到子 Router",而是"编译时把子路由展开到父路由表中"。子 Router 作为一个独立实体在 nest 调用后被完全消费(into_inner 拿走内部数据),它的路由被逐条注册到父 Router 的 matchit 树中。这是一个重要的性能决策——请求进来时只做一次 matchit 查找,而不是逐层前缀匹配。无论你嵌套了多少层,分派的时间复杂度始终是 O(1)(matchit 基于基数树,查找时间与路由数量无关)。
SetNestedPath:让处理器感知挂载位置
SetNestedPath(extract/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 中间件:前缀剥离的微观实现
StripPrefix(routing/strip_prefix.rs)是一个 Tower 中间件,核心逻辑在 strip_prefix 函数(第 47-122 行)。它不仅能处理静态前缀(如 /api),还能处理带路径参数的前缀(如 /api/{version})。
StripPrefix 的 Service 实现(第 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_service(routing/mod.rs:241-253)是 nest 的变体,区别在于它接受任意 Service 而非 Router。典型场景是挂载一个第三方服务(如 ServeDir、tonic 的 gRPC Routes):
rust
let app = Router::new()
.nest_service("/assets", ServeDir::new("static"));nest_service 与 nest 的第一个区别在入口处:当 path 为空或 / 时,它建议使用 fallback_service 而非 merge(routing/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_service(path_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_path(path_router.rs:445-464)在 nest 和 nest_service 中都被调用。它检查三条规则:
- 前缀必须以
/开头——与validate_path一致,所有 Axum 路径都必须以/开头。 - 前缀长度不小于 2(即不能是
/本身)——根级别嵌套被禁止,需要使用merge或fallback_service。 - 前缀中不能包含通配符(
{*...}形式)——通配符会匹配不确定数目的路径段,这与前缀剥离的确定性语义矛盾。如果前缀包含通配符,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::merge(routing/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_fallback 是 RouterInner 的布尔字段(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::merge(routing/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_fallback(routing/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::merge(path_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::route(path_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 上分别对同一路径注册 get 和 post,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(零大小类型),不携带任何状态,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(routing/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_service(routing/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_state在PathRouter返回MatchError::NotFound后调用。fallback_endpoint在PathRouter中注册特殊路由,这让 fallback 也参与路径匹配。为什么需要在 PathRouter 中注册?因为PathRouter::call_with_state只在 matchit 查找失败时返回Err,而 matchit 的路由表是 fallback_endpoint 注册的目标。如果 fallback 不注册到 matchit 中,某些边界情况(如 CONNECT 方法的空路径请求)就无法正确路由到 fallback。
fallback 用 any(handler) 创建一个接受所有 HTTP 方法的 MethodRouter,确保 fallback 不区分方法——无论请求是 GET、POST 还是 DELETE,只要路径不匹配,fallback 都会处理。
fallback_endpoint:两条隐式路由
fallback_endpoint(routing/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_error(routing/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 已经是 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 的问题,但 clone 意味着每个请求都要复制整个 service 栈(包括所有嵌套的 Layer 和 handler 状态),这对性能是不可接受的。take 模式用"只能调用一次"的约束换取零 clone 开销。
method_not_allowed_fallback:方法级兜底
Router::method_not_allowed_fallback(routing/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_fallback(path_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)),
}
}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(routing/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)
}
}
}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 放在一起,一个请求的完整分派路径如下(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_state(path_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) 查找。
三种模式的横向对比与工程选型
| 维度 | nest | merge | fallback |
|---|---|---|---|
| 语义 | 前缀挂载,子路由路径相对前缀 | 扁平合并,路径不变 | 兜底未匹配请求 |
| 路径处理 | 自动拼接前缀,运行时剥离 | 不处理路径 | 注册 / 和 /{*__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。 - 需要挂载第三方 Service用
nest_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 的 StripPrefix 和 SetNestedPath 就是 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 字段会显示 true 或 false。
陷阱三: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 方法是如何被组织、合并、分派的。