Rust in Everything
基于 axum 0.8.9

nest、merge 与 fallback

主线位置:主要在装配主线。nest 和 merge 都不保留子路由器这个「整体」,而是把子路由器里的路由一条条搬进父路由器(nest_service 例外),fallback 也一样被搬进父路由器的第二棵树。请求主线上能看到的只是结果:一次 matchit 匹配、一层 StripPrefix 改写 URI,以及没匹配上时按顺序查的三级兜底。

flowchart TB
  req["请求"] --> pr["path_router:普通路由"]
  pr -->|命中| ep["端点:MethodRouter 或 Route"]
  ep -->|"方法不对"| m405["MethodRouter 的 fallback:405"]
  pr -->|NotFound| fr["fallback_router"]
  fr -->|"命中子前缀"| inner["子路由器的 fallback"]
  fr -->|"命中顶层通配"| outer["本路由器的 fallback"]
  fr -->|NotFound| ca["catch_all_fallback"]

RouterInner 有四个字段:path_router、fallback_router、default_fallback、catch_all_fallback。它们各自是什么、Router::call_with_state 怎么依次尝试,见 Router 的内部结构。这一页讲 nest、merge、fallback 怎么改这几个字段。

nest:拆开、加前缀、逐条注册

Router::nest:

    pub fn nest(self, path: &str, router: Router<S>) -> Self {
        if path.is_empty() || path == "/" {
            panic!("Nesting at the root is no longer supported. Use merge instead.");
        }

        let RouterInner {
            path_router,
            fallback_router,
            default_fallback,
            // 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.
            catch_all_fallback: _,
        } = router.into_inner();

        tap_inner!(self, mut this => {
            panic_on_err!(this.path_router.nest(path, path_router));

            if !default_fallback {
                panic_on_err!(this.fallback_router.nest(path, fallback_router));
            }
        })
    }

子路由器被拆成零件:普通路由进父路由器的 path_router;子路由器有自定义 fallback 时(!default_fallback),它的 fallback_router 也进父路由器的 fallback_router。两步用的是同一个 PathRouter::nest:对子路由的每条路径调用 path_for_nested_route 拼上前缀,给端点套上 (StripPrefix::layer(prefix), SetNestedPath::layer(path_to_nest_at)),再像普通的 route 一样注册。之后父路由器里没有「子路由器」,只有一批带前缀的路由。

拼前缀有一条特例:子路由器的 / 拼出来是前缀本身,不是「前缀 + /」:

    if prefix.ends_with('/') {
        format!("{prefix}{}", path.trim_start_matches('/')).into()
    } else if path == "/" {
        prefix.into()
    } else {
        format!("{prefix}{path}").into()
    }

nest_service 则不拆。服务是不透明的,axum 不知道里面有哪些路径,只能在父路由树里注册 {prefix}/{*__private__axum_nest_tail_param},再补上 {prefix} 和 {prefix}/ 两条(源码注释:「/{*rest} is not matched by / so we need to also register a router at the prefix itself」)。三条都指向同一个套了 StripPrefix 的 Route;如果服务本身是一个 Router,它收到剥过前缀的请求后自己再匹配一次。

同一个只有 / 路由的子路由器,分别挂到 /n 和 /s:

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, http::{Request, Uri}, routing::get};
use http_body_util::BodyExt;
use tower::ServiceExt;

#[tokio::main]
async fn main() {
  let inner = || Router::new().route("/", get(|uri: Uri| async move { format!("inner / sees {uri}") }));
  let app = Router::new().nest("/n", inner()).nest_service("/s", inner());

  for uri in ["/n", "/n/", "/s", "/s/", "/s/x"] {
    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:<5} {status} {:?}", String::from_utf8_lossy(&body));
  }
}

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同:

