不起服务器的测试
主线位置:两条主线的使用端。
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。
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管,没有这个头。
问: 测试里为什么都写 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 intoRouteeagerly 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");
}
}
}
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的上限是每条路由一份。