升到 0.7.1 会怎样
主线位置:作用点主线上的版本差异。0.7.0 / 0.7.1 对本站有影响的改动,全部落在
ServeDir(叶子服务)和CompressionLayer的第二个时刻(响应 future 完成时改状态码)。
链接指向本站提交 ceffc19。结论先写在这里:先不升。理由是依赖树,不是行为;行为上的差异逐条列在下面,升级时照着检查。
flowchart TB app["app:tower-http = 0.6"] --> th6["tower-http 0.6.11"] ds["dioxus-server 0.7.9:^0.6.8(fs)"] --> th6 df["dioxus-fullstack 0.7.9:^0.6.8(fs、limit)"] --> th6 rq["reqwest 0.12.28:^0.6.8(follow-redirect)"] --> th6 app -.->|"只把自己升到 0.7"| th7["tower-http 0.7.1"]
依赖树:只升自己会有两份
Cargo.lock 里依赖 tower-http 的除了 app 自己,还有三个 crate,它们在各自 Cargo.toml 里的要求(从 ceffc19 的 Cargo.lock 定版本,再看这三个版本发布的清单):
| crate | 版本 | 对 tower-http 的要求 | 用到的 feature |
|---|---|---|---|
| dioxus-server | 0.7.9 | 0.6.8 | fs |
| dioxus-fullstack | 0.7.9 | 0.6.8 | fs、limit |
| reqwest | 0.12.28 | 0.6.8 | follow-redirect(加上 gzip 等 feature 打开的 decompression-*) |
0.6.8 是 ^0.6.8,不包括 0.7。只把 app 的依赖改成 0.7,Cargo 会再拉一份 0.7.1,和它们用的 0.6.11 并存。实验 crate 里 tower-http =0.7.1 和 reqwest 0.12 放在一起就是这样(cargo tree -d):
tower-http v0.6.11
└── reqwest v0.12.28 (*)
tower-http v0.7.1
└── p02-extra v0.1.0
两份并存能编译,但有两个实际问题:编译时间和二进制里多一份 tower-http 及其依赖;更要紧的是两份的类型互不相通:dioxus-server 内部用 0.6 的 ServeDir 和 ServeFileSystemResponseBody,app 里 0.7 的同名类型和它们是两个类型,任何要在两边之间传递 tower-http 类型的地方都编译不过。所以升级应当等 dioxus 和 reqwest 跟进,一起升。
行为差异:同一组层,两个版本各跑一遍
不改本站依赖。实验 crate 用本站同样的组合:nest_service 挂 ServeDir、/js 用 nest + map_response 加 no-cache、一个照抄 slash_redirect_target 的 308 中间件、最外层 CompressionLayer::new(),tower-http 只开 fs、compression-gzip、compression-br。同一份 main.rs,Cargo.toml 里分别写 =0.6.11 和 =0.7.1:
fn app(root: &std::path::Path, slash_mw: bool) -> Router {
let no_cache = |mut res: Response| async move {
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
res
};
let router = Router::new()
.route("/page", get(|| async { "<html>".to_owned() + &"page ".repeat(400) }))
.nest("/js", Router::new().fallback_service(ServeDir::new(root.join("js"))).layer(middleware::map_response(no_cache)))
.nest_service("/audio", ServeDir::new(root.join("audio")))
.nest_service("/cases", ServeDir::new(root.join("cases")));
let router = if slash_mw { router.layer(middleware::from_fn(trailing_slash_mw)) } else { router };
router.layer(CompressionLayer::new())
}
// ... show() 用 oneshot 发请求,打印状态码和几个头;4xx 时打印正文
M2 Max、rustc 1.99.0,各跑 3 次,除 ETag 的值(来自临时文件的修改时间)外相同;<date> 是 Last-Modified 的值,正文太长的截断了:
== tower-http 0.6.11
GET /js/a.js/ (site) 308 location=/js/a.js
GET /js/a.js/ (no slash mw) 200 last-modified=<date> content-type=text/javascript
GET /page ae=gzip 200 content-encoding=gzip content-type=text/plain; charset=utf-8
GET /page ae=identity;q=0 200 content-type=text/plain; charset=utf-8
GET /page ae=*;q=0 200 content-type=text/plain; charset=utf-8
GET /js/a.js ae=identity;q=0 200 last-modified=<date> content-type=text/javascript
GET /js/a.js 200 last-modified=<date> content-type=text/javascript
GET /js/a.js If-Modified-Since 304
GET /audio range 0-99 206 last-modified=<date> content-type=audio/m4a content-range=bytes 0-99/4096
GET /audio range 0-1,5-6 416 last-modified=<date> content-type=audio/m4a content-range=bytes */4096 body="Cannot serve multipart range requests"
GET /audio range 9999- 416 last-modified=<date> content-type=audio/m4a content-range=bytes */4096
GET /audio range abc 416 last-modified=<date> content-type=audio/m4a content-range=bytes */4096
GET /cases/alloy 307 location=/alloy/
GET /cases/<300 bytes> 500
== tower-http 0.7.1
GET /js/a.js/ (site) 308 location=/js/a.js
GET /js/a.js/ (no slash mw) 404
GET /page ae=gzip 200 content-encoding=gzip content-type=text/plain; charset=utf-8
GET /page ae=identity;q=0 406 content-type=text/plain; charset=utf-8 body="<html>page page ..."
GET /page ae=*;q=0 406 content-type=text/plain; charset=utf-8 body="<html>page page ..."
GET /js/a.js ae=identity;q=0 406 etag="6ae5f9dd.0721de32-640" last-modified=<date> content-type=text/javascript body="console.log(1);\n..."
GET /js/a.js 200 etag="6ae5f9dd.0721de32-640" last-modified=<date> content-type=text/javascript
GET /js/a.js If-Modified-Since 304 etag="6ae5f9dd.0721de32-640" last-modified=<date>
GET /js/a.js If-None-Match 304 etag="6ae5f9dd.0721de32-640" last-modified=<date>
GET /audio range 0-99 206 etag="6ae5f9dd.072380a7-1000" last-modified=<date> content-type=audio/m4a content-range=bytes 0-99/4096
GET /audio range 0-1,5-6 416 etag="6ae5f9dd.072380a7-1000" last-modified=<date> content-range=bytes */4096 body="Cannot serve multipart range requests"
GET /audio range 9999- 416 etag="6ae5f9dd.072380a7-1000" last-modified=<date> content-range=bytes */4096
GET /audio range abc 416 etag="6ae5f9dd.072380a7-1000" last-modified=<date> content-range=bytes */4096
GET /cases/alloy 307 location=/alloy/
GET /cases/<300 bytes> 500
0.6.11 那一组和开发服务器(本站 main 代码,0.6.11)上用 curl 实际请求的结果一致:/js/prism.min.js/ 是 308,identity;q=0 是 200 原样返回,304 不带 Last-Modified、整个响应没有 ETag,/audio 的多段 Range 是 416 且带 content-type: audio/m4a、正文 37 字节,/cases/alloy 跳到 /alloy/。
逐条看
#678:文件路径带尾斜杠回 404。 0.7.1 下 /js/a.js/ 不经过本站中间件时是 404(0.6.11 是 200)。本站的 trailing_slash_mw 在 ServeDir 外面,对 GET、HEAD 先 308 到 /js/a.js,第一行两个版本都是 308,ServeDir 根本看不到带斜杠的文件路径;其他方法 ServeDir 本来就回 405(serve_dir/mod.rs 第 442 行)。对本站无影响,前提是这个中间件一直在 ServeDir 外面。
#693:拒绝一切编码时回 406。 Accept-Encoding: identity;q=0 或 *;q=0 从 200 变成 406,页面和 ServeDir 的文件都一样(CompressionLayer 在最外层)。注意 406 的正文是原来的完整正文:compression/future.rs 第 51 行 只改状态码、补 Vary,内层已经生成的 body 原样交出去。浏览器不会发这种头,只有手写的客户端会;影响很小,但如果有监控脚本这么写,会开始报 406。
#691:强 ETag,304 带校验器。 0.7.1 的每个文件响应多了 ETag,If-None-Match 能得到 304,304 也带上了 ETag 和 Last-Modified。/js 的脚本是 no-cache,浏览器每次都要复验,现在复验多了一种方式,304 响应也让缓存能更新自己存的校验器。有好处,无需改代码。
#727、#661:Range 错误响应。 416 不再带 Content-Type(ETag、Last-Modified 这类校验器还在),多段 Range 统一回 Cannot serve multipart range requests。/audio、/podcasts 会收到播放器的 Range 请求,正常的单段 Range(0-99 那一行)两个版本都是 206。本站没人依赖 416 的 Content-Type,无影响。0.7.1 另有 ignore_multi_range_requests(true) 可以把多段 Range 当成整份请求处理,本站不需要。
#718:只影响 ServeDir::try_call。 本站全部用 Service::call,无影响。
两个版本都一样的。 /cases/alloy 跳到 /alloy/、超长文件名回 500,0.7.1 没有变化(前者 0.7.0 起可以用 redirect_path_prefix("/cases") 修,后者要等 #744 发版)。要提醒的是:加上 redirect_path_prefix 以后,/cases/alloy 会 307 到 /cases/alloy/,而本站的 trailing_slash_mw 会把它 308 回 /cases/alloy,正是 NormalizePath 之后目录无限 307 那种死循环,只是由手写的 308 代替了 NormalizePath。升级时如果要修目录跳转,应当同时用 redirect_to_trailing_slash(false),或者干脆让这些目录不可浏览。
CsrfLayer 值不值得加
0.7.0 的 CsrfLayer 不需要 token,按 Sec-Fetch-Site 和 Origin 判定。本站的情况,只给评估,不下结论:
- 会改状态的请求:dioxus 的 server fn 都是
/api/下的 POST,由页面里的 wasm 发出,浏览器会带Sec-Fetch-Site: same-origin,CsrfLayer第二步就放行;登录状态在SameSite=Lax的 cookie 里,跨站的 POST 本来就不带它。 - 已有的手写检查:
/api/auth/logout拒绝Sec-Fetch-Site: cross-site,放行same-site和不带这个头的旧浏览器(注释:登出 CSRF 危害有限)。CsrfLayer会更严:same-site也拒,没有Sec-Fetch-Site时还要比Origin和Host。 - 必须放行的跨站 POST:支付回调
/api/pay/alipay/notify、/api/pay/wechat/notify、/api/pay/stripe/notify由支付平台的服务器发出。服务器之间的请求通常既不带Sec-Fetch-Site也不带Origin,CsrfLayer第三步放行;但这是对对方行为的假设,未核实,稳妥的做法是用with_insecure_bypass按路径把这三条排除,或者只把层挂在 server fn 的路由上。 - 部署:本站前面有一个 Pingora 网关。它转发时是否保留原始
Host,本页未核实;如果改写了,不带Sec-Fetch-Site的旧浏览器会被拒,见 反向代理后面 CsrfLayer 回 403。
收益在于把 logout 那种手写检查统一成一层、覆盖以后新增的表单;代价是要处理支付回调的豁免和网关的 Host。在 dioxus 跟进 0.7 之前,这个评估不急于落地。
小结
- 先不升:dioxus-server、dioxus-fullstack、reqwest 都要求
^0.6.8,只升自己会有两份 tower-http,两份的类型互不相通。 - #678 被本站的 308 中间件挡在前面,无影响;#693 让
identity;q=0变成 406,正文还是原文;#691 给/js的复验带来ETag;#727 / #661 只改了 416 的头和正文;#718 无关。 - 升级时如果顺手修
nest_service下的目录跳转,要防住和trailing_slash_mw形成的 307 / 308 循环。 CsrfLayer能统一现有的手写检查,但要先处理支付回调的豁免和网关是否保留Host。