Rust in Everything
基于 axum 0.8.9

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。

📝NOTE
没有蠢问题

问: /{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/new200/users/new
/users/42200/users/{id}id="42"
/users/new/edit200/users/new/edit
/users/42/edit404
/assets/logo.png200/assets/logo.png
/assets/img/a.png200/assets/{*path}path="img/a.png"
/assets/404
/assets404
/v2/status200/v{version}/statusversion="2"
/{literal}200/{{literal}}
  • 同一层有静态段和参数时,静态段先试:/users/new 不会落进 /users/{id},/assets/logo.png 不会落进通配。
  • 通配不匹配空:/assets/ 和 /assets 都是 404。axum 的 route.md 文档原话:「/x/{*key} doesn't match /x or /x/」。要让 /assets 本身也有响应,得另外注册。
  • /users/42/edit 是 404:{id} 分支下只有 /posts,没有回溯可做。
  • 通配捕获的值不带开头的 /(img/a.png)。
❗IMPORTANT
动脑筋:/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 与请求元数据。

📝NOTE
冷知识:/{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 起不再自动重定向。

💬 关联讨论

发起讨论
加载中...