Rust in Everything
基于 axum 0.8.9

/foo/ 返回 404

主线位置:请求主线的路由匹配那一步。matchit 按字节比较路径,/foo/ 匹配不上 /foo,请求落进 fallback。修法要回到装配主线上选位置:规范化路径的中间件必须包在路由之外,重定向的中间件可以挂在路由之后。

flowchart TB
  req["GET /foo/"] --> where{"中间件在哪"}
  where -->|"包住整个 Router"| norm["改成 /foo"]
  norm --> match1["matchit 命中 /foo"]
  where -->|"Router::layer"| match2["matchit 没命中"]
  match2 --> fb["fallback"]
  fb --> mw{"中间件做什么"}
  mw -->|"改 URI"| nf["404,已经晚了"]
  mw -->|"回 308"| redir["Location: /foo"]

复现

只注册 /foo,分别请求 /foo、/foo/、带查询串的 /foo/?a=1、两个斜杠的 /foo//。同一组请求再发给下面要讲的几种修法:

axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
tower-http = { version = "=0.7.1", features = ["normalize-path"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use axum::body::Body;
use axum::extract::Request;
use axum::http::{header, Method};
use axum::middleware::{from_fn, Next};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use axum::Router;
use tower::{Layer, Service, ServiceExt};
use tower_http::normalize_path::NormalizePathLayer;

// Redirects GET/HEAD `/x/` to `/x`, keeping the query; leaves `/` alone.
async fn redirect_slash(req: Request, next: Next) -> Response {
  if matches!(*req.method(), Method::GET | Method::HEAD) {
    let path = req.uri().path();
    let trimmed = path.trim_end_matches('/');
    if path != "/" && path.ends_with('/') && !trimmed.is_empty() && !trimmed.starts_with("//") {
      let to = match req.uri().query() {
        Some(q) => format!("{trimmed}?{q}"),
        None => trimmed.to_owned(),
      };
      return Redirect::permanent(&to).into_response();
    }
  }
  next.run(req).await
}

async fn row<S>(name: &str, svc: S)
where
  S: Service<Request, Response = Response> + Clone,
  S::Error: std::fmt::Debug,
{
  for uri in ["/foo", "/foo/", "/foo/?a=1", "/foo//"] {
    let res = svc.clone().oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();
    let loc = res.headers().get(header::LOCATION).map(|v| v.to_str().unwrap().to_owned());
    let status = res.status().as_u16();
    let body = axum::body::to_bytes(res.into_body(), 1024).await.unwrap();
    println!("{name:<31} {uri:<10} {status} {:<6} location={loc:?}", String::from_utf8_lossy(&body));
  }
}

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

  row("only /foo", foo()).await;
  row("/foo and /foo/", foo().route("/foo/", get(|| async { "foo/" }))).await;
  row("Router::layer(NormalizePath)", foo().layer(NormalizePathLayer::trim_trailing_slash())).await;
  row("NormalizePath wraps Router", NormalizePathLayer::trim_trailing_slash().layer(foo())).await;
  row("Router::layer(from_fn redirect)", foo().layer(from_fn(redirect_slash))).await;
}

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

only /foo                       /foo       200 foo    location=None
only /foo                       /foo/      404        location=None
only /foo                       /foo/?a=1  404        location=None
only /foo                       /foo//     404        location=None
/foo and /foo/                  /foo       200 foo    location=None
/foo and /foo/                  /foo/      200 foo/   location=None
/foo and /foo/                  /foo/?a=1  200 foo/   location=None
/foo and /foo/                  /foo//     404        location=None
Router::layer(NormalizePath)    /foo       200 foo    location=None
Router::layer(NormalizePath)    /foo/      404        location=None
Router::layer(NormalizePath)    /foo/?a=1  404        location=None
Router::layer(NormalizePath)    /foo//     404        location=None
NormalizePath wraps Router      /foo       200 foo    location=None
NormalizePath wraps Router      /foo/      200 foo    location=None
NormalizePath wraps Router      /foo/?a=1  200 foo    location=None
NormalizePath wraps Router      /foo//     200 foo    location=None
Router::layer(from_fn redirect) /foo       200 foo    location=None
Router::layer(from_fn redirect) /foo/      308        location=Some("/foo")
Router::layer(from_fn redirect) /foo/?a=1  308        location=Some("/foo?a=1")
Router::layer(from_fn redirect) /foo//     308        location=Some("/foo")

第一组就是这个坑:/foo/ 是 404,body 为空,日志里只有一个 404,看不出是尾斜杠的问题。原因在 matchit:它按字节匹配,/foo/ 比 /foo 多一个字节,就是另一条路径,axum 拿到「没匹配」后交给 fallback。这一点和路径参数不匹配末尾空段(/users/ 匹配不上 /users/{id})在 matchit 与路径语法 里有实验。

axum 为什么不替你跳

axum 以前会跳。0.3.0 换到 matchit 时加了这个行为,CHANGELOG 写的是:路由 /foo 存在而请求是 /foo/,「axum will send a 301 redirection」,反过来也一样;两个都注册就不跳。0.6.0 把它删掉了。

删掉的理由在 issue #1118「Consider removing support for trailing slash redirects」:「I think its a surprising feature and not something most users would expect the default behavior to be. Its almost like adding a hidden route.」同一个 issue 里定下了两件事:不提供开关找回旧行为(「I don't think we should provide a configuration option to get the old behavior back. If users want both /foo and /foo/ they can add those routes themselves.」),以及另做一个去尾斜杠的中间件放进 tower-http,作为升级路径。讨论里也有人指出「两条都返回同样的内容」和「跳到一个规范地址」不是一回事:同一页面在两个 URL 上,浏览器历史里会有两条记录,没有 <link rel="canonical"> 时搜索引擎可能当成重复内容。

#1119「Remove trailing slash redirects」关闭了这个 issue,首次发布于 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. Consider using axum_extra::routing::RouterExt::route_with_tsr if you want the old behavior」。从那以后,axum 本体对尾斜杠什么也不做,/foo/ 返回什么完全由你的 fallback 决定。

📝NOTE
冷知识:自动跳转先是 301,后来改成 308

0.3.0 的自动跳转用的是 301。#682(首次发布于 axum-v0.4.4)把它换成 308,PR 说明的动机是:路由 /path 存在时,「POST /path/ currently may redirect to GET /path when it should redirect to POST /path (depending on the client)」,并指出「This has potential security implications」:POST 里的敏感数据可能因此出现在浏览器历史或服务端访问日志里。301 允许客户端把非 GET 请求改成 GET 再跟随,308 不允许。CHANGELOG 同一条还把 Redirect::found(302)标为 deprecated,理由一样:「This is to prevent clients from changing non-GET requests to GET requests」。今天 Redirect::permanent 生成的就是 308,下面本站的中间件用的正是它,而且只对 GET、HEAD 跳。

修法一:两条都注册

第二组:/foo 和 /foo/ 各注册一次,都是 200。这正是 issue 里说的「they can add those routes themselves」。路由少、或者两条本来就该返回不同内容时,这是最直接的写法。代价是同一个内容有两个 URL,而且每加一条路由都要记得写两遍;/foo// 仍然 404。

修法二:NormalizePath 包住整个 Router

tower-http 的 NormalizePath(feature normalize-path)就是 issue 里说的那个中间件。它的 Service 实现 在 call 里只是改 URI,再交给内层:

    fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
        match self.mode {
            NormalizeMode::Trim => trim_trailing_slash(req.uri_mut()),
            NormalizeMode::Append => append_trailing_slash(req.uri_mut()),
        }
        self.inner.call(req)
    }

