Path segments must not start with : 与 Invalid route、Overlapping method route
主线位置:装配主线上
Router::route/nest/merge这一步。路径先过 axum 自己的格式检查,再插进 matchit 树或和已有的MethodRouter合并;任何一步失败都直接 panic,请求主线根本没有机会开始。
flowchart TB
route["route(path, mr)"] --> vp["validate_path"]
vp -->|"空、不以 / 开头"| p1["Paths must start with"]
vp -->|"段以 : 或 * 开头"| p2["Path segments ..."]
vp --> exists{"路径注册过?"}
exists -->|是| merge["merge_for_path"]
merge -->|同一方法| p3["Overlapping ..."]
exists -->|否| setnode["set_node:matchit 插入"]
setnode -->|InsertError| p4["Invalid route ..."]
这三类报错和它们背后的规则,在 matchit 与路径语法(路径语法、冲突的全部写法)和 MethodRouter(方法重复的三个入口)里已经挨个试过。这一页换个角度:它们在真实程序里是什么样子、怎么找到出错的那一行、怎么修。
复现:启动就退出
不接 panic,直接让程序崩:
axum = "=0.8.9"
use axum::{
Router,
routing::{get, post},
};
async fn show() {}
async fn update() {}
fn users() -> Router {
Router::new()
.route("/", get(show))
.route("/:id", get(show))
}
fn main() {
let case = std::env::args().nth(1).unwrap_or_default();
let _app: Router = match case.as_str() {
"colon" => Router::new().nest("/users", users()),
"conflict" => Router::new()
.route("/users/{id}", get(show))
.route("/users/{name}", post(update)),
"overlap" => Router::new()
.route("/users/{id}", get(show))
.route("/users/{id}", get(update)),
"merge" => Router::new()
.route("/users/{id}", get(show))
.merge(Router::new().route("/users/{id}", get(update).post(update))),
_ => Router::new(),
};
println!("router built");
}
M2 Max、rustc 1.99.0、--release,用 for c in colon conflict overlap merge; do echo "== $c"; ./route_panic $c; echo "exit=$?"; done 四个参数各跑一次(stdout、stderr 合在一起),连跑 3 遍,除了线程号以外输出相同:
== colon
thread 'main' (1175595) panicked at src/bin/route_panic.rs:12:6:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
== conflict
thread 'main' (1175597) panicked at src/bin/route_panic.rs:21:8:
Invalid route "/users/{name}": Insertion failed due to conflict with previously registered route: /users/{id}
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
== overlap
thread 'main' (1175599) panicked at src/bin/route_panic.rs:24:8:
Overlapping method route. Handler for `GET /users/{id}` already exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
== merge
thread 'main' (1175602) panicked at src/bin/route_panic.rs:27:8:
Overlapping method route. Handler for `GET /users/{id}` already exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
几个值得看的地方:
- 位置:每个 panic 的位置都是
route_panic.rs里你写的那一行,而不是 axum 内部。colon指向第 12 行,即子路由users()里的.route("/:id", ...),不是第 18 行的nest:子路由在被 nest 之前自己构造时就 panic 了。merge指向第 27 行的.merge(...)调用,因为冲突是 merge 内部逐条重放路由时发现的。 conflict:两个参数名不同、方法也不同,照样冲突。matchit 先把参数名规范化,/users/{id}和/users/{name}在树里是同一个节点(原因见 matchit 与路径语法 的「没有蠢问题」)。报错里的两个路径都是你写的原样。overlap:路径完全相同的第二次route不进 matchit,而是和已有的MethodRouter合并;同一个方法两边都有,就报这一句,信息里带着方法和路径。merge:另一边有GET和POST,POST是新的,GET撞了,整个 merge 失败。
源码:三处检查,一个 panic
Router::route 本身只有一行逻辑:
#[track_caller]
pub fn route(self, path: &str, method_router: MethodRouter<S>) -> Self {
tap_inner!(self, mut this => {
panic_on_err!(this.path_router.route(path, method_router));
})
}
panic_on_err! 是 Err(err) => panic!("{err}")。真正的检查都在 PathRouter::route 里,它返回 Result<(), Cow<'static, str>>,按顺序做三件事:
validate_path:空路径、不以/开头直接拒绝;v7_checks开着时再调validate_v07_paths,按/切段,哪一段以:或*开头就返回那两句提示。这一步只看字符串,和 matchit 无关。- 路径已经注册过:取出旧的
MethodRouter,调merge_for_path(Some(path), ...)逐个方法合并,两边同一方法都有端点就是「Overlapping method route. Handler for{name} {path}already exists」。 - 新路径:
set_node插进 matchit,InsertError被包成format!("Invalid route {path:?}: {err}"),Insertion failed due to conflict ...是 matchit 0.8.4 的原话。
route、nest、nest_service、merge 都标了 #[track_caller],panic! 报告的位置因此是调用方。panic_on_err! 是宏,展开后 panic! 就在 route 的函数体里,#[track_caller] 能一直传到你的代码。
还有一种「Overlapping」不经过 Router::route:get(show).get(update) 这样链式地给同一个 MethodRouter 加两次 GET,panic 发生在 set_endpoint,这时还不知道路径。下面的动脑筋就是它。
#1248「Annotate panicking functions with #[track_caller]」给这些会 panic 的方法加上了属性。PR 说明里贴了前后对比,之前是:
thread 'routing::tests::foobar' panicked at 'Invalid route "/:b": insertion failed due to conflict with previously registered route: /:a', axum/src/routing/mod.rs:174:13
thread 'routing::tests::foobar' panicked at 'Invalid route "/:b": insertion failed due to conflict with previously registered route: /:a', axum/src/routing/tests/mod.rs:667:10
理由是「This should make it even easier to hunt down which route actually causes the conflict.」CHANGELOG 记在 0.6.0 下:「Annotate panicking functions with #[track_caller] so the error message points to where the user added the invalid route, rather than somewhere internally in axum」。提交 6cd35669 第一次出现在 axum-v0.6.0-rc.1。之前位置永远是 axum/src/routing/mod.rs,一个有几十条路由的程序只能靠报错里的路径去搜。
without_v07_checks:不报错,也不匹配
/:id 的报错提示「If you meant to literally match a segment starting with a colon, call without_v07_checks」。这个开关的意思是把 :id 当成字面量,不是恢复 0.7 的参数语法。从 0.7 升级时为了消掉 panic 调用它,路由就悄悄变成了另一条:
use axum::{Router, body::Body, http::Request, routing::get};
use tower::ServiceExt;
#[tokio::main]
async fn main() {
let app = Router::new()
.without_v07_checks()
.route("/users/:id", get(|| async { "matched /users/:id" }));
for uri in ["/users/42", "/users/:id", "/users/%3Aid"] {
let res = app.clone().oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();
println!("{uri:<14} {}", res.status());
}
}
(依赖再加 tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }、tower = { version = "=0.5.3", features = ["util"] }。)连跑 3 次输出相同:
/users/42 404 Not Found
/users/:id 200 OK
/users/%3Aid 404 Not Found
只有路径里真的写着 :id 的请求能匹配,百分号编码的 %3Aid 也不行。0.8.9 不再「静默改变行为」,靠的正是这个默认开着的检查:0.8.0 的 CHANGELOG 写的是「the old syntax produces a panic to avoid silent change in behavior」(#2645,来历见 matchit 与路径语法)。without_v07_checks 只作用于调用它的那个 Router;按它的文档,nest 进来的子路由要各自关,merge 时两边都关了结果才是关的。
get,panic 指向哪里、信息里有没有路径?use axum::{Router, routing::get};
async fn show() {}
async fn update() {}
fn main() {
let _app: Router = Router::new()
.route("/users", get(show))
.route("/users/{id}", get(show).get(update));
println!("router built");
}
panic 的位置是第 9 行的哪一列?信息里会不会出现 /users/{id}?答案在本页末尾。
修法
| 报错开头 | 原因 | 怎么改 |
|---|---|---|
Path segments must not start with `:` / `*` | 0.7 的 /:id、/*rest | 改成 /{id}、/{*rest};全局搜 "/: 和 "/*,包括拼出来的路径(format! 里的) |
Paths must start with a `/` | 路径是空串或没有前导 / | 根路由写 "/";nest 进来的子路由里也写 "/" |
Invalid route "...": Insertion failed due to conflict | 同一位置两个参数(名字不同也算)、参数和通配、两个通配 | 统一成一个参数名,在 handler 里区分;或改成不同的静态前缀 |
Invalid route "...": ...(其他) | matchit 语法:通配不在末尾、一段里两个参数、空参数名 | 按 matchit 与路径语法 的规则改 |
Overlapping method route. Handler for ... already exists | 同一路径同一方法注册两次,常见于 nest 或 merge 两个模块时撞车 | 删掉一个,或把两个 handler 合并;不同方法可以分两次 route |
Overlapping method route. Cannot add two method routes | 同一个 MethodRouter 上同一方法两次 | 同上,信息里没有路径,按 panic 位置找 |
这些 panic 都发生在构造 Router 的时候,和请求无关。把构造路由的函数放进一个最简单的测试(调用一次、什么也不断言),cargo test 就能在部署前把它们全部暴露出来;不然就是服务一启动就退出,日志里只有一行 panic。
动脑筋答案
连跑 3 次,除线程号外相同:
thread 'main' (1133404) panicked at src/bin/quiz_chain.rs:9:37:
Overlapping method route. Cannot add two method routes that both handle `GET`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
- 位置:第 9 行第 37 列,是
.get(update)的位置,不是.route(...)。panic 发生在MethodRouter::get里(on_endpoint和set_endpoint都标了#[track_caller]),这时Router::route还没被调用。 - 路径:没有。这句话来自
set_endpoint,那里只知道方法名。引入「Handler forGET /pathalready exists」写法的 #1306 在说明里专门提到了这个例外:「It doesn't work forRouter::new().route("/foo/bar", get(handler).get(handler));because theget(_).get(_)panics beforeRouter::routeis called so we don't know the path at that point.」所以这一种只能靠 panic 的行列号定位。
小结
- 路由相关的 panic 分三处产生:
validate_path的格式检查(含 0.7 语法)、matchit 插入失败(Invalid route "...": ...)、merge_for_path/set_endpoint的方法重复(Overlapping method route)。 - 它们都发生在构造
Router时;#[track_caller](0.6 起)让 panic 位置指向你写的.route、.merge或.get,子路由里的错误指向子路由自己的那一行。 - 参数名不同不等于不冲突:
/users/{id}和/users/{name}在 matchit 里是同一个节点。 without_v07_checks把/:id当字面量,消掉 panic 的同时让路由再也匹配不到/users/42;从 0.7 升级应该改语法,不是关检查。- 把构造路由的函数放进测试里跑一次,启动时的 panic 就变成了测试失败。