/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 决定。
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 相同或不同 | 路由表 | 路由少;两条本该不同 |
NormalizePath | 200,和 /foo 相同 | 包住整个 Router | API |
from_fn 重定向 | 308 到 /foo | Router::layer(在所有路由之后)或包住 Router 都行 | 面向浏览器和搜索引擎的页面 |
/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));
}
}
动脑筋答案
连跑 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的路径。