用到的两样与手写的四样
主线位置:作用点主线在一个真实站点上的分布。
CompressionLayer三个时刻都占,ServeDir是叶子服务;手写的四个中间件里,三个只在响应 future 完成时动手,一个在call时短路。
本页的链接都指向本站仓库的提交 ceffc19(2026-10-31),行号以它为准。本站 crates/app/Cargo.toml 写的是 tower-http = { version = "0.6", features = ["fs", "compression-gzip", "compression-br"] },Cargo.lock 解析到 0.6.11,所以这一页的实测是 0.6.11 的行为;和本卷 0.7.1 的差别放在下一页 升到 0.7.1 会怎样。整个中间件栈的顺序、为什么限流不用 tower 的 RateLimit,tower 卷 中间件栈 讲过;路由和静态目录的全貌在 axum 卷 路由与静态资源。
flowchart TB sec["security_headers_mw(手写)"] --> comp["CompressionLayer(tower-http)"] comp --> slash["trailing_slash_mw(手写)"] slash --> rl["rate_limit_mw(手写)"] rl --> bl["body_limit_mw(手写)"] bl --> routes["路由"] routes --> sd["ServeDir(tower-http)"] routes --> js["/js:ServeDir + map_response(手写的缓存头)"] routes --> dx["dioxus 页面与 server fn"]
用到的两样
CompressionLayer
main.rs 第 401 行,router.layer(tower_http::compression::CompressionLayer::new()),挂在安全头里面、其余中间件外面。只开了 compression-gzip 和 compression-br 两个 feature,所以协商时 zstd、deflate 根本不在候选里(共享内核 的 SupportedEncodings)。对开发服务器发只读请求:
curl --noproxy '*' -sS -o /dev/null -D - -H 'accept-encoding: <值>' http://localhost:8080/js/prism.min.js
Accept-Encoding | Content-Encoding |
|---|---|
zstd | 没有(原样返回) |
gzip | gzip |
gzip, br | br |
br;q=0.5, gzip | gzip |
gzip, br 同分时选了 br,这是 Encoding 枚举的顺序决定的;带 q 值时按 q 值。首页 HTML 带 gzip 请求时同样是 content-encoding: gzip。只开两个算法是合理的:浏览器都支持 br 和 gzip,多开 zstd 只多一份依赖。
ServeDir
static_assets.rs 第 12 行起,/images、/posts、/uploads、/audio、/podcasts、/cases、/assets/font 用 nest_service 直接挂 ServeDir;/js 和 /courses 用 nest 包一个 Router,fallback_service(ServeDir) 再套 map_response 加缓存头(/courses 还有鉴权)。开发服务器上看到的几处行为:
GET /js/prism.min.js 200, last-modified, 没有 etag
GET /js/prism.min.js(If-Modified-Since) 304, cache-control: no-cache,没有 last-modified
GET /audio/<m4a>(Range: bytes=0-99) 206, content-range: bytes 0-99/37919194
GET /cases/alloy 307, location: /alloy/
GET /images/<300 字节的文件名> 500
后两行是 ServeDir 在本站的两个小问题,都和 常见坑 里的条目对得上:
- 目录跳转丢了前缀。
/cases/alloy是一个目录(里面是案例的case.yaml、README.md,不是给人直接浏览的),ServeDir想跳到带斜杠的地址,可nest_service已经把/cases剥掉了,于是跳到/alloy/;接着本站的trailing_slash_mw把/alloy/308 到/alloy,最后 404。0.6.11 没有redirect_path_prefix,见 NormalizePath 之后目录无限 307。 - 文件名过长回 500,而且没有日志。
Cargo.lock里 tower-http 的依赖列表没有tracing,也就是整个依赖树里没人打开它的tracingfeature,ServeDir那行Failed to read file没有编译进来,见 ServeDir 回 500,日志里什么都没有。任何人都能发这种请求,tag 之后的 #744 会把它改成 404。
手写的四样
main.rs 里另外四个 from_fn / map_response 中间件,tower-http 都有看起来对应的层:
| 本站 | 做什么 | tower-http 里对应的 | 用了会怎样 |
|---|---|---|---|
body_limit_mw | /api/upload 8 MiB,其余 2 MiB;Content-Length 超限当场 413 | RequestBodyLimitLayer | 一个层只有一个上限 |
security_headers_mw | 六个安全头,已有同名头不覆盖;环境变量可整体关掉 | SetMultipleResponseHeadersLayer::if_not_present | 0.6.11 在 axum 里编译不过,0.7.1 可以 |
trailing_slash_mw | /foo/ 308 到 /foo,只对 GET、HEAD,跳过 /api/ | NormalizePathLayer::trim_trailing_slash | 只改 URI,地址栏不变,不是同一件事 |
cache_control + map_response | /js 加 Cache-Control: no-cache | SetResponseHeaderLayer::overriding | 可以直接替换 |
体积上限。上限要按路径区分:dioxus-fullstack 0.7.9 读 server fn 请求体时对错误 unwrap,超限会 panic 成 500 而不是 413(模块注释),所以图片上传那一条路径要放宽到 8 MiB,其余保持 2 MiB。RequestBodyLimitLayer 一个实例只有一个上限,要分路径就得把它挂在具体路由上,而本站的 server fn 路由是 dioxus::server::router(App) 生成的,拿不到单独的那一条去挂层。手写的中间件按路径选上限、按 Content-Length 先拒,没有 Content-Length 的情况留给 axum 的 DefaultBodyLimit。这一条是真实的理由。
安全头。规划时以为没用 tower-http 的原因是「要读环境变量开关」,实测不是:开关可以在启动时读一次,用 tower::util::option_layer 决定挂不挂。真正的障碍在 0.6.11 本身。下面用本站同样的组合(axum 0.8.9,安全头不覆盖已有值,/js 覆盖缓存头)试两个版本:
let js = Router::new()
.route("/a.js", get(|| async { "1" }))
.layer(SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")));
let router = Router::new()
.route("/", get(|| async { "home" }))
.route("/framed", get(|| async { ([(header::X_FRAME_OPTIONS, "SAMEORIGIN")], "framed") }))
.nest("/js", js);
// 环境变量在启动时读一次;None 就不挂这一层
let headers = (!disabled).then(|| {
SetMultipleResponseHeadersLayer::if_not_present(security_headers().into_iter().map(Into::into).collect())
});
router.layer(tower::util::option_layer(headers))
tower-http 0.6.11、rustc 1.99.0:
error[E0277]: the trait bound `Response<Body>: Clone` is not satisfied
--> src/main.rs:27:16
|
27 | router.layer(tower::util::option_layer(headers))
| ----- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `Response<Body>`
...
= note: required for `Either<SetMultipleResponseHeadersLayer<Response<Body>>, Identity>` to implement `Clone`
note: required by a bound in `Router::<S>::layer`
Router::layer 要求层是 Clone,而 0.6.11 的 SetMultipleResponseHeadersLayer<M> 只在 M: Clone 时才 Clone,M 是 Response<Body>,axum 的 Body 不是 Clone。去掉 option_layer 直接挂也是同一个错误;单头的 SetResponseHeaderLayer 在 0.6.11 里没有这个问题。0.7.1 的 #714 修好了响应侧(set_header 一族),同一段代码换成 0.7.1 输出(跑 3 次相同):
disabled=false / x-frame-options=Some("DENY") nosniff=Some("nosniff") cache-control=None
disabled=false /framed x-frame-options=Some("SAMEORIGIN") nosniff=Some("nosniff") cache-control=None
disabled=false /js/a.js x-frame-options=Some("DENY") nosniff=Some("nosniff") cache-control=Some("no-cache")
disabled=true / x-frame-options=None nosniff=None cache-control=None
disabled=true /framed x-frame-options=Some("SAMEORIGIN") nosniff=None cache-control=None
disabled=true /js/a.js x-frame-options=None nosniff=None cache-control=Some("no-cache")
/framed 自己给的 SAMEORIGIN 没有被覆盖,和本站中间件「已存在同名头则不覆盖」一致。所以安全头在 0.7.1 下可以换成 tower-http;在本站现在的 0.6.11 下不行。
尾斜杠。本站要的是 SEO 意义上的规范地址:/blog/ 308 到 /blog,地址栏变、搜索引擎只收一个 URL;中间件同时处理旧博客地址的跳转,并且避开 /api/ 和会变成 //host 的开放重定向。NormalizePath 只在内部改 URI,同一内容仍然有两个地址,做不到这一点(axum 卷 /foo/ 返回 404 有三种修法的对比)。不用 tower-http 是对的。
/js 的缓存头。第 21 行 的 middleware::map_response(cache_control("no-cache")) 和 SetResponseHeaderLayer::overriding(CACHE_CONTROL, ..) 效果相同,上面的实验里 /js/a.js 那一行就是它。换不换只是风格问题:cache_control 函数同时给 /courses 用(private, no-cache),现在的写法已经足够短。
小结
- 本站用
CompressionLayer(只开 gzip、br)和八处ServeDir(编译了课程模块时是九处),跑的是 0.6.11。 ServeDir在nest_service下的目录跳转丢前缀,超长文件名回 500 且没有日志,两处都是常见坑里的条目。- 体积上限要按路径区分、尾斜杠要 308,这两样不用 tower-http 是有理由的;安全头在 0.6.11 下换不了(
SetMultipleResponseHeadersLayer不是Clone),0.7.1 可以;/js的缓存头换不换都行。