Rust in Everything
基于 tower 0.5.3

不起服务器的测试

主线位置:两条主线的使用端。ServeDir 和 Router 的 poll_ready 都直接返回 Ready;测试用 oneshot 一次走完「等就绪、call、等响应」三步。

flowchart TB
  t[测试函数] --> m["mount(Router::new())"]
  m --> o["oneshot(req)"]
  o --> pr["poll_ready:Ready"]
  pr --> c["call(req)"]
  c --> rmatch[路由匹配]
  rmatch -->|"/images/..."| sd[ServeDir]
  rmatch -->|"/api/auth/logout"| h[handler]
  sd --> resp[Response]
  h --> resp
  resp --> asrt[断言状态码和头]

本页的源码链接都指向本站仓库的提交 89a61b1。

ServeDir 是一个 Service

本站的静态资源目录由 static_assets::mount 挂上路由:

pub fn mount(router: Router, assets_root: &str) -> Router {
  let router = router
    .nest_service("/images", ServeDir::new(format!("{}/images", assets_root)))
    .nest_service("/posts", ServeDir::new(app_core::utils::content_dir("posts")))
    // 脚本文件名不带哈希:每次使用前向服务端确认(未变时 304),改版后立即生效
    .nest(
      "/js",
      Router::new()
        .fallback_service(ServeDir::new(format!("{}/js", assets_root)))
        .layer(middleware::map_response(cache_control("no-cache"))),
    )
    .nest_service("/uploads", ServeDir::new(format!("{}/uploads", assets_root)))
    // ...
    .nest_service("/assets/font", ServeDir::new(format!("{}/font", assets_root)));
  // ...
  router
}

ServeDir 不是 handler,也不是中间件,它直接实现了 Service<Request<ReqBody>>(tower-http 0.6.11):

  • Error = Infallible。读文件出错不会变成 Err,而是变成一个 500 响应;找不到文件是 404。
  • poll_ready:配了 fallback 服务就问 fallback,没配就直接 Ready(Ok(()))。本站都没配。
  • Response 是 Response<ResponseBody>,不是 axum 的 Body。

nest_service 对服务的要求正好是这几条:Service<Request, Error = Infallible> + Clone,响应实现 IntoResponse。ServeDir 都满足,所以不用任何适配就能挂上去。nest_service 会去掉前缀再转给服务:请求 /images/x.txt,ServeDir 看到的路径是 /x.txt。

/js 换了一种挂法:一个子 Router,ServeDir 做它的 fallback_service,外面套一层 map_response 统一加 Cache-Control: no-cache。nest_service 收的是一个现成的服务,没法往它和路由之间插 layer;子 Router 就可以。

Router 也是 Service:oneshot 发请求

axum 的 Router<()> 实现了 Service<Request<B>>,poll_ready 直接返回 Ready(Ok(())),Error = Infallible。有了这一点,测试不需要监听端口,也不需要 HTTP 客户端:造一个 Request,交给 Router,拿回 Response。

本站 static_assets.rs 的测试:

  use tower::ServiceExt;

  async fn get(assets_root: &str, uri: &str) -> axum::response::Response {
    super::mount(axum::Router::new(), assets_root)
      .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
      .await
      .unwrap()
  }

auth_routes.rs 的测试是同一个写法:mount 到一个空 Router 上,oneshot 发 GET 或 POST /api/auth/logout,断言 405、303、403 和 Set-Cookie。

oneshot 来自 tower::ServiceExt。它拿走服务和请求,返回一个 future,里面先等 poll_ready 返回 Ready,再 call,再等响应,状态机见 ServiceExt 与 Oneshot。拿走 self 不心疼:Router 里面是一个 Arc,clone 只加一次引用计数;本站的测试每次都重新 mount 一个。这也是 app 里唯一直接用到 tower crate 的地方:Cargo.toml 只在 [dev-dependencies] 里写了 tower = { version = "0.5", features = ["util"] }。

下面的程序在一个临时目录上复现这两种挂法,再把 ServeDir 单独拿出来当服务用:

axum = "=0.8.9"
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::{future::poll_fn, path::PathBuf};

use axum::{
  Router,
  body::{Body, to_bytes},
  http::{HeaderValue, Request, header},
  middleware::map_response,
  response::Response,
};
use tower::{Service, ServiceExt};
use tower_http::services::ServeDir;

fn req(uri: &str) -> Request<Body> {
  Request::builder().uri(uri).body(Body::empty()).unwrap()
}

