Rust in Everything
基于 tower-http 0.7.1

小型改写层

主线位置:作用点主线的前两个时刻。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 照旧。
📝NOTE
没有蠢问题

问: 在 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。

💬 关联讨论

发起讨论
加载中...