trim_trailing_slash 用 format!("/{}", uri.path().trim_matches('/')) 重写路径,查询串原样接回。trim_matches 去掉的是两端所有的 /,所以 /foo// 也变成 /foo,开头的 // 也被压成一个;路径中间的 // 不动。另有 append_trailing_slash 模式,方向相反。

第三组和第四组是同一个 layer,挂法不同:

  • Router::layer(NormalizePathLayer::trim_trailing_slash()):/foo/ 仍然 404。 Router::layer 把 layer 套在每个端点上,中间件在路由之后执行(layer 与 route_layer)。/foo/ 先在 matchit 里匹配失败,进了 fallback,NormalizePath 包着的是那个 404 的 NotFound,它把 URI 改成 /foo 再交给 NotFound,结果还是 404。编译器不会拦:NormalizePath 的错误类型和内层一样,满足 Router::layer 的全部约束。
  • NormalizePathLayer::trim_trailing_slash().layer(router):/foo/ 200。 Layer::layer 直接包住整个 Router,NormalizePath::call 先改 URI,Router::call 后匹配。

包完之后的类型是 NormalizePath<Router>,不再是 Router,不能再 .route、.with_state;交给 axum::serve 时要转成 make service:

use axum::extract::Request;
use axum::routing::get;
use axum::{Router, ServiceExt};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let router = Router::new().route("/foo", get(|| async { "foo" }));
  // Wrapping from outside: the result is NormalizePath<Router>, no longer a Router.
  let app = NormalizePathLayer::trim_trailing_slash().layer(router);

  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  tokio::spawn(async move {
    axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await.unwrap();
  });

  let mut stream = TcpStream::connect(addr).await.unwrap();
  stream
    .write_all(b"GET /foo/ HTTP/1.1\r\nhost: x\r\nconnection: close\r\n\r\n")
    .await
    .unwrap();
  let mut buf = String::new();
  stream.read_to_string(&mut buf).await.unwrap();
  let first = buf.lines().next().unwrap();
  let body = buf.split("\r\n\r\n").nth(1).unwrap();
  println!("{first} | body {body:?}");
}

