Rust in Everything
基于 axum 0.8.9

路由与静态资源

主线位置:请求主线的末端。上一页那棵树里,main.rs 自己挂上去的叶子都在这里:几组 handler、一串 ServeDir,以及包在全部路由外面的尾斜杠跳转。装配上只有 route、nest、nest_service 三种动作;值得看的是每个叶子拿什么提取器、回什么响应,以及静态文件在 tower-http 两个版本下的差别。

flowchart TB
  base["dioxus 的 Router"] --> auth["auth mount"]
  auth --> st["static mount"]
  st --> pay["pay mount"]
  pay --> seo["seo mount"]
  seo --> lay["四层 layer"]
  st --> ns["nest_service"]
  st --> jsn["/js:子 Router"]
  st --> crs["/courses:子 Router"]
  lay --> tsm["尾斜杠跳转"]

本页的站点源码链接都指向本站仓库的提交 89a61b1。站点锁的 tower-http 是 0.6.11,本卷讲的是 0.7.1;前两节的机制在两个版本里一样,最后一节讲差别。

四个 mount 函数

main.rs 依次调用四个 mount(router, ...) -> Router(见 dioxus 生成的 Router)。每个函数接一个 Router、往上加几条路由、再交回去,没有共享状态。handler 全是闭包,配置靠 move 捕获:拿到的 Router 已经是 Router<()>,State 用不上,原因见上一页的「没有蠢问题」。

路由提取器响应
GET /api/auth/login/{provider}Path<String>Redirect::temporary(307)+ 插一个 Set-Cookie
GET /api/auth/callback/{provider}Path<String>、Query<HashMap<String, String>>、HeaderMapRedirect::temporary + 追加两个 Set-Cookie
POST /api/auth/logoutHeaderMapRedirect::to(303)或 403
POST /api/pay/alipay/notifyForm<HashMap<String, String>>支付模块给的纯文本
POST /api/pay/wechat/notifyHeaderMap、Bytes(StatusCode, String)
POST /api/pay/stripe/notifyHeaderMap、BytesStatusCode
GET /sitemap.xml、/feed.xml、/robots.txt无手写的 Response

OAuth 三条在 auth_routes.rs,main.rs 只在 accounts feature 下调用它的 mount。几处写法的原因:

  • 登录跳转用 307,登出用 303。 登录和回调都是浏览器的 GET,temporary 和 to 效果一样。登出是 POST,Redirect::to 是 303 See Other,规定浏览器跟随时改用 GET 去 /;用 307 的话浏览器会把 POST 原样发给首页。三个构造函数的区别见 SSE 与重定向。
  • 登出只挂 post(...)。 GET 落到 MethodRouter 的 405,站点的测试就断言了这一点;注释给的理由是 GET 会被用户内容里的 <img src> 或链接预取触发。跨站的 POST 再按 sec-fetch-site: cross-site 回 403。
  • 回调的 query 用 HashMap 接。 Query<T> 用 serde_urlencoded 把整个 query 串反序列化成 T(try_from_uri),没有 query 时按空串处理。要是 T 是带必填字段 code、state 的结构体,缺一个就是提取器直接回 400,handler 根本不运行;HashMap 什么都收,缺了就 unwrap_or_default() 成空串,交给后面的校验失败,最后统一跳到 /?error=auth_failed,用户看到的是站点自己的错误页,不是一行 400 文本。
  • Set-Cookie 的 insert 和 append。 登录只发一个 cookie,用 insert;回调要同时写 session 和清掉 oauth_pkce,第二个用 append,否则后一个会覆盖前一个。这里没用 (AppendHeaders(...), Redirect) 这种元组响应(IntoResponse 与 IntoResponseParts),而是先 into_response() 再改头:cookie 字符串 parse() 失败时就跳过这个头,不让整个响应失败。
  • 读 cookie 用 get_all。 headers.get_all(COOKIE) 遍历每一个 Cookie 字段,find_map 找出 oauth_pkce;get 只看第一个。

支付回调在 pay_routes.rs,payments feature 关闭时 mount 原样返回。支付宝的回调是 application/x-www-form-urlencoded,用 Form 接;Content-Type 不对时 Form 在读 body 之前就回 415(body 提取器)。微信支付和 Stripe 要对原始 body 逐字节验签,所以用 Bytes,不能先用 Json 反序列化再序列化回去;Bytes 消费 body,按规则放在最后一个参数,HeaderMap 只读 Parts,放在前面(FromRequestParts 与 FromRequest)。返回值直接用 StatusCode 或 (StatusCode, String),网关只看状态码和那几个字。

