matchit 与路径语法
主线位置:两条主线都有。装配主线上,
Router::route先检查路径写法,再把它插进 matchit 的基数树,冲突、写法错误都在这一步 panic,程序根本起不来。请求主线上,PathRouter::call_with_state用uri.path()原文调用 matchit 的at,拿到RouteId和参数切片。
flowchart TB
rt["Router::route(path, mr)"] --> vp["validate_path"]
vp -->|"空串、不以 / 开头"| p1["panic"]
vp --> v7{"段以 : 或 * 开头"}
v7 -->|"是,且没关检查"| p2["panic:提示用 {}"]
v7 -->|否| same{"同一路径已注册"}
same -->|是| mg["合并 MethodRouter"]
same -->|否| ins["matchit insert"]
ins -->|InsertError| p3["panic:Invalid route"]
ins -->|Ok| ok["记下 RouteId"]
req["请求:uri.path()"] --> at["matchit at"]
at -->|"静态优先,失败回溯"| hit["RouteId + 参数"]
at -->|NotFound| fb["交给 fallback"]
Router 内部怎么用 RouteId 把 matchit 和端点表连起来,见 Router 的内部结构。这一页只讲路径本身:能写什么、写错了报什么、一个请求路径会落到哪个路由。
matchit 0.8.4,版本写死
axum 的 Cargo.toml 里是 matchit = "=0.8.4",用 = 锁住,不随 cargo update 变。原因写在把 matchit 升到 0.8 的 PR 里(见本页的冷知识):axum 的代码假设一个参数总是占满一整个路径段,matchit 后续的小版本可能放宽这一点。matchit 的文档在 docs.rs/matchit/0.8.4;下面引用它的源码时写文件名,代码以 crates.io 上的 matchit 0.8.4 为准。
axum 对 matchit 只做了一层薄包装。Node 里是 matchit::Router<RouteId> 加两张 RouteId 与路径互查的表;插入失败时 set_node 把 matchit 的错误包成一句话:
fn set_node(&mut self, path: &str, id: RouteId) -> Result<(), String> {
let node = Arc::make_mut(&mut self.node);
node.insert(path, id)
.map_err(|err| format!("Invalid route {path:?}: {err}"))
}
Router::route 再用 panic_on_err! 把 Err 变成 panic!("{err}")。route 带 #[track_caller],panic 的位置指向你调用 .route(...) 的那一行,而不是 axum 内部。
能写什么
| 写法 | 含义 | 例子 |
|---|---|---|
| 静态段 | 字面匹配 | /users/new |
{name} | 一个参数,匹配到下一个 / 或路径末尾 | /users/{id} |
前缀 + {name} | 段内静态前缀后跟参数 | /v{version}/status |
{*name} | 通配,匹配剩下的全部,只能放最后 | /assets/{*path} |
{{、}} | 字面的 {、} | /{{literal}} 匹配 /{literal} |
参数之后不能再跟别的字符(/{id}.json 不行),一段里也只能有一个参数。matchit 的 find_wildcard(src/tree.rs)找到参数的右花括号后看下一个字符,不是 / 就返回 InvalidParamSegment,所以这两种写错的报错是同一句。
axum 在交给 matchit 之前还有两道自己的检查,在 validate_path 和 validate_v07_paths:
fn validate_v07_paths(path: &str) -> Result<(), &'static str> {
path.split('/')
.find_map(|segment| {
if segment.starts_with(':') {
Some(Err(
"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.",
))
} else if segment.starts_with('*') {
// ...
matchit 0.8 里 : 和 * 已经不是特殊字符,/users/:id 交给它会被当成字面路径,静悄悄地只匹配 /users/:id 这一个地址。axum 0.7 升级上来的代码如果没改语法,路由全都「失效」却不报错。这道检查把它变成启动时的 panic。确实想匹配字面冒号的,调用 Router::without_v07_checks 关掉。
写错了报什么
把各种写法挨个试一遍,用 catch_unwind 接住 panic,打印 panic 信息。
axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
use std::panic::{self, AssertUnwindSafe};
use axum::{Router, routing::get};
fn try_build(label: &str, f: impl FnOnce() -> Router) {
match panic::catch_unwind(AssertUnwindSafe(f)) {
Ok(_) => println!("{label}\n ok"),
Err(e) => {
let msg = e
.downcast_ref::<String>()
.cloned()
.or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
println!("{label}\n panic: {msg}");
}
}
}
fn main() {
panic::set_hook(Box::new(|_| {}));
let h = || get(|| async {});
try_build("route(\"/users/:id\")", || Router::new().route("/users/:id", h()));
try_build("route(\"/files/*path\")", || Router::new().route("/files/*path", h()));
try_build("route(\"users\")", || Router::new().route("users", h()));
try_build("route(\"\")", || Router::new().route("", h()));
try_build("route(\"/users/{id}\") + route(\"/users/{name}\")", || {
Router::new().route("/users/{id}", h()).route("/users/{name}", h())
});
try_build("route(\"/{a}/foo\") + route(\"/{b}/bar\")", || {
Router::new().route("/{a}/foo", h()).route("/{b}/bar", h())
});
try_build("route(\"/assets/{file}\") + route(\"/assets/{*path}\")", || {
Router::new().route("/assets/{file}", h()).route("/assets/{*path}", h())
});
try_build("route(\"/{*a}\") + route(\"/{*b}\")", || {
Router::new().route("/{*a}", h()).route("/{*b}", h())
});
try_build("route_service(\"/a\") twice", || {
Router::new()
.route_service("/a", tower::service_fn(|_| async { Ok::<_, std::convert::Infallible>("") }))
.route_service("/a", tower::service_fn(|_| async { Ok::<_, std::convert::Infallible>("") }))
});
try_build("route(\"/files/{*path}/edit\")", || Router::new().route("/files/{*path}/edit", h()));
try_build("route(\"/users/{}\")", || Router::new().route("/users/{}", h()));
try_build("route(\"/{id}.json\")", || Router::new().route("/{id}.json", h()));
try_build("route(\"/v{version}/x\")", || Router::new().route("/v{version}/x", h()));
try_build("route(\"/{a}{b}\")", || Router::new().route("/{a}{b}", h()));
try_build("route(\"/{{literal}}\")", || Router::new().route("/{{literal}}", h()));
try_build("without_v07_checks().route(\"/:id\")", || {
Router::new().without_v07_checks().route("/:id", h())
});
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同:
route("/users/:id")
panic: 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.
route("/files/*path")
panic: Path segments must not start with `*`. For wildcard capture, use `{*wildcard}`. If you meant to literally match a segment starting with an asterisk, call `without_v07_checks` on the router.
route("users")
panic: Paths must start with a `/`
route("")
panic: Paths must start with a `/`. Use "/" for root routes
route("/users/{id}") + route("/users/{name}")
panic: Invalid route "/users/{name}": Insertion failed due to conflict with previously registered route: /users/{id}
route("/{a}/foo") + route("/{b}/bar")
ok
route("/assets/{file}") + route("/assets/{*path}")
panic: Invalid route "/assets/{*path}": Insertion failed due to conflict with previously registered route: /assets/{file}
route("/{*a}") + route("/{*b}")
panic: Invalid route "/{*b}": Insertion failed due to conflict with previously registered route: /{*a}
route_service("/a") twice
panic: Invalid route "/a": Insertion failed due to conflict with previously registered route: /a
route("/files/{*path}/edit")
panic: Invalid route "/files/{*path}/edit": Catch-all parameters are only allowed at the end of a route
route("/users/{}")
panic: Invalid route "/users/{}": Parameters must be registered with a valid name
route("/{id}.json")
panic: Invalid route "/{id}.json": Only one parameter is allowed per path segment
route("/v{version}/x")
ok
route("/{a}{b}")
panic: Invalid route "/{a}{b}": Only one parameter is allowed per path segment
route("/{{literal}}")
ok
without_v07_checks().route("/:id")
ok
报错分两类,看前缀就能分清:
- 没有
Invalid route前缀的,是 axum 自己的检查:validate_path的两句和validate_v07_paths的两句。 Invalid route "<你写的路径>": ...是 matchit 的InsertError经set_node包装。冲突(Conflict)会带上已注册的那条路由,参数名还原成你当初写的名字。
同一位置的两个参数、参数与通配、两个通配,都算冲突:同一层的通配子节点只能有一个。但两条路由在参数之后分叉就没问题,/{a}/foo 和 /{b}/bar 能共存。route_service 两次注册同一路径走的也是 matchit 冲突;route 两次注册同一路径不会走到这里,而是合并成一个 MethodRouter,方法重复时报的是另一句话,见 MethodRouter。
问: /{a}/foo 和 /{b}/bar 两个参数名不同也能共存,为什么 /users/{id} 和 /users/{name} 就冲突?
答: matchit 插入前先把参数名「规范化」:normalize_params(src/tree.rs)把第一个参数改写成 {a}、第二个改写成 {b},依此类推,真实的名字存在节点的 remapping 里,匹配成功后再换回去。于是 /users/{id} 和 /users/{name} 在树里都是 /users/{a},是同一个节点,一个节点只能挂一个值,插第二条时 current.value.is_some(),返回 Conflict。/{a}/foo 和 /{b}/bar 规范化后是 /{a}/foo 和 /{a}/bar,共用 {a} 节点,到 /foo、/bar 才分开,各挂各的值;匹配 /x/bar 时参数会按第二条路由的 remapping 叫 b。规范化的目的正是让这种「参数之后才分叉」的路由能放进同一棵树。
一个路径落到哪个路由
matchit 的文档只有一句规则:「Static and dynamic route segments are allowed to overlap. If they do, static segments will be given higher priority」。具体的搜索在 Node::at(src/tree.rs):沿树往下走,每个节点先按下一个字节找静态子节点;有静态子节点可走、同时这个节点还有通配子节点时,把当前状态压进 skipped_nodes。静态这条路走不通(到了末尾没有值、或者前缀对不上),就弹出最近一次跳过的通配节点,从那里重试(try_backtrack!)。所以「静态优先」不是「静态独占」:静态分支死胡同时,参数分支还有机会。
用一组有重叠的路由,每个 handler 返回 MatchedPath 和参数:
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["full"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
use axum::{
Router,
body::Body,
extract::{MatchedPath, RawPathParams},
http::Request,
routing::get,
};
use http_body_util::BodyExt;
use tower::ServiceExt;
async fn show(m: MatchedPath, params: RawPathParams) -> String {
let params: Vec<String> = params.iter().map(|(k, v)| format!("{k}={v:?}")).collect();
format!("{} {}", m.as_str(), params.join(" "))
}
#[tokio::main]
async fn main() {
let mut app = Router::new();
for path in [
"/",
"/users",
"/users/new",
"/users/new/edit",
"/users/{id}",
"/users/{id}/posts",
"/assets/logo.png",
"/assets/{*path}",
"/v{version}/status",
"/foo",
"/foo/",
"/bar",
"/{{literal}}",
] {
app = app.route(path, get(show));
}
for uri in [
"/",
"/users",
"/users/",
"/users/new",
"/users/42",
"/users/new/edit",
"/users/new/posts",
"/users/42/edit",
"/users/42/",
"/users//posts",
"/assets/logo.png",
"/assets/img/a.png",
"/assets/",
"/assets",
"/v2/status",
"/foo",
"/foo/",
"/bar",
"/bar/",
"/{literal}",
] {
let req = Request::get(uri).body(Body::empty()).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status().as_u16();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{uri:<18} {status} {}", String::from_utf8_lossy(&body));
}
}
连跑 3 次输出相同。完整输出放在本页最后(里面有动脑筋的答案),这里先整理成表:
| 请求路径 | 状态 | 匹配到的路由 | 参数 |
|---|---|---|---|
/users/new | 200 | /users/new | |
/users/42 | 200 | /users/{id} | id="42" |
/users/new/edit | 200 | /users/new/edit | |
/users/42/edit | 404 | ||
/assets/logo.png | 200 | /assets/logo.png | |
/assets/img/a.png | 200 | /assets/{*path} | path="img/a.png" |
/assets/ | 404 | ||
/assets | 404 | ||
/v2/status | 200 | /v{version}/status | version="2" |
/{literal} | 200 | /{{literal}} |
- 同一层有静态段和参数时,静态段先试:
/users/new不会落进/users/{id},/assets/logo.png不会落进通配。 - 通配不匹配空:
/assets/和/assets都是 404。axum 的route.md文档原话:「/x/{*key}doesn't match/xor/x/」。要让/assets本身也有响应,得另外注册。 /users/42/edit是 404:{id}分支下只有/posts,没有回溯可做。- 通配捕获的值不带开头的
/(img/a.png)。
/users/new/posts、/users/、/users//posts 这三个请求各落到哪个路由?参数是什么?注册的路由就是上面程序里那 13 条,其中有 /users/new/edit 和 /users/{id}/posts,但没有 /users/new/posts。M2 Max、rustc 1.99.0。答案在本页最后。
尾斜杠:/foo 和 /foo/ 是两个路由
上面的输出里,/foo 和 /foo/ 各自注册,各自命中;只注册了 /bar 时,/bar/ 是 404。matchit 按字节比较,尾部多一个 / 就是另一条路径,axum 也不做任何改写。
0.6 以前 axum 会自动在两者之间重定向,#1119 删掉了这个行为(首次发布于 axum-v0.6.0-rc.1),0.6.0 的 CHANGELOG 写的是「That is no longer supported and such requests will now be sent to the fallback」,并建议需要旧行为的人用 axum-extra 的 RouterExt::route_with_tsr。另一种做法是在路由前面套一个改写 URI 的中间件。这是一个常见坑,本卷「常见坑」部分单独讲;本站自己的处理在「在本站中」部分。
路径参数在匹配之后才做百分号解码(%2F 不会被当成分隔符),这一点见 Path、Query 与请求元数据。
/{id} 语法是 0.8 才换的,旧写法特意改成 panic#2645(2024-10-03 合并,首次发布于 axum-v0.8.0-alpha.1,正式版 axum-v0.8.0)把 matchit 升到 0.8。之前 axum 0.7 用的 matchit 0.7,参数写 /:id、通配写 /*rest;matchit 0.8 改成 {id}、{*rest}。PR 描述说,代码改动只有三处(TypedPath 的解析、nest_service 生成 NEST_TAIL_PARAM 的方式、StripPrefix 识别参数段的逻辑),其余都是把 :capture 改成 {capture}。版本锁死的理由也在这里:「the code currently expects that if there is a match it encompasses the whole path segment」,而 matchit 可能在小版本里支持更细的段内匹配。本页开头的 validate_v07_paths 和 without_v07_checks 就是这个 PR 加的。CHANGELOG 的说法是「the old syntax produces a panic to avoid silent change in behavior」:旧语法在 matchit 0.8 里是合法的字面路径,不拦住的话,升级后路由会安静地全部 404。
动脑筋答案
上面程序的完整输出,连跑 3 次相同:
/ 200 /
/users 200 /users
/users/ 404
/users/new 200 /users/new
/users/42 200 /users/{id} id="42"
/users/new/edit 200 /users/new/edit
/users/new/posts 200 /users/{id}/posts id="new"
/users/42/edit 404
/users/42/ 404
/users//posts 200 /users/{id}/posts id=""
/assets/logo.png 200 /assets/logo.png
/assets/img/a.png 200 /assets/{*path} path="img/a.png"
/assets/ 404
/assets 404
/v2/status 200 /v{version}/status version="2"
/foo 200 /foo
/foo/ 200 /foo/
/bar 200 /bar
/bar/ 404
/{literal} 200 /{{literal}}
/users/new/posts匹配/users/{id}/posts,id="new"。 搜索先走静态的new分支(同时记下跳过了{id}),new下面只有/edit,对不上/posts,于是回溯到{id},把new当成参数值,接着匹配/posts成功。这是 matchit 0.8 的回溯;没有回溯的路由器在这里会回 404。/users/是 404。 路径在/users/这个中间节点上就走完了,这个节点自己没有值;{id}是它的子节点,路径已经没有剩余字节可以交给参数。参数不会匹配末尾的空段。/users//posts匹配/users/{id}/posts,id=""。 进入参数节点时剩余路径是/posts,参数取到下一个/为止,长度为 0,然后/posts正常匹配。中间的空段可以是空参数。Path<u32>之类的提取器会在解析空串时报 400;用String接就会拿到空串。
小结
- axum 0.8.9 锁定 matchit
=0.8.4:参数{name}、通配{*name}(只能放最后)、字面花括号写{{、}};参数只能占段尾,/{id}.json不行,/v{version}可以。 - 以
:或*开头的段会被validate_v07_paths拦下并 panic,提示新语法;without_v07_checks关掉这道检查。 - 写法错误和冲突都在
route时 panic:axum 自己的检查没有前缀,matchit 的错误前缀是Invalid route "<path>":,冲突会指出已有的那条路由。 - 参数名在树里被规范化,同一位置不同名字的参数仍然冲突;参数之后分叉的路由可以共存。
- 匹配时静态段优先,走不通会回溯到跳过的参数或通配;通配不匹配空,参数不匹配末尾的空段,但中间的空段可以是空参数。
/foo与/foo/是两个路由,axum 从 0.6 起不再自动重定向。