/n    200 "inner / sees /"
/n/   404 ""
/s    200 "inner / sees /"
/s/   200 "inner / sees /"
/s/x  404 ""
  • nest 下子路由的 / 变成了 /n,所以 /n/ 是 404,和 matchit 与路径语法 里讲的尾斜杠规则一致。nest.md 文档里也写了:「a nested router at /foo will match the path /foo (but not /foo/)」。
  • nest_service 下 /s 和 /s/ 都进了子路由器,剥前缀后都是 /。/s/x 也进了子路由器,是子路由器自己回的 404。

StripPrefix:子路由看到的路径

StripPrefix 在每次调用时改写 req.uri_mut()。strip_prefix 把请求路径和前缀模板按段对齐:前缀段是参数({version})就匹配任意段,否则要字面相等;数出前缀占了多少字节,切掉,剩下的部分不以 / 开头就补一个,查询串原样接回去。前缀里可以有参数,正是因为这里按模板而不是按字面比较。

结果是:nest 和 nest_service 下,handler 的 Uri 提取器看到的都是剥过前缀的路径,OriginalUri 才是原始的;MatchedPath 两种挂法都是完整模板。这些在 Path、Query 与请求元数据 里已经用同一个子路由器、两种挂法对比过,这里不重复。两种挂法真正的区别在三处:

nest(path, Router)nest_service(path, svc)
父路由树里注册什么子路由的每一条,各加前缀{path}、{path}/、{path}/{*tail} 三条
每个请求匹配几次一次两次(父树一次,子 Router 自己一次)
/{path} 和 /{path}/取决于子路由器有没有 /都进服务
子路由器的 fallback搬进父路由器,没有就继承父路由器的留在子路由器里,不继承

能用 nest 时用 nest。nest_service 是给不透明的服务准备的,典型的是 tower-http 的 ServeDir(见 ServeDir 与 ServeFile)。把 Router 交给 route_service 会直接 panic,提示改用 nest(见下面的输出)。

什么情况会 panic

nest、nest_service、merge 最终都是往 matchit 里插路由,冲突的规则和 route 一样;它们自己还有几条检查。挨个试:

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 {});
  let inner = || Router::new().route("/users", get(|| async {}));
  let svc = || tower::service_fn(|_| async { Ok::<_, std::convert::Infallible>("svc") });

  try_build("nest(\"/\", inner)", || Router::new().nest("/", inner()));
  try_build("nest_service(\"/\", svc)", || Router::new().nest_service("/", svc()));
  try_build("nest(\"/files/{*rest}\", inner)", || Router::new().nest("/files/{*rest}", inner()));
  try_build("nest(\"/:version\", inner)", || Router::new().nest("/:version", inner()));
  try_build("nest(\"/api\", a).nest(\"/api\", b)", || {
    Router::new().nest("/api", inner()).nest("/api", Router::new().route("/teams", h()))
  });
  try_build("nest(\"/api\", inner).route(\"/api/users\", ..)", || {
    Router::new().nest("/api", inner()).route("/api/users", h())
  });
  try_build("nest_service(\"/api\", svc).route(\"/api\", ..)", || {
    Router::new().nest_service("/api", svc()).route("/api", h())
  });
  try_build("route(\"/users\", get).merge(route(\"/users\", post))", || {
    Router::new()
      .route("/users", h())
      .merge(Router::new().route("/users", axum::routing::post(|| async {})))
  });
  try_build("route(\"/users\", get).merge(route(\"/users\", get))", || {
    Router::new().route("/users", h()).merge(Router::new().route("/users", h()))
  });
  try_build("route(\"/{id}\").merge(route(\"/{name}\"))", || {
    Router::new().route("/{id}", h()).merge(Router::new().route("/{name}", h()))
  });
  try_build("a.fallback(f).merge(b.fallback(f))", || {
    Router::new()
      .fallback(|| async {})
      .merge(Router::new().fallback(|| async {}))
  });
  try_build("a.fallback(f).merge(b.fallback(f).reset_fallback())", || {
    Router::new()
      .fallback(|| async {})
      .merge(Router::new().fallback(|| async {}).reset_fallback())
  });
  try_build("route_service(\"/api\", Router)", || {
    Router::new().route_service("/api", inner())
  });
  try_build("nest(\"/api\", a.fallback(f)).nest(\"/api\", b.fallback(f))", || {
    Router::new()
      .nest("/api", inner().fallback(|| async {}))
      .nest("/api", Router::new().route("/teams", h()).fallback(|| async {}))
  });
}

