小型改写层
主线位置:作用点主线的前两个时刻。
NormalizePath、MapRequestBody在call时改请求,type Future = S::Future;MapResponseBody、SetStatus在响应 future 完成时改响应。四个层加起来不到 300 行非测试代码,每个都只做一件事。
flowchart TB req[请求] --> np["NormalizePath:改 req.uri_mut()"] np --> mrb["MapRequestBody:req.map(&mut self.f)"] mrb --> inner[内层服务] inner --> mres["MapResponseBody:res.map(f 的克隆)"] mres --> ss["SetStatus:status_mut() 直接覆盖"] ss --> out[响应]
NormalizePath:改 URI,不改地址栏
NormalizePath::call 按模式调一个自由函数改 req.uri_mut(),然后交给内层。它不回 301/308,客户端看到的地址不变,只有内层看到的 URI 被改写了。需要「浏览器地址栏里也去掉尾斜杠」的,要的是重定向而不是这个层。
trim_trailing_slash 的核心两行:
if !uri.path().ends_with('/') && !uri.path().starts_with("//") {
return;
}
let new_path = format!("/{}", uri.path().trim_matches('/'));
trim_matches('/') 去掉的是两头所有的斜杠,再补一个开头的 /。所以它同时做了两件事:去掉结尾的斜杠,以及把开头的多个斜杠并成一个。后者是 0.4.2 的 #359(「Fix path normalization for preceding slashes」)加的,starts_with("//") 那个条件也来自那里。路径中间的 // 不动,%2F 这类编码过的斜杠不解码。
0.6.5 加的 append_trailing_slash(#547)同样先 trim_matches('/'),再在两头各补一个 /。它不区分「目录」和「文件」,/style.css 也会变成 /style.css/。
两个函数最后都 Uri::from_parts(parts),失败了就保持原样;改写时保留 query,也保留绝对形式 URI 里的 scheme 和 authority。
MapRequestBody 与 MapResponseBody
MapRequestBody::call 是 req.map(&mut self.f),闭包约束是 FnMut(ReqBody) -> NewReqBody。MapResponseBody::call 要等响应回来,只能把闭包克隆进 future,约束多一个 Clone:
impl<F, S, ReqBody, ResBody, NewResBody> Service<Request<ReqBody>> for MapResponseBody<S, F>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
F: FnMut(ResBody) -> NewResBody + Clone,
和 set_header 一族请求侧、响应侧的差别是同一回事。两者都不碰 body 里的数据,只换 body 的类型,最常见的用法是把某一层的 body 装箱成统一类型:四件套里为了让 Cors 能套在 Trace 外面,body 模块里为了让 if / else 两个分支同类型,用的都是 MapResponseBodyLayer。
SetStatus:不看内层给了什么
SetStatus 的 future 完成时只有一句:
let mut response = ready!(this.inner.poll(cx)?);
*response.status_mut() = this.status.take().expect("future polled after completion");
不看原来的状态码,也不看头。crate 里唯一用它的地方是 ServeDir::not_found_service:
pub fn not_found_service<F2>(self, new_fallback: F2) -> ServeDir<SetStatus<F2>, B> {
self.fallback(SetStatus::new(new_fallback, StatusCode::NOT_FOUND))
}
这就是 fs feature 依赖 set-status 的原因(源码地图里那条依赖边)。用 fallback 时 fallback 的状态码原样返回,用 not_found_service 时一律变成 404,包括 fallback 自己出错回的 500。ServeDir 何时调用 fallback 见 axum 卷 ServeDir 与 ServeFile。
实验:三个层的边界
tower-http = { version = "=0.7.1", features = ["normalize-path", "map-request-body", "fs"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
bytes = "1"
use bytes::Bytes;
use http::{Request, Response, StatusCode};
use http_body_util::{BodyExt, Full};
use std::convert::Infallible;
use tower::{service_fn, ServiceBuilder, ServiceExt};
use tower_http::{map_request_body::MapRequestBodyLayer, normalize_path::NormalizePathLayer, services::ServeDir};
// 内层只回显它看到的 URI
async fn echo(req: Request<()>) -> Result<Response<String>, Infallible> {
Ok(Response::new(req.uri().to_string()))
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let inputs = ["/foo/", "/foo//", "/a//b/", "//evil.example/x", "/", "/foo/?q=1", "/style.css", "/a%2F", "http://h.example/foo/"];
println!("{:<24} {:<20} {}", "request", "trim", "append");
for uri in inputs {
let trim = ServiceBuilder::new().layer(NormalizePathLayer::trim_trailing_slash()).service_fn(echo);
let append = ServiceBuilder::new().layer(NormalizePathLayer::append_trailing_slash()).service_fn(echo);
let a = trim.oneshot(Request::get(uri).body(()).unwrap()).await.unwrap().into_body();
let b = append.oneshot(Request::get(uri).body(()).unwrap()).await.unwrap().into_body();
println!("{uri:<24} {a:<20} {b}");
}
// MapRequestBody 换掉请求 body 的类型:内层收到的是 String
let svc = ServiceBuilder::new()
.layer(MapRequestBodyLayer::new(|b: Full<Bytes>| format!("wrapped({})", http_body::Body::size_hint(&b).exact().unwrap())))
.service_fn(|req: Request<String>| async move { Ok::<_, Infallible>(Response::new(req.into_body())) });
let res = svc.oneshot(Request::new(Full::from("hello"))).await.unwrap();
println!("MapRequestBody: inner got {:?}", res.into_body());
// SetStatus 不看内层给了什么:not_found_service 的 fallback 回 200 或 500,都被改成 404
for (name, status) in [("fallback 200", StatusCode::OK), ("fallback 500", StatusCode::INTERNAL_SERVER_ERROR)] {
let fallback = service_fn(move |_: Request<Full<Bytes>>| async move {
let mut r = Response::new(Full::from("from fallback"));
*r.status_mut() = status;
Ok::<_, Infallible>(r)
});
let dir = ServeDir::new("no-such-dir").not_found_service(fallback);
let res = dir.oneshot(Request::get("/missing").body(Full::<Bytes>::default()).unwrap()).await.unwrap();
let st = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("ServeDir::not_found_service, {name}: {st} {body:?}");
}
}
输出(M2 Max、rustc 1.99.0,跑 3 次相同):
request trim append
/foo/ /foo /foo/
/foo// /foo /foo/
/a//b/ /a//b /a//b/
//evil.example/x /evil.example/x /evil.example/x/
/ / /
/foo/?q=1 /foo?q=1 /foo/?q=1
/style.css /style.css /style.css/
/a%2F /a%2F /a%2F/
http://h.example/foo/ http://h.example/foo http://h.example/foo/
MapRequestBody: inner got "wrapped(5)"
ServeDir::not_found_service, fallback 200: 404 Not Found b"from fallback"
ServeDir::not_found_service, fallback 500: 404 Not Found b"from fallback"
//evil.example/x:两种模式都把开头并成一个斜杠。内层如果拿路径拼重定向地址(Location: //evil.example/x会被浏览器当成另一个站点),这一步恰好挡掉了;但这是trim_matches的副作用,不是它声明的职责,不要依赖它做安全检查。/a//b/:中间的双斜杠两种模式都保留。/style.css在 append 模式下变成/style.css/。0.7.0 起ServeDir对带尾斜杠的文件路径回 404(#678),append_trailing_slash套在ServeDir外面,所有静态文件都会 404。另用一个只有style.css的目录单独跑过(3 次相同):不套时GET /style.css -> 200 OK,套上append_trailing_slash后GET /style.css -> 404 Not Found。MapRequestBody把Full<Bytes>换成了String,内层的请求类型跟着变。- fallback 回 200 还是 500,经过
not_found_service都是 404,body 照旧。
问: 在 axum 里用 Router::new().route(...).layer(NormalizePathLayer::trim_trailing_slash()),/foo/ 还是 404,是这个层坏了吗?
答: 层没坏,是位置不对。Router::layer 套上的层在路由匹配之后才运行,那时 /foo/ 已经被判定为没有匹配的路由。要把 NormalizePath 包在整个 Router 外面,再交给 axum::serve。axum 卷的 /foo/ 返回 404 一页有完整的复现和三种修法。
小结
NormalizePath只在call时改写 URI,不重定向;trim_matches('/')让两种模式都顺手把开头的多个斜杠并成一个,中间的//和%2F不动;append模式不区分文件和目录,和 0.7.0 的ServeDir(#678)放在一起会让文件 404。MapRequestBody用&mut self.f原地调用,MapResponseBody要把闭包克隆进 future,多一个Clone约束;两者只换 body 类型,常用来统一类型、装箱。SetStatus无条件覆盖状态码;ServeDir::not_found_service就是fallback(SetStatus::new(f, 404)),fallback 回 500 也会变成 404。