Axum 设计与实现
第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 前缀下。一个典型的用例:
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(CHANGELOGUnreleased段第一条「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 一样拒绝以 : 或 * 开头的旧语法片段):
- 前缀必须以
/开头——与validate_path一致,所有 Axum 路径都必须以/开头。 - 前缀长度不小于 2(即不能是
/本身)——根级别嵌套被禁止,需要使用merge或fallback_service。 - 前缀中不能包含通配符(
{*...}形式)——通配符会匹配不确定数目的路径段,这与前缀剥离的确定性语义矛盾。如果前缀包含通配符,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 函数是如何被泛型与宏展开变成可以挂到路由上的服务的。