连跑 3 次,输出相同:

nest("/", inner)
  panic: Nesting at the root is no longer supported. Use merge instead.
nest_service("/", svc)
  panic: Nesting at the root is no longer supported. Use fallback_service instead.
nest("/files/{*rest}", inner)
  panic: Invalid route: nested routes cannot contain wildcards (*)
nest("/:version", inner)
  panic: called `Result::unwrap()` on an `Err` value: "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."
nest("/api", a).nest("/api", b)
  ok
nest("/api", inner).route("/api/users", ..)
  panic: Overlapping method route. Handler for `GET /api/users` already exists
nest_service("/api", svc).route("/api", ..)
  panic: Invalid route "/api": Insertion failed due to conflict with previously registered route: /api
route("/users", get).merge(route("/users", post))
  ok
route("/users", get).merge(route("/users", get))
  panic: Overlapping method route. Handler for `GET /users` already exists
route("/{id}").merge(route("/{name}"))
  panic: Invalid route "/{name}": Insertion failed due to conflict with previously registered route: /{id}
a.fallback(f).merge(b.fallback(f))
  panic: Cannot merge two `Router`s that both have a fallback
a.fallback(f).merge(b.fallback(f).reset_fallback())
  ok
route_service("/api", Router)
  panic: Invalid route: `Router::route_service` cannot be used with `Router`s. Use `Router::nest` instead
nest("/api", a.fallback(f)).nest("/api", b.fallback(f))
  panic: Cannot merge two `MethodRouter`s that both have a fallback
  • 在根上 nest:Router::nest 和 Router::nest_service 一开头就拦下 "" 和 "/",并分别指向 merge 和 fallback_service。
  • 前缀里不能有通配:validate_nest_path 检查。它对旧语法的检查用的是 validate_v07_paths(path).unwrap(),所以同一句提示在这里被包成了 called `Result::unwrap()` on an `Err` value: "...",不像 route 那样干净。
  • 同一前缀 nest 两次是可以的,只要两边的路由不撞:拍平之后它们只是几条不同的路径。0.6.0 的 CHANGELOG 写过「.nest("/foo", a).nest("/foo", b) will panic」,在 0.8.9 里已经不是这样。
  • 和 nest 进来的路由撞车,报错与直接 route 两次完全相同:父路由器里本来就只是两条同路径的路由。
  • merge 的冲突:PathRouter::merge 对另一边的每条路由调用 self.route 或 self.route_service,同路径不同方法会合并,同方法、同位置参数都 panic,和 MethodRouter、matchit 与路径语法 里的报错一致。
📝NOTE
冷知识:nest("/", ...) 曾经是合法的,0.8 才禁止

#3051(2024-11-26 合并,首次发布于 axum-v0.8.0-rc.1,正式版 axum-v0.8.0)加上了上面那两句 panic。PR 给的理由只有一句:「It's confusing, there isn't really any nesting going on.」在根上 nest 既没有前缀可剥,也没有前缀可加,效果和 merge 一样;nest_service 在根上则等于「所有没匹配的请求都给这个服务」,就是 fallback_service。PR 的原话:「merge can fully replace nest("" | "/", ...), fallback_service can fully replace nest_service("" | "/", ...).」于是 panic 信息直接告诉你该换成哪一个。

merge:两棵树合成一棵

