Rust in Everything
基于 tower-http 0.7.1

升到 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-server0.7.90.6.8fs
dioxus-fullstack0.7.90.6.8fs、limit
reqwest0.12.280.6.8follow-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。

💬 关联讨论

发起讨论
加载中...