路由与静态资源
主线位置:请求主线的末端。上一页那棵树里,
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>>、HeaderMap | Redirect::temporary + 追加两个 Set-Cookie |
POST /api/auth/logout | HeaderMap | Redirect::to(303)或 403 |
POST /api/pay/alipay/notify | Form<HashMap<String, String>> | 支付模块给的纯文本 |
POST /api/pay/wechat/notify | HeaderMap、Bytes | (StatusCode, String) |
POST /api/pay/stripe/notify | HeaderMap、Bytes | StatusCode |
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/font | nest_service | 资产根目录下的同名目录(/assets/font 对应 font) | 无 |
/posts | nest_service | 内容仓库的 posts | 无 |
/js | nest 一个子 Router,ServeDir 做 fallback_service | 资产根目录下的 js | map_response 加 Cache-Control: no-cache |
/courses(course feature) | 同上 | 内容仓库的 courses | from_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 与它的兄弟。
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 放进同一个程序。)
第三组给 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/hello307 到/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 上同样存在,本站没有会触发它的流式响应体。