Router::merge 先把 path_router 合并(上面说的逐条 route),再按两边是否有自定义 fallback 分四种情况合并 fallback_router:

            match (this.default_fallback, default_fallback) {
                // both have the default fallback
                // use the one from other
                (true, true) => {
                    this.fallback_router.merge(other_fallback).expect(PANIC_MSG);
                }
                // this has default fallback, other has a custom fallback
                (true, false) => {
                    this.fallback_router.merge(other_fallback).expect(PANIC_MSG);
                    this.default_fallback = false;
                }
                // this has a custom fallback, other has a default
                (false, true) => {
                    let fallback_router = std::mem::take(&mut this.fallback_router);
                    other_fallback.merge(fallback_router).expect(PANIC_MSG);
                    this.fallback_router = other_fallback;
                }
                // both have a custom fallback, not allowed
                (false, false) => {
                    panic!("Cannot merge two `Router`s that both have a fallback")
                }
            };

每个 fallback_router 都有 / 和 /{*__private__axum_fallback} 两条顶层路由,直接合并必然冲突。PathRouter::merge 在 IS_FALLBACK 为真时对这两条路径改用 replace_endpoint,用另一边的端点覆盖自己的;Router::merge 负责让「另一边」总是想保留的那一个。所以 a.merge(b) 和 b.merge(a) 的 fallback 结果相同(源码注释:「when merging two routers it doesn't matter if you do a.merge(b) or b.merge(a). This must also be true for fallbacks」)。两边都有自定义 fallback 时没法选,panic;要保留其中一个,先对另一个调 reset_fallback。catch_all_fallback 用 Fallback::merge 按同样的规则取舍。

fallback 在哪里、谁继承谁

Router::fallback 做两件事:把 handler 存成 catch_all_fallback,再把 any(handler) 交给 fallback_endpoint,后者调用 set_fallback:

    pub(super) fn set_fallback(&mut self, endpoint: Endpoint<S>) {
        self.replace_endpoint("/", endpoint.clone());
        self.replace_endpoint(FALLBACK_PARAM_PATH, endpoint);
    }

也就是在 fallback_router 这棵树里,把 / 和 /{*__private__axum_fallback} 两条路由换成你的 handler,并把 default_fallback 设成 false。fallback 不是一个特殊的槽,而是第二棵路由树里的两条普通路由。

这样继承就有了很直接的实现。子路由器有自定义 fallback 时,nest 把它的 fallback_router 也加前缀搬过来,父路由器的 fallback 树里多出 /b 和 /b/{*__private__axum_fallback};请求 /b/x 在普通路由树里没匹配上,到 fallback 树里,matchit 的静态优先让 /b/{*..} 赢过 /{*..},于是子路由器的 fallback 接住。子路由器没有自定义 fallback 时什么都不搬,/a/x 只能匹配父路由器的 /{*..},用的就是父路由器的 fallback。nest_service 不碰 fallback 树,子 Router 的兜底留在它自己里面。

📝NOTE
冷知识:fallback 树是为了「拍平」才有的

#1711(2023-04-11 合并,首次发布于 axum-v0.6.13)把 nest 改成拍平:「just moving the routes from one router to another by calling .route(nest_path, nested_endpoint)」。CHANGELOG 记的理由是修 0.6.0 引入的性能回退(「nest now flattens the routes which performs better」)。难点在 fallback 继承,PR 描述说:「The main reason we didn't flatten routes in 0.6 previously was specifically because of fallback inheritance」。解决办法就是本页的结构:新增内部类型 PathRouter,「A Router then contains two PathRouters: One for the actual routes and another for fallbacks. Then when you add a fallback to the root router that adds / and /* routes to the fallback PathRouter. Nesting is then pretty straight forward: We just nest the routes from each PathRouter.」

继承规则本身改过几次:0.4 起 nest 一个带 fallback 的路由器会 panic(#529,此前 fallback 被静默丢弃);0.6.0-rc.1 的 #1086 让嵌套路由器不再委托给外层 fallback;0.6.0-rc.5 的 #1521 又加回继承,并允许子路由器有自己的 fallback;0.7.0 的 #1956 规定只有 nest 继承、nest_service 不继承,理由是「It also simplifies the code and avoids some unfortunate extension work arounds」。

把四种挂法放在一起:

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,
  http::{Method, Request, StatusCode, Uri},
  routing::get,
};
use http_body_util::BodyExt;
use tower::ServiceExt;

fn fb(name: &'static str) -> impl Fn(Uri) -> std::future::Ready<String> + Clone {
  move |uri: Uri| std::future::ready(format!("{name} sees {uri}"))
}

async fn send(app: &Router, method: Method, uri: &str) {
  let req = Request::builder().method(method.clone()).uri(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!("{:<5}{uri:<10} {status} {:?}", method.as_str(), String::from_utf8_lossy(&body));
}

#[tokio::main]
async fn main() {
  let users = || Router::new().route("/users", get(|| async { "users" }));

  let app = Router::new()
    .nest("/a", users())
    .nest("/b", users().fallback(fb("b_fallback")))
    .nest_service("/c", users())
    .nest_service("/d", users().fallback(fb("d_fallback")))
    .fallback(fb("outer_fallback"));

  for uri in ["/a/x", "/b/x", "/c/x", "/d/x", "/x", "/a", "/b", "/b/", "/c"] {
    send(&app, Method::GET, uri).await;
  }
  send(&app, Method::POST, "/a/users").await;

  println!("-- fallback set before nest");
  let app = Router::new().fallback(fb("outer_fallback")).nest("/a", users());
  send(&app, Method::GET, "/a/x").await;

  println!("-- method_not_allowed_fallback before / after nest");
  let m405 = |m: Method| async move { (StatusCode::METHOD_NOT_ALLOWED, format!("custom 405 for {m}")) };
  let before = Router::new().method_not_allowed_fallback(m405).nest("/a", users());
  let after = Router::new().nest("/a", users()).method_not_allowed_fallback(m405);
  send(&before, Method::POST, "/a/users").await;
  send(&after, Method::POST, "/a/users").await;

  println!("-- merge keeps whichever side has the fallback");
  let left = users().fallback(fb("left_fallback")).merge(Router::new().route("/teams", get(|| async { "teams" })));
  let right = Router::new().route("/teams", get(|| async { "teams" })).merge(users().fallback(fb("right_fallback")));
  send(&left, Method::GET, "/x").await;
  send(&right, Method::GET, "/x").await;
}

连跑 3 次输出相同,完整输出在本页最后。整理成表(/b/ 那一行留给动脑筋):

请求挂法结果
GET /a/xnest,子路由器无 fallback200 outer_fallback sees /a/x
GET /b/xnest,子路由器有 fallback200 b_fallback sees /x
GET /c/xnest_service,子路由器无 fallback404,空 body
GET /d/xnest_service,子路由器有 fallback200 d_fallback sees /x
GET /x不在任何前缀下200 outer_fallback sees /x
GET /a子路由器没有 / 路由200 outer_fallback sees /a
GET /b同上,但子路由器有 fallback200 b_fallback sees /
GET /cnest_service404,空 body
POST /a/users路径存在、方法不对405,空 body
❗IMPORTANT
动脑筋:上面的程序里,GET /b/ 由哪个 fallback 处理?它看到的 URI 是什么?

提示:子路由器挂在 /b,自己有 fallback;父路由器也有 fallback。M2 Max、rustc 1.99.0。答案在本页最后。

  • nest 继承,nest_service 不继承。 /c/x 的 404 是子 Router 自己默认的 NotFound,父路由器的 fallback 根本没机会。
  • 子路由器的 fallback 也看到剥过前缀的 URI(/x、/),因为它和普通路由一样经过 PathRouter::nest,套了 StripPrefix;父路由器的 fallback 看到的是完整路径。
  • /b 进了子路由器的 fallback:子 fallback 树里的 / 按 path_for_nested_route 的特例拼成了 /b。
  • 405 不归 Router::fallback 管。 POST /a/users 的路径匹配上了,交给 MethodRouter,由它的 fallback 回 405(fallback.md 文档原话:「if the request hits a valid path but the MethodRouter does not have an appropriate method handler installed, the fallback is not called」)。
  • fallback 写在 nest 前后都一样(输出的「fallback set before nest」一段):set_fallback 只替换顶层的两条,nest 搬进来的 /a/{*..} 不受影响。
  • method_not_allowed_fallback 有顺序。 PathRouter::method_not_allowed_fallback 遍历的是调用那一刻 routes 里已有的 MethodRouter,写在 nest 之前时,后来搬进来的路由没有被改,仍然是默认的空 405。
  • merge 的两种方向都保留了唯一的那个 fallback(left_fallback、right_fallback)。
📝NOTE
没有蠢问题

问: 两个都带 fallback 的子路由器 nest 到同一个 /api,报错是 Cannot merge two `MethodRouter`s that both have a fallback。我没有合并任何 MethodRouter,这句话从哪来?

答: 从 fallback 的存法来。Router::fallback 往 fallback 树里放的是 Endpoint::MethodRouter(any(handler)),而 any 就是一个只有 fallback 槽的 MethodRouter。第二次 nest 时,两边都要往父路由器的 fallback 树里注册 /api 和 /api/{*__private__axum_fallback};PathRouter::route 发现同一路径已有 MethodRouter,就照常走 merge_for_path 去合并,两边的 fallback 槽都被占了,于是报这句。换句话说,同一前缀下只能有一个子路由器带 fallback。把这些路由放进同一个子路由器、只给它设一个 fallback;两个都带 fallback 的路由器直接 merge 同样会 panic,只是报的是「two Routers」那句,更好懂。

动脑筋答案

fallback 程序的完整输出,连跑 3 次相同:

GET  /a/x       200 "outer_fallback sees /a/x"
GET  /b/x       200 "b_fallback sees /x"
GET  /c/x       404 ""
GET  /d/x       200 "d_fallback sees /x"
GET  /x         200 "outer_fallback sees /x"
GET  /a         200 "outer_fallback sees /a"
GET  /b         200 "b_fallback sees /"
GET  /b/        200 "outer_fallback sees /b/"
GET  /c         404 ""
POST /a/users   405 ""
-- fallback set before nest
GET  /a/x       200 "outer_fallback sees /a/x"
-- method_not_allowed_fallback before / after nest
POST /a/users   405 ""
POST /a/users   405 "custom 405 for POST"
-- merge keeps whichever side has the fallback
GET  /x         200 "left_fallback sees /x"
GET  /x         200 "right_fallback sees /x"

GET /b/ 由父路由器的 fallback 处理,看到的是完整的 /b/:

  • 父路由器的 fallback 树里有四条:/、/{*__private__axum_fallback}(父的),/b、/b/{*__private__axum_fallback}(从子路由器搬来的)。
  • /b/ 不等于 /b;/b/{*..} 的通配不匹配空,所以子路由器那两条都不命中。
  • 剩下的 /{*..} 匹配 b/,父路由器的 fallback 接住。它没有套 StripPrefix,URI 不变。

明明是「/b 下面的请求」,却绕过了 /b 自己的 fallback。如果子路由器的 fallback 回的是特定格式(比如 JSON 错误体),/b/ 会得到父路由器的格式。

小结

  • nest 把子路由器拆开:每条路由加前缀、套 StripPrefix 和 SetNestedPath,逐条注册进父路由器;子路由的 / 变成前缀本身,所以 /prefix/ 不匹配。
  • nest_service 只注册 {prefix}、{prefix}/、{prefix}/{*tail} 三条,服务收到剥过前缀的请求;Router 交给它会多匹配一次,且不继承 fallback。
  • 在根上 nest、前缀含通配会 panic;同一前缀 nest 两次可以,只要路由不撞。
  • merge 逐条重新注册,冲突规则和 route 相同;两边都有自定义 fallback 时 panic,先 reset_fallback 其中一个。
  • fallback 是第二棵路由树里的 / 和 /{*..} 两条路由;nest 把子路由器的 fallback 树加前缀搬过来,靠静态优先实现「子覆盖父」,子路由器没有 fallback 时自然落到父的。
  • 405 不走 Router::fallback;method_not_allowed_fallback 只作用于调用时已经存在的路由。

💬 关联讨论

发起讨论
加载中...