SEO 三条在 seo.rs。闭包每次调用先 clone 一份 base,再 async move 进 future:handler 闭包会被调用多次,future 要 'static,只能各拿一份。响应由 xml_response 用 Response::builder() 手写,Content-Type 和 Cache-Control 都是调用方给的;最后的 unwrap_or_else 是因为工作区的 clippy 规则禁止 unwrap,头的值都是常量,这条退路实际走不到。

静态资源:nest_service 和子 Router

static_assets::mount 挂了九个目录:

前缀挂法目录额外的层
/images、/uploads、/audio、/podcasts、/cases、/assets/fontnest_service资产根目录下的同名目录(/assets/font 对应 font)无
/postsnest_service内容仓库的 posts无
/jsnest 一个子 Router,ServeDir 做 fallback_service资产根目录下的 jsmap_response 加 Cache-Control: no-cache
/courses(course feature)同上内容仓库的 coursesfrom_fn(guard_course_file),外面再 map_response 加 private, no-cache

ServeDir 是现成的 Service,nest_service 直接挂(ServeDir 与 ServeFile)。nest_service 收的是一个不透明的服务,在它和路由之间没法插 layer;要给某个目录单独加头或加鉴权,就换成一个子 Router:ServeDir 做它的 fallback_service,layer 套在这个子 Router 上,再 nest 进来。nest 会把子路由器的 fallback 搬进父路由器的 fallback 树,挂在 /js 和 /js/{*..} 下(nest、merge 与 fallback)。tower 卷的 不起服务器的测试 已经用实验对比过这两种挂法,这里不重复。

/js 那层 map_response 的参数是 cache_control 返回的闭包:

fn cache_control(value: &'static str) -> impl Fn(Response) -> std::future::Ready<Response> + Clone {
  move |mut res: Response| {
    res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
    std::future::ready(res)
  }
}

map_response 要的是「收一个响应、返回一个 future」的函数,这里不需要等任何东西,就用 std::future::ready 包一下,省掉一个 async 块。它和 from_fn 的关系见 from_fn 与它的兄弟。

📝NOTE
冷知识:map_response 是 0.6 才有的

0.6 之前,只想改一下响应头也得写 from_fn,函数里 next.run(req).await 再改。issue #1394 提议加 map_request / map_response,理由是:「axums from_fn[_with_state[_arc]] can be confusing to newcomers who haven't read about / fully understood tower's "onion principle" of layers yet. Its generality also makes for a little more boilerplate than necessary when one wants to write a middleware that only concerns itself with either the request or response.」#1414「Add map_response and friends」在 2022-09-25 合并。它首先出现在 axum-v0.6.0-rc.3 这个 tag 里,但 CHANGELOG 记这个版本「Yanked, as it didn't compile in release mode」,所以条目记在 0.6.0-rc.4 和 0.6.0 下:「Add map_response, map_response_with_state for transforming the response with an async function」。本站的 cache_control 就是这种「只关心响应」的中间件。

/courses 叠了两层,顺序是有讲究的:

  let router = router.nest(
    "/courses",
    Router::new()
      .fallback_service(ServeDir::new(app_core::utils::content_dir("courses")))
      .layer(middleware::from_fn(guard_course_file))
      // 按用户鉴权的内容:共享缓存(CDN / 反代)不得存,浏览器每次复验
      .layer(middleware::map_response(cache_control("private, no-cache"))),
  );

后加的在外层,map_response 包着 guard_course_file。鉴权不通过时 guard 直接回 404,不调 next;这个 404 往外走时仍然经过 map_response,也带上 private, no-cache。反过来叠,404 就没有这个头,CDN 可能把「没权限」缓存下来发给已经买了课的人。站点的测试 course_files_are_private 断言的正是 404 也带这个头。

guard 看到的路径是 nest 剥过前缀的:请求 /courses/rust-basics/01-x/01-y/a.mp3,req.uri().path() 是 /rust-basics/01-x/01-y/a.mp3(剥前缀的机制见 nest、merge 与 fallback)。may_serve_course_file 的文档注释写明了「path 是 nest 之后的路径」,它调用的 parse_course_file_path 「按 ServeDir 的方式逐段解码」,遇到空段、.、.. 或解码出的 /、\ 一律拒绝。guard 和 ServeDir 必须对同一个路径得出同一个文件,否则就可能出现 guard 判定的是 A、ServeDir 给出的是 B。

下一节的实验程序里有一个照着 mount 搭的小站,/js 和 /courses 两组输出行就是这一节说的行为:/js/a.js 带 no-cache;guard 打印的路径是 /free/01/a.md、/paid/01/a.md;被拒的 404 也带 private, no-cache。