依赖在上面的基础上,tokio 加 net、io-util。连跑 3 次输出相同:

HTTP/1.1 200 OK | body "foo"

ServiceExt::<Request>::into_make_service 是 axum::ServiceExt(不是 tower 的),要写出请求类型,因为 NormalizePath<Router> 对任意 Request<B> 都实现了 Service。

NormalizePath 的代价和修法一一样:/foo/ 和 /foo 返回同样的内容、同样的 200,客户端看到的仍是两个 URL。它适合 API:调用方不关心规范地址,只要别 404。

修法三:重定向到规范地址

要让 /foo/ 跳到 /foo,用一个 from_fn 中间件回 308。第五组就是这样:/foo/ 得到 308 和 location: /foo,查询串保留。

这个中间件挂在 Router::layer 上就能用,和修法二相反。区别在于它不改 URI 让路由重新匹配,而是自己生成响应、不调用 next:/foo/ 匹配失败进入 fallback,fallback 被 Router::layer 包着,中间件在 fallback 前面拦下请求,直接返回 308。路由已经做完,对它没有影响。前提是 .layer 写在所有路由和 fallback 之后(中间件没生效)。

本站的 trailing_slash_mw 就是这个写法,同时处理旧博客地址的跳转:

pub async fn trailing_slash_mw(
  req: axum::extract::Request,
  next: axum::middleware::Next,
) -> axum::response::Response {
  use axum::http::Method;
  use axum::response::{IntoResponse, Redirect};
  if matches!(*req.method(), Method::GET | Method::HEAD) {
    let path = req.uri().path();
    let target =
      legacy_blog_target(path).or_else(|| slash_redirect_target(path, req.uri().query()));
    if let Some(to) = target {
      return Redirect::permanent(&to).into_response();
    }
  }
  next.run(req).await
}

尾斜杠的目标地址由 slash_redirect_target 算:

fn slash_redirect_target(path: &str, query: Option<&str>) -> Option<String> {
  if path == "/" || !path.ends_with('/') || path.starts_with("/api/") {
    return None;
  }
  let trimmed = path.trim_end_matches('/');
  if trimmed.is_empty() || trimmed.starts_with("//") {
    return None;
  }
  Some(match query {
    Some(q) => format!("{trimmed}?{q}"),
    None => trimmed.to_string(),
  })
}