async fn show(label: &str, res: Response<Body>) {
  let status = res.status().as_u16();
  let cc = res.headers().get(header::CACHE_CONTROL).and_then(|v| v.to_str().ok()).map(str::to_owned);
  let body = to_bytes(res.into_body(), 1024).await.unwrap();
  println!("{label:<28} {status} cache-control={cc:?} body={body:?}");
}

#[tokio::main]
async fn main() {
  let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixture");
  std::fs::create_dir_all(root.join("js")).unwrap();
  std::fs::create_dir_all(root.join("images")).unwrap();
  std::fs::write(root.join("js/a.js"), "1").unwrap();
  std::fs::write(root.join("images/x.txt"), "img").unwrap();

  // 1. ServeDir 自己就是 Service:poll_ready 立刻 Ready,oneshot 直接发请求
  let mut dir = ServeDir::new(root.join("js"));
  let ready = poll_fn(|cx| std::task::Poll::Ready(Service::<Request<Body>>::poll_ready(&mut dir, cx))).await;
  println!("ServeDir poll_ready: {ready:?}");
  show("ServeDir /a.js", dir.clone().oneshot(req("/a.js")).await.unwrap().map(Body::new)).await;
  show("ServeDir /nope.js", dir.oneshot(req("/nope.js")).await.unwrap().map(Body::new)).await;

  // 2. 和 static_assets::mount 同样的挂法
  let cache = |mut res: Response| {
    res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
    std::future::ready(res)
  };
  let router = Router::new()
    .nest_service("/images", ServeDir::new(root.join("images")))
    .nest(
      "/js",
      Router::new().fallback_service(ServeDir::new(root.join("js"))).layer(map_response(cache)),
    );
  for uri in ["/images/x.txt", "/js/a.js", "/js/nope.js", "/other"] {
    show(&format!("Router {uri}"), router.clone().oneshot(req(uri)).await.unwrap()).await;
  }
}
ServeDir poll_ready: Ready(Ok(()))
ServeDir /a.js               200 cache-control=None body=b"1"
ServeDir /nope.js            404 cache-control=None body=b""
Router /images/x.txt         200 cache-control=None body=b"img"
Router /js/a.js              200 cache-control=Some("no-cache") body=b"1"
Router /js/nope.js           404 cache-control=Some("no-cache") body=b""
Router /other                404 cache-control=None body=b""

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

  • 单独用的 ServeDir,响应体类型是 tower-http 自己的,要 .map(Body::new) 才能和 Router 的响应放进同一个函数;挂到 Router 上时这一步由 IntoResponse 做了。
  • /images/x.txt 找到的是 fixture/images/x.txt:前缀 /images 被 nest_service 去掉了。
  • /js/nope.js 是 404,但仍然带 no-cache:map_response 包在 ServeDir 外面,404 也从它里面出来。/other 不归 /js 管,没有这个头。
📝NOTE
没有蠢问题

问: 测试里为什么都写 oneshot,不写 router.ready().await?.call(req)?

答: 后者在 Router 上推断不出类型:

error[E0283]: type annotations needed
  --> src/bin/router_ready.rs:8:20
   |
 8 |   let res = router.ready().await.unwrap().call(req).await.unwrap();
   |                    ^^^^^
   |
   = note: multiple `impl`s satisfying `Router: Service<_>` found in the `axum` crate:
           - impl<B> Service<axum::http::Request<B>> for Router
             where <B as HttpBody>::Data == axum::body::Bytes, B: HttpBody, B: Send, B: 'static, <B as HttpBody>::Error: Into<Box<(dyn std::error::Error + Send + Sync + 'static)>>, <B as HttpBody>::Error: ?Sized;
           - impl<L> Service<IncomingStream<'_, L>> for Router
             where L: Listener;

(只摘了第一条错误。)Router 实现了两个 Service:一个收 HTTP 请求,一个收新连接(IncomingStream,给 axum::serve 用的)。ServiceExt<Request> 是按请求类型参数化的,ready() 的时候还没看到请求,编译器不知道要哪一个。oneshot(req) 直接把请求传进去,类型就定了。非要分两步,用 router.as_service::<Body>() 先把请求体类型钉住。

oneshot 测试和线上的一处不同:with_state

axum 的 handler 路由在 Router 里先以「还没拿到 state 的 handler」存着。Router::layer 对这种路由不是立刻套,而是记下「将来要套这一层」。真正套上发生在 handler 变成 Route 的时候:

  • 调过 with_state 的 Router,这一步在 with_state 里一次做完。axum 自己的 into_make_service 就先调 self.with_state(()),注释写的是「such that everything is turned into Route eagerly rather than doing that per request」。
  • 没调过的,每个请求到来时在 MethodRouter::call_with_state 里 handler.clone().into_route(state),每个请求都把 layer 重新套一遍。