trailing_slash_mw 和目录重定向

trailing_slash_mw 只管 GET 和 HEAD:旧博客地址跳到新地址,以 / 结尾的地址 Redirect::permanent(308)到去掉斜杠的地址;根路径、/api/*,以及去掉斜杠后以 // 开头的路径(会变成开放重定向)不跳。

它对应的坑是 /foo/ 返回 404:在 axum 里 /foo 和 /foo/ 是两条路由(matchit 与路径语法)。本站的页面却不是 404:页面都没有注册成 axum 路由,/blog 和 /blog/ 一起落进 SSR fallback,由 dioxus 的前端路由渲染成同一页。问题是同一页有两个 URL,爬虫会当成两份内容,函数的注释写的就是这个。它作为最外面几层之一套在每个路由和 fallback 上(本站的中间件栈),所以 ServeDir 也看不到 GET 的带尾斜杠路径:/images/a.png/ 在到达 ServeDir 之前就被 308 到 /images/a.png。

它和 ServeDir 的目录重定向碰在一起时有一个现象。ServeDir 遇到不带尾斜杠的目录会 307 到加了斜杠的地址,Location 由它看到的 URI 拼出;nest_service 已经剥掉了前缀,于是 Location 丢了前缀(ServeDir 与 ServeFile 的动脑筋)。本站的 /posts 下每篇文章是一个目录,请求 /posts/hello 会得到 307 Location: /hello/,浏览器跟过去,又被 trailing_slash_mw 308 到 /hello,最后由 SSR 渲染一个不相干的地址。0.7 给了两个开关:redirect_path_prefix 把前缀补回 Location,redirect_to_trailing_slash(false) 干脆不跳。

下面的程序把 /posts 和 trailing_slash_mw 单独拿出来(SSR 换成一个回 404 的 fallback),像浏览器一样跟着 Location 走,最多 4 跳,分别试两个版本的默认配置和几个开关:

axum = "=0.8.9"
tower-http = { version = "=0.7.1", features = ["fs"] }
tower-http-06 = { package = "tower-http", version = "=0.6.11", features = ["fs"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use std::{convert::Infallible, path::PathBuf};

use axum::{
  Router,
  body::Body,
  extract::Request,
  http::{Method, StatusCode, header},
  middleware::{self, Next},
  response::{IntoResponse, Redirect, Response},
};
use tower::{Service, ServiceExt};

// seo::trailing_slash_mw 的尾斜杠部分
async fn trailing_slash_mw(req: Request, next: Next) -> Response {
  if matches!(*req.method(), Method::GET | Method::HEAD) {
    let path = req.uri().path();
    if path != "/" && path.ends_with('/') && !path.starts_with("/api/") {
      let trimmed = path.trim_end_matches('/');
      if !trimmed.is_empty() && !trimmed.starts_with("//") {
        return Redirect::permanent(trimmed).into_response();
      }
    }
  }
  next.run(req).await
}

fn site<S>(dir: S) -> Router
where
  S: Service<Request, Error = Infallible> + Clone + Send + Sync + 'static,
  S::Response: IntoResponse,
  S::Future: Send + 'static,
{
  Router::new()
    .nest_service("/posts", dir)
    .fallback(|| async { (StatusCode::NOT_FOUND, "ssr fallback") }) // 代替 SSR
    .layer(middleware::from_fn(trailing_slash_mw))
}

// 像浏览器一样跟着 Location 走,最多 4 跳
async fn follow(name: &str, app: Router) {
  let mut uri = "/posts/hello".to_string();
  let mut hops = vec![];
  for _ in 0..4 {
    let req = Request::get(uri.as_str()).body(Body::empty()).unwrap();
    let res = app.clone().oneshot(req).await.unwrap();
    hops.push(format!("{} {uri}", res.status().as_u16()));
    match res.headers().get(header::LOCATION) {
      Some(loc) => uri = loc.to_str().unwrap().to_owned(),
      None => break,
    }
  }
  println!("{name}:\n  {}", hops.join(" -> "));
}

#[tokio::main]
async fn main() {
  let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixture/posts");
  std::fs::create_dir_all(root.join("hello")).unwrap();
  std::fs::write(root.join("hello/index.md"), "# hello").unwrap();

  use tower_http::services::ServeDir as ServeDir07;
  use tower_http_06::services::ServeDir as ServeDir06;
  follow("0.6.11 default", site(ServeDir06::new(&root))).await;
  follow("0.7.1 default", site(ServeDir07::new(&root))).await;
  follow("0.7.1 redirect_path_prefix(\"/posts\")", site(ServeDir07::new(&root).redirect_path_prefix("/posts"))).await;
  follow("0.7.1 redirect_to_trailing_slash(false)", site(ServeDir07::new(&root).redirect_to_trailing_slash(false))).await;
  follow("0.6.11 append_index_html_on_directories(false)", site(ServeDir06::new(&root).append_index_html_on_directories(false))).await;
}

(tower-http-06 是用 Cargo 的 package 重命名把 0.6.11 和 0.7.1 放进同一个程序。)

❗IMPORTANT
动脑筋:补上前缀以后,第 4 跳落在哪?

第三组给 ServeDir 配了 redirect_path_prefix("/posts"),看起来正是修 Location 丢前缀的办法。从 /posts/hello 出发跟着跳,第 4 个请求的地址和状态码是什么?

这一现象只在有人直接请求目录地址时出现(文章正文引用的是 /posts/<slug>/xxx.png 这样的文件),后果是跳到一个不相干的地址,而不是报错。要修的话,0.6.11 里就有的 append_index_html_on_directories(false) 让目录一律 404,两个版本都能用;本站的文章目录里放的是 index.mdx 或 index.md,没有 index.html,0.7 的 redirect_to_trailing_slash(false) 效果相同,也是 404。

tower-http 0.6.11 到 0.7.x:对本站有什么影响

站点的 Cargo.toml 写的是 tower-http = { version = "0.6", features = ["fs", "compression-gzip", "compression-br"] },锁在 0.6.11。本站用到的 tower-http 只有三样:自己的 ServeDir(经 nest_service / fallback_service)、全站一层 CompressionLayer::new(),以及 dioxus-server 用来出 public/ 下文件的 ServeFile::new(..).precompressed_br()。按 tower-http 的 CHANGELOG(0.7.0、0.7.1 两节),和这三样有关的变化:

变化本站怎么用影响
ServeDir::try_call 把 IO 错误原样交出(#718,0.7.1)nest_service 调的是 Service::call,不是 try_call无。call 照旧把不存在、无权限变成 404,其余 500
文件路径带尾斜杠改为 404(#678,0.7.0,breaking)GET/HEAD 的尾斜杠先被 trailing_slash_mw 308 掉;其他方法 ServeDir 本来就回 405无,见实验
强 ETag 和 If-None-Match,304 带校验器(#691,0.7.0)/js 设了 no-cache,浏览器每次复验多一个 ETag;304 带上 ETag 和 Last-Modified
配了预压缩就加 Vary: Accept-Encoding(#692,0.7.0)dioxus 的 ServeFile::precompressed_br()0.6.11 下选中 .br 的响应没有 Vary;升级要等 dioxus(见下)
目录重定向的两个开关(#486 0.7.0,#728 0.7.1)/posts 的目录地址可以修上一节的现象,但别和 trailing_slash_mw 配成循环
多段 Range 统一回 416、Range 错误响应不带 Content-Type 等头(#661、#727,0.7.1)/audio、/podcasts 的音频会收到 Range 请求变化都在多段 Range 和 Range 出错时的响应上
identity 编码时误删扩展名(#686,0.7.0)只在配了预压缩、文件又不存在时触发无:本站的 ServeDir 不配预压缩,dioxus 只给存在的文件建路由
* 通配和 identity;q=0 按 RFC 处理,没有可接受的编码就 406(#693,0.7.0,breaking)全站 CompressionLayer::new()明确拒绝所有编码的客户端从 200 变成 406,见实验
SizeAbove 阈值改成 u64(#704)、压缩后转发 trailers(#685)用默认谓词,响应没有 trailers无

dioxus-server 0.7.9 自己依赖 tower-http = "0.6.8"(即 ^0.6.8)。本站把自己的依赖改成 0.7,Cargo 会同时编译 0.6.11 和 0.7.x 两份:本站的 ServeDir 和 CompressionLayer 换成新的,dioxus 的 ServeFile 仍是 0.6.11,Vary 那一行要等 dioxus 升级才会变。两份可以共存,因为它们只在 Router 里以 tower Service 的身份相遇,CompressionLayer 不关心内层响应体来自哪个版本。

下面的程序照 static_assets::mount(只取 /images、/js、/courses 三处,guard 换成「只放行 /free/」)、trailing_slash_mw 和全站的 CompressionLayer 搭一个小站,两个版本各跑一遍同一组请求;后面两段分别看不经过中间件的 ServeDir 和 dioxus 那样的 ServeFile:

axum = "=0.8.9"
tower-http = { version = "=0.7.1", features = ["fs", "compression-gzip", "compression-br"] }
tower-http-06 = { package = "tower-http", version = "=0.6.11", features = ["fs", "compression-gzip", "compression-br"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use std::path::{Path, PathBuf};

use axum::{
  Router,
  body::{Body, to_bytes},
  extract::Request,
  http::{HeaderValue, Method, StatusCode, header},
  middleware::{self, Next},
  response::{IntoResponse, Redirect, Response},
  routing::get,
};
use tower::ServiceExt;

fn cache_control(value: &'static str) -> impl Fn(Response) -> std::future::Ready<Response> + Clone {
  move |mut res: Response| {
    res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
    std::future::ready(res)
  }
}

// guard_course_file 的替身:只放行 /free/ 开头的路径,并打印它看到的路径
async fn guard_course_file(req: Request, next: Next) -> Response {
  println!("    guard sees {:?}", req.uri().path());
  if req.uri().path().starts_with("/free/") {
    next.run(req).await
  } else {
    StatusCode::NOT_FOUND.into_response()
  }
}

// trailing_slash_mw 的尾斜杠部分
async fn trailing_slash_mw(req: Request, next: Next) -> Response {
  if matches!(*req.method(), Method::GET | Method::HEAD) {
    let path = req.uri().path();
    if path != "/" && path.ends_with('/') && !path.starts_with("/api/") {
      let trimmed = path.trim_end_matches('/');
      if !trimmed.is_empty() && !trimmed.starts_with("//") {
        return Redirect::permanent(trimmed).into_response();
      }
    }
  }
  next.run(req).await
}

macro_rules! site {
  ($th:ident, $root:expr) => {{
    use $th::{compression::CompressionLayer, services::ServeDir};
    let root: &Path = $root;
    Router::new()
      .route("/robots.txt", get(|| async { "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml\n" }))
      .nest_service("/images", ServeDir::new(root.join("images")))
      .nest(
        "/js",
        Router::new()
          .fallback_service(ServeDir::new(root.join("js")))
          .layer(middleware::map_response(cache_control("no-cache"))),
      )
      .nest(
        "/courses",
        Router::new()
          .fallback_service(ServeDir::new(root.join("courses")))
          .layer(middleware::from_fn(guard_course_file))
          .layer(middleware::map_response(cache_control("private, no-cache"))),
      )
      .fallback(|| async { (StatusCode::NOT_FOUND, "ssr fallback") })
      .layer(middleware::from_fn(trailing_slash_mw))
      .layer(CompressionLayer::new())
  }};
}

fn h(res: &Response, name: header::HeaderName) -> String {
  res.headers().get(name).map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into())
}

async fn send(app: &Router, method: Method, uri: &str, extra: &[(header::HeaderName, &str)]) -> Response {
  let mut b = Request::builder().method(method).uri(uri);
  for (k, v) in extra {
    b = b.header(k, *v);
  }
  app.clone().oneshot(b.body(Body::empty()).unwrap()).await.unwrap()
}

async fn show(app: &Router, uri: &str, extra: &[(header::HeaderName, &str)]) -> Response {
  let res = send(app, Method::GET, uri, extra).await;
  println!(
    "  GET {uri:<22} {} loc={} cc={:?} etag={} lm={} enc={} vary={}",
    res.status().as_u16(),
    h(&res, header::LOCATION),
    h(&res, header::CACHE_CONTROL),
    if res.headers().contains_key(header::ETAG) { "yes" } else { "-" },
    if res.headers().contains_key(header::LAST_MODIFIED) { "yes" } else { "-" },
    h(&res, header::CONTENT_ENCODING),
    h(&res, header::VARY),
  );
  res
}

async fn run_site(name: &str, app: Router) {
  println!("{name}");
  show(&app, "/images/a.png/", &[]).await;
  let res = show(&app, "/js/a.js", &[]).await;
  let lm = h(&res, header::LAST_MODIFIED);
  show(&app, "/js/a.js", &[(header::IF_MODIFIED_SINCE, &lm)]).await;
  show(&app, "/courses/free/01/a.md", &[]).await;
  show(&app, "/courses/paid/01/a.md", &[]).await;
  show(&app, "/robots.txt", &[(header::ACCEPT_ENCODING, "gzip")]).await;
  show(&app, "/robots.txt", &[(header::ACCEPT_ENCODING, "identity;q=0")]).await;
}

#[tokio::main]
async fn main() {
  let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixture");
  for d in ["images", "js", "courses/free/01", "courses/paid/01", "public"] {
    std::fs::create_dir_all(root.join(d)).unwrap();
  }
  std::fs::write(root.join("images/a.png"), "png").unwrap();
  std::fs::write(root.join("js/a.js"), "console.log(1)").unwrap();
  std::fs::write(root.join("courses/free/01/a.md"), "free").unwrap();
  std::fs::write(root.join("courses/paid/01/a.md"), "paid").unwrap();
  std::fs::write(root.join("public/main-dxh1a2b.js"), "x".repeat(600)).unwrap();
  std::fs::write(root.join("public/main-dxh1a2b.js.br"), "BR").unwrap();

  run_site("== site, tower-http 0.6.11", site!(tower_http_06, &root)).await;
  run_site("== site, tower-http 0.7.1", site!(tower_http, &root)).await;

  // ServeDir 单独用,不经过 trailing_slash_mw:文件路径带尾斜杠
  println!("== bare ServeDir GET /a.png/");
  let r06 = tower_http_06::services::ServeDir::new(root.join("images"))
    .oneshot(Request::get("/a.png/").body(Body::empty()).unwrap())
    .await
    .unwrap();
  let r07 = tower_http::services::ServeDir::new(root.join("images"))
    .oneshot(Request::get("/a.png/").body(Body::empty()).unwrap())
    .await
    .unwrap();
  println!("  0.6.11 {}  0.7.1 {}", r06.status().as_u16(), r07.status().as_u16());

  // dioxus-server 的 serve_dir_cached:ServeFile::precompressed_br,再套全站的 CompressionLayer
  for (name, app) in [
    (
      "== dioxus ServeFile + Compression, 0.6.11",
      Router::new()
        .nest_service("/main-dxh1a2b.js", tower_http_06::services::ServeFile::new(root.join("public/main-dxh1a2b.js")).precompressed_br())
        .layer(tower_http_06::compression::CompressionLayer::new()),
    ),
    (
      "== dioxus ServeFile + Compression, 0.7.1",
      Router::new()
        .nest_service("/main-dxh1a2b.js", tower_http::services::ServeFile::new(root.join("public/main-dxh1a2b.js")).precompressed_br())
        .layer(tower_http::compression::CompressionLayer::new()),
    ),
  ] {
    println!("{name}");
    for ae in ["br", "gzip"] {
      let res = send(&app, Method::GET, "/main-dxh1a2b.js", &[(header::ACCEPT_ENCODING, ae)]).await;
      let (status, enc, vary) = (res.status().as_u16(), h(&res, header::CONTENT_ENCODING), h(&res, header::VARY));
      let len = to_bytes(res.into_body(), 1 << 20).await.unwrap().len();
      println!("  accept={ae:<5} {status} enc={enc:<4} vary={vary} body={len}");
    }
  }
}
== site, tower-http 0.6.11
  GET /images/a.png/         308 loc=/images/a.png cc="-" etag=- lm=- enc=- vary=-
  GET /js/a.js               200 loc=- cc="no-cache" etag=- lm=yes enc=- vary=-
  GET /js/a.js               304 loc=- cc="no-cache" etag=- lm=- enc=- vary=-
    guard sees "/free/01/a.md"
  GET /courses/free/01/a.md  200 loc=- cc="private, no-cache" etag=- lm=yes enc=- vary=-
    guard sees "/paid/01/a.md"
  GET /courses/paid/01/a.md  404 loc=- cc="private, no-cache" etag=- lm=- enc=- vary=-
  GET /robots.txt            200 loc=- cc="-" etag=- lm=- enc=gzip vary=accept-encoding
  GET /robots.txt            200 loc=- cc="-" etag=- lm=- enc=- vary=accept-encoding
== site, tower-http 0.7.1
  GET /images/a.png/         308 loc=/images/a.png cc="-" etag=- lm=- enc=- vary=-
  GET /js/a.js               200 loc=- cc="no-cache" etag=yes lm=yes enc=- vary=-
  GET /js/a.js               304 loc=- cc="no-cache" etag=yes lm=yes enc=- vary=-
    guard sees "/free/01/a.md"
  GET /courses/free/01/a.md  200 loc=- cc="private, no-cache" etag=yes lm=yes enc=- vary=-
    guard sees "/paid/01/a.md"
  GET /courses/paid/01/a.md  404 loc=- cc="private, no-cache" etag=- lm=- enc=- vary=-
  GET /robots.txt            200 loc=- cc="-" etag=- lm=- enc=gzip vary=accept-encoding
  GET /robots.txt            406 loc=- cc="-" etag=- lm=- enc=- vary=accept-encoding
== bare ServeDir GET /a.png/
  0.6.11 200  0.7.1 404
== dioxus ServeFile + Compression, 0.6.11
  accept=br    200 enc=br   vary=- body=2
  accept=gzip  200 enc=gzip vary=accept-encoding body=41
== dioxus ServeFile + Compression, 0.7.1
  accept=br    200 enc=br   vary=accept-encoding body=2
  accept=gzip  200 enc=gzip vary=accept-encoding body=41

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

  • #678 被挡住了。 单独用 ServeDir 时 /a.png/ 在 0.6.11 是 200、0.7.1 是 404;放进本站的结构里,两个版本都是 308,ServeDir 根本看不到带尾斜杠的路径。
  • #691 多了 ETag。 /js/a.js 在 0.7.1 多了 ETag;用 If-Modified-Since 复验得到的 304,0.6.11 不带任何校验器,0.7.1 带 ETag 和 Last-Modified。/js 的 no-cache 本来就靠这个 304 省流量,两个版本都能 304。
  • /courses 两个版本一样。 guard 看到的是剥过前缀的路径,被拒的 404 带着 private, no-cache。
  • #693 多了 406。 Accept-Encoding: identity;q=0 的 /robots.txt,0.6.11 回未压缩的 200,0.7.1 回 406。浏览器不会这么发;会受影响的只有明确拒绝所有编码的客户端。
  • #692 在 dioxus 那份 ServeFile 上。 客户端接受 br 时,0.6.11 回 .br 文件却不带 Vary;接受 gzip 时,.br 用不上,ServeFile 给原文件,由外面的 CompressionLayer 现场压缩并加上 Vary。同一个 URL,前者缺 Vary,共享缓存就可能把 br 的字节发给不支持 br 的客户端。这只在 public/ 里真的有 .br 文件、前面又有共享缓存时才有后果。

压缩与流式响应体:#732

tower-http 还有一个写作时仍开着的 issue #732「CompressionLayer re-polls non-fused streaming bodies after EOF and panics」:内层 body 结束后,压缩层还会再 poll 它一次,futures::stream::unfold 这种结束后不许再 poll 的流就 panic(压缩 一页复现过)。issue 写的是「Reproduced on 0.7.0 and 0.7.1」。本站的哪些响应是流、会不会碰上?

  • SSR:render_handler 的 body 是 Body::from_stream(ReceiverWithDrop),里面是 futures-channel 的 mpsc::Receiver(本站锁 0.3.32)。它的 poll_next 在通道结束后把 inner 置空,之后每次 poll 都由 next_message 直接回 Ready(None),再 poll 也安全。
  • 静态文件:ServeDir / ServeFile 的 body 是 tower-http 自己的类型,上面的实验和 压缩 一页都读到了结尾,没有 panic。
  • server 函数和本站自己的 handler:响应体都是一次给全的 String,不是流。本站的代码里没有 Body::from_stream,也没有 SSE 或 WebSocket。

再用一个程序对比两个版本,并把 SSR 那种 mpsc::Receiver 也放进去:

axum = "=0.8.9"
tower-http = { version = "=0.7.1", features = ["compression-gzip"] }
tower-http-06 = { package = "tower-http", version = "=0.6.11", features = ["compression-gzip"] }
tower = { version = "=0.5.3", features = ["util"] }
futures = "0.3"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use axum::{
  Router,
  body::{Body, Bytes, to_bytes},
  http::{Request, header},
  routing::get,
};
use futures::{SinkExt, stream};
use tower::ServiceExt;

fn unfold_body() -> Body {
  // 三块数据,然后结束;unfold 结束后再被 poll 会 panic
  Body::from_stream(stream::unfold(0u8, |n| async move {
    (n < 3).then(|| (Ok::<_, std::io::Error>(Bytes::from(vec![b'x'; 100])), n + 1))
  }))
}

fn mpsc_body() -> Body {
  // dioxus-server 的 render_to:futures_channel::mpsc::channel,后台任务往里写 HTML 片段
  let (mut tx, rx) = futures::channel::mpsc::channel::<Result<String, std::io::Error>>(1000);
  tokio::spawn(async move {
    for _ in 0..3 {
      tx.send(Ok("<div>".repeat(20))).await.unwrap();
    }
  });
  Body::from_stream(rx)
}

async fn run(name: &str, app: Router) {
  let task = tokio::spawn(async move {
    let req = Request::get("/").header(header::ACCEPT_ENCODING, "gzip").body(Body::empty()).unwrap();
    let res = app.oneshot(req).await.unwrap();
    let enc = res.headers().get(header::CONTENT_ENCODING).cloned();
    let n = to_bytes(res.into_body(), 1 << 20).await.unwrap().len();
    (enc, n)
  });
  match task.await {
    Ok((enc, n)) => println!("{name:<24} ok   enc={enc:?} gz_len={n}"),
    Err(e) => println!("{name:<24} task failed: panicked={}", e.is_panic()),
  }
}

#[tokio::main]
async fn main() {
  std::panic::set_hook(Box::new(|info| {
    let msg = info.payload().downcast_ref::<&str>().map(|s| s.to_string()).unwrap_or_default();
    println!("  [panic] {msg}");
  }));
  run(
    "unfold, 0.6.11",
    Router::new().route("/", get(|| async { unfold_body() })).layer(tower_http_06::compression::CompressionLayer::new()),
  )
  .await;
  run(
    "unfold, 0.7.1",
    Router::new().route("/", get(|| async { unfold_body() })).layer(tower_http::compression::CompressionLayer::new()),
  )
  .await;
  run(
    "mpsc (SSR), 0.7.1",
    Router::new().route("/", get(|| async { mpsc_body() })).layer(tower_http::compression::CompressionLayer::new()),
  )
  .await;
}
  [panic] Unfold must not be polled after it returned `Poll::Ready(None)`
unfold, 0.6.11           task failed: panicked=true
  [panic] Unfold must not be polled after it returned `Poll::Ready(None)`
unfold, 0.7.1            task failed: panicked=true
mpsc (SSR), 0.7.1        ok   enc=Some("gzip") gz_len=42

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

0.6.11 也 panic:tower-http 0.6.11 的 compression_utils.rs 里,WrapBody::poll_frame 读完数据后同样再 poll 一次内层取 trailers,BodyIntoStream::poll_frame 最后同样是 this.body.poll_frame(cx)。所以这不是升级会引入的问题,本站现在就带着它;只是本站没有会被它触发的响应体,SSR 的 mpsc::Receiver 那一行好好地压完了。以后要是加 SSE 或别的流式响应,用 unfold 造流时记得 .fuse()。

动脑筋答案

目录跳转那个程序的输出,M2 Max、rustc 1.99.0、--release,连跑 3 次相同:

0.6.11 default:
  307 /posts/hello -> 308 /hello/ -> 404 /hello
0.7.1 default:
  307 /posts/hello -> 308 /hello/ -> 404 /hello
0.7.1 redirect_path_prefix("/posts"):
  307 /posts/hello -> 308 /posts/hello/ -> 307 /posts/hello -> 308 /posts/hello/
0.7.1 redirect_to_trailing_slash(false):
  404 /posts/hello
0.6.11 append_index_html_on_directories(false):
  404 /posts/hello
  • 第 4 跳是 /posts/hello/,308,回到了第 2 跳。 ServeDir 把 /posts/hello 307 到 /posts/hello/,trailing_slash_mw 把 /posts/hello/ 308 回 /posts/hello,两者互相抵消,浏览器最后报重定向次数过多。redirect_path_prefix 本身没有错,错在它和「去掉尾斜杠」的规范化方向相反:ServeDir 认为目录应该带斜杠,本站认为所有地址都不该带。
  • 两个版本的默认行为一样:Location 丢了前缀,跳到 /hello/,再被规范化成 /hello,最后由 fallback(本站是 SSR)处理一个不相干的地址。
  • 让 ServeDir 不对目录重定向的两种写法都是直接 404:redirect_to_trailing_slash(false) 会去找 index.html,这里只有 index.md(本站的文章目录里是 index.mdx 或 index.md);append_index_html_on_directories(false) 让目录一律 404。

小结

  • 四个 mount 函数都是「收一个 Router<()>、加几条路由、交回去」,handler 用闭包捕获配置。提取器按需选:OAuth 回调用 Query<HashMap> 让缺参走自己的错误页,支付回调用 Form 和原始 Bytes(验签要逐字节一致)。
  • 登出用 Redirect::to(303)让浏览器改用 GET;回调写两个 cookie 用 append。
  • 静态目录用 nest_service 挂 ServeDir;要加 layer 的 /js、/courses 换成子 Router 加 fallback_service。/courses 的 map_response 在 guard 外面,鉴权失败的 404 也不进共享缓存;guard 拿到的是剥过前缀的路径。
  • trailing_slash_mw 让 ServeDir 看不到 GET 的尾斜杠路径,0.7 的 #678 对本站没有影响;它和 ServeDir 的目录 307 一起会把 /posts/<slug> 带到不相干的地址,配 redirect_path_prefix 还会变成循环,应改用 append_index_html_on_directories(false)。
  • tower-http 0.7 对本站可见的变化是 ETag、identity;q=0 的 406,以及(要等 dioxus 升级的)预压缩 Vary。#732 的压缩 panic 在 0.6.11 上同样存在,本站没有会触发它的流式响应体。

💬 关联讨论

发起讨论
加载中...