几处判断各有原因:

  • 只跳 GET、HEAD。 其余方法原样交给 next。308 会让客户端带着原方法和 body 重发一次,对提交数据的请求没有好处。
  • / 不跳,去掉斜杠就成了空串。
  • /api/ 开头的不跳。 API 的调用方不是浏览器地址栏,跳转只会多一次往返;本站的 server function 都在 /api/ 下。
  • 去掉斜杠后以 // 开头的不跳。 请求 //evil.com/ 会得到 Location: //evil.com,浏览器把它当成协议相对地址,跳到别的站点,也就是开放重定向。函数的注释和单元测试都写了这一条(slash_redirect_target("//evil.com/", None) 断言为 None,「不能变成开放重定向」)。
  • 查询串保留:测试里 slash_redirect_target("/docs/axum//", Some("a=1")) 得到 /docs/axum?a=1,末尾的多个斜杠一起去掉。

它在 main.rs 里用 router.layer(axum::middleware::from_fn(crate::server::seo::trailing_slash_mw)) 挂上,前面所有路由(dioxus::server::router(App) 生成的、/healthz、静态资源、SEO 路由)都已注册;页面渲染是 dioxus-server 0.7.9 用 .fallback(get(FullstackState::render_handler)) 挂上的 fallback,也在 .layer 之前,所以每一个 /xxx/ 都会经过它。(那段测试的注释写的是「尾斜杠 301」,实际 Redirect::permanent 发的是 308。)本站路由的全貌见「在本站中」的 路由与静态资源。

怎么选

修法/foo/ 的结果挂在哪适合
两条都注册200,内容和 /foo 相同或不同路由表路由少;两条本该不同
NormalizePath200,和 /foo 相同包住整个 RouterAPI
from_fn 重定向308 到 /fooRouter::layer(在所有路由之后)或包住 Router 都行面向浏览器和搜索引擎的页面
❗IMPORTANT
动脑筋:同时注册 /foo 和 /foo/,再用 NormalizePath 包住整个 Router,GET /foo/ 由哪个 handler 处理?
use axum::body::Body;
use axum::extract::Request;
use axum::routing::get;
use axum::Router;
use tower::{Layer, ServiceExt};
use tower_http::normalize_path::NormalizePathLayer;

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let router = Router::new()
    .route("/foo", get(|| async { "route /foo" }))
    .route("/foo/", get(|| async { "route /foo/" }));
  let app = NormalizePathLayer::trim_trailing_slash().layer(router);
  for uri in ["/foo", "/foo/"] {
    let res = app.clone().oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();
    let status = res.status();
    let body = axum::body::to_bytes(res.into_body(), 1024).await.unwrap();
    println!("GET {uri:<5} -> {status} {:?}", String::from_utf8_lossy(&body));
  }
}
依赖同复现程序。M2 Max、rustc 1.99.0。答案在本页最后。

动脑筋答案

连跑 3 次输出相同:

GET /foo  -> 200 OK "route /foo"
GET /foo/ -> 200 OK "route /foo"

/foo/ 也由 /foo 的 handler 处理。NormalizePath 在路由之前把每个请求的尾斜杠都去掉,路由器再也见不到以 / 结尾的路径(根路径 / 除外),/foo/ 这条路由永远匹配不上,成了死代码,编译和启动都不会提醒。从修法一迁到修法二时,要把那些带尾斜杠的路由一并删掉。

小结

  • matchit 按字节匹配,/foo/ 和 /foo 是两条路由;只注册一条时,另一条进 fallback,默认 404。
  • axum 0.3.0 起自动跳转(先 301,0.4.4 起 308),0.6.0 由 #1119 删掉,理由是「Its almost like adding a hidden route」,并且不提供开关。
  • 两条都注册最直接,代价是同一内容两个 URL。
  • NormalizePath 必须用 Layer::layer(router) 包住整个 Router;挂在 Router::layer 上时路由已经做完,/foo/ 照样 404,编译器不提醒。包完后要用 axum::ServiceExt::into_make_service 交给 axum::serve。
  • 要一个规范地址就用 from_fn 回 308,它不依赖重新路由,挂在最后一个 Router::layer 上即可;本站的 trailing_slash_mw 只跳 GET、HEAD,跳过 /、/api/ 和会变成 //host 的路径。

💬 关联讨论

发起讨论
加载中...