测试里 mount(Router::new()).oneshot(...) 走的是后一种。layer 不带状态时两种没有区别;带状态的 layer(Layer::layer 里新建信号量、计数器的那种)就不一样了。下面两个路由各睡 100ms,外面套 ConcurrencyLimitLayer::new(1),同时发两个 /a 一个 /b:

axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["limit", "util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "time"] }
use std::time::{Duration, Instant};

use axum::{Router, body::Body, extract::Request, routing::get};
use tower::{ServiceExt, limit::ConcurrencyLimitLayer};

fn req(path: &str) -> Request {
  Request::builder().uri(path).body(Body::empty()).unwrap()
}

#[tokio::main]
async fn main() {
  for eager in [false, true] {
    println!("ConcurrencyLimitLayer::new(1), with_state: {eager}");
    let mut router = Router::new()
      .route("/a", get(|| async { tokio::time::sleep(Duration::from_millis(100)).await }))
      .route("/b", get(|| async { tokio::time::sleep(Duration::from_millis(100)).await }))
      .layer(ConcurrencyLimitLayer::new(1));
    if eager {
      // into_make_service 内部就是这么做的:提前把 handler 变成 Route
      router = router.with_state(());
    }
    let start = Instant::now();
    let send = |path: &'static str| {
      let router = router.clone();
      tokio::spawn(async move {
        router.oneshot(req(path)).await.unwrap();
        (path, start.elapsed().as_millis() / 10 * 10)
      })
    };
    // 同时发三个请求:两个 /a,一个 /b
    let tasks = [send("/a"), send("/a"), send("/b")];
    for t in tasks {
      let (path, ms) = t.await.unwrap();
      println!("  {path} done at ~{ms}ms");
    }
  }
}
❗IMPORTANT
动脑筋:with_state: true 那一组,第二个 /a 和 /b 各在多少毫秒完成?

上限是 1,三个请求同时发出。提示两条:Router::layer 是逐条路由套的;ConcurrencyLimit 的副本共用一个信号量,见 ConcurrencyLimit。顺带想想 with_state: false 那一组会怎样。

本站自己的测试不受影响:它们测的是路由、状态码和响应头,没有带状态的 layer;本站唯一带状态的中间件(限流)把状态放在 static 里,套几遍都是同一份,见 本站的中间件栈。要在 oneshot 测试里测带状态的 layer,先 .with_state(()),和线上的 into_make_service 保持一致。

动脑筋答案

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同(毫秒数向下取整到 10):

ConcurrencyLimitLayer::new(1), with_state: false
  /a done at ~100ms
  /a done at ~100ms
  /b done at ~100ms
ConcurrencyLimitLayer::new(1), with_state: true
  /a done at ~100ms
  /a done at ~200ms
  /b done at ~100ms
  • with_state: true:第二个 /a 在 200ms,/b 在 100ms。 layer 在 with_state 时给每条路由各套了一次,/a 和 /b 各有一个信号量。之后每个请求复制的是已经套好的服务,副本共用本路由的信号量,所以两个 /a 排队,/b 不受影响。上限是「每条路由 1 个」,不是「整个应用 1 个」。
  • with_state: false:三个都在 100ms。 每个请求都重新调一次 ConcurrencyLimitLayer::layer,每次新建一个信号量,上限形同虚设。
  • 想让所有路由共用一个上限,用 GlobalConcurrencyLimitLayer:它存的是一个 Arc<Semaphore>,每次 layer 都用同一个,不管套几遍。

小结

  • ServeDir 自己就是 Service:Error = Infallible,poll_ready 没有 fallback 时直接 Ready;nest_service 去掉前缀后直接转给它。
  • 要在静态目录外面加 layer,就挂一个子 Router,ServeDir 做 fallback_service,像本站的 /js。
  • Router<()> 也是 Service,测试用 ServiceExt::oneshot 发请求,不监听端口;ready().call() 在 Router 上推断不出类型。
  • 没 with_state 的 Router 每个请求都把 handler 外的 layer 重新套一遍;带状态的 layer 要在测试里先 with_state(()),才和 into_make_service 一致。
  • Router::layer 逐条路由套,ConcurrencyLimitLayer 的上限是每条路由一份。

💬 关联讨论

发起讨论
加载中...