Rust in Everything
基于 tower-http 0.7.1

取舍汇总

主线位置:两条主线的回顾。作用点主线上的取舍是「每层在哪个时刻动手、动完交出什么类型」,结局主线上的是「失败、结束、放弃各由谁判断,判断不了的交给谁」。
flowchart TB
  root["只认 http + http-body"] --> types["body 类型层层传染"]
  root --> resp["自己的失败变成响应"]
  resp --> noerr["Error 一律 S::Error,和 axum 的 Infallible 契合"]
  resp --> blind["客户端分不清本地 504 和对端 504"]
  root --> fwd["poll_ready 一律转发"]
  fwd --> bp["背压交给 tower"]
  outcome["结局拆三件事"] --> cls["classify:只有 trace 在用"]
  outcome --> drop["Drop:知道被丢,不知道为什么"]
  types --> body["包一层 body 类型,不暴露 http-body-util"]
  sec["安全默认值"] --> order["靠层的顺序和部署来补"]

前面各部分按模块读,这一页把散在各页的取舍收拢起来。不重讲机制,每条链回讲过它的那一页;数字都来自那些页和「常见坑」里的实验。

决定换来代价由谁来补
只认 http + http_body同一个层用在 axum、hyper、tonic、客户端body 类型层层传染,层的顺序影响能不能编译axum 的 Router::layer;使用者装箱
失败变成响应Error = S::Error,不需要 HandleErrorLayer客户端分不清本地层和对端产生的状态码要区分时用 tower 的 Timeout
poll_ready 一律转发中间件无状态、可随意克隆不做背压tower 的 ConcurrencyLimit、Buffer
classify 单独成模块判断失败的规则可替换、可复用只有 trace 在用,多一层抽象—
用 Drop 表达放弃不需要连接层配合只知道「被丢了」,不知道为什么连接层自己的日志
不暴露 http-body-util公开 API 不跟着一个 0.1 crate 走多一层包装,0.6 里造不出 UnsyncBoxBody0.7.0 的 UnsyncBoxBody::new
feature 默认全关不用的模块不编译、不拉依赖隐式 feature 让日志悄悄消失使用者显式打开 tracing
安全默认值偏向「能用」不破坏现有部署fail open、没有解压上限、3xx 原样返回层的顺序和部署配置

1. 只认 http 和 http-body

每个中间件都是 impl<S, ReqBody, ResBody> Service<http::Request<ReqBody>>,不依赖 hyper,也不依赖任何框架(tower-http 解决什么问题)。同一个 CompressionLayer 值套在纯 tower 服务和 axum Router 上,结果逐字节相同。

代价是 body 类型:中间件不知道内层用什么 body,只能把它当类型参数收下,再交出自己的包装类型。三个后果在前面都撞到过:

  • 按条件加层、或者调换两层的顺序,响应类型就变了,if / else 编译不过,装箱也不够,要先用 MapResponseBodyLayer 把 body 擦成同一个类型(body 模块、常见坑:incompatible types)。
  • 短路的层要用 Default 造空 body,而 Trace、InFlightRequests、OnEarlyDrop、TimeoutBody 的 body 不实现 Default,于是 Cors、Timeout 不能直接套在它们外面(四件套);客户端栈里 hyper 的 Incoming 也不实现,TimeoutLayer 套不上(拼一个客户端栈)。
  • 报错里的类型名写满一屏。

axum 用户很少碰到这些,因为 Router::layer 把每一层的响应都转回 axum::body::Body。直接用 tower、hyper 或者自己拼客户端的人,要自己付这笔账。

2. 自己的失败变成响应

超时、超限、跨站、不支持的编码、panic,tower-http 都造一个 HTTP 响应(408 或自选状态码、413、403、415、500),而不是返回 Err。中间件的 Error 有 33 处是 S::Error,没有一处引入 BoxError(四件套)。

换来的是和 axum 天然契合:Router::layer 要求错误类型是 Infallible,tower-http 的层原样满足,不需要 HandleErrorLayer;tower 自己的 Timeout 返回 Elapsed,在 axum 里就得先处理错误。

代价落在客户端:本地 TimeoutLayer 到点回的 504,和网关回的 504 在调用方看来一模一样,Trace 也会把它记成对端的失败(拼一个客户端栈)。要区分,就用 tower 的 Timeout,让本地超时走 Err。服务端也有一个小代价:TimeoutLayer::new 隐含 408,0.6.7 起弃用,要求调用方自己选(常见坑:弃用警告)。

body 里的失败是例外:响应头已经发出,没法再换状态码,TimeoutBody、Limited、解压的多余字节都只能让 poll_frame 返回错误(超时一族)。

3. poll_ready 一律转发

tag 里非测试代码的中间件,poll_ready 都原样转发给内层,Cors 只是多做一次配置检查(四件套)。tower-http 不占资源、不做背压,中间件可以随意克隆,没有 tower Buffer 那类「克隆以后共享谁」的问题。

背压留给 tower:并发上限、限流、排队用 ConcurrencyLimit、RateLimit、Buffer,放在 tower-http 的层旁边。有一处要留意:AsyncRequireAuthorization 把已经就绪的内层换进 future(0.6.4 的 #561),于是内层预留的名额在整个鉴权期间被占着,慢鉴权会被内层的并发上限串行化(校验与鉴权)。「一律转发」说的是它自己不占,不是说它不会替内层占着。

4. 「什么算失败」单独成模块

classify 不挂在任何 feature 后面,MakeClassifier → ClassifyResponse(Ready / RequiresEos)→ ClassifyEos 三个 trait 把判断拆成两段,gRPC 那种要等 trailers 才知道成败的协议也能表达(classify)。注释里设想过给重试策略复用,classify 比 Trace 还早一个多月合入。

现实是 crate 里只有 trace 消费它,「能用于重试」只在一个 #[cfg(test)] 模块里验证过。这个抽象现在只有一个使用者,代价是 Trace 多了一个类型参数、new_for_http 和 new_for_grpc 走两条路径(Trace 的内部)。它的价值在别处:客户端可以换成 StatusInRangeAsFailures 把 4xx 也算失败,GrpcErrorsAsFailures 可以只看 grpc-status、连 HTTP 500 都判成功。判断规则可以换,而 Trace 的代码不用动。

5. 用 Drop 表达放弃

InFlightRequests 的计数守卫、OnEarlyDrop 的守卫都是在 Drop 里做事:body 被丢掉才减一,守卫没被标记完成就回调(InFlightRequests、OnEarlyDrop)。好处是不需要 hyper 或 axum 配合:谁丢掉 future 或 body,都会被看到,不管是客户端断开、上层超时还是自己的代码提前返回。

代价是只知道「被丢了」,不知道为什么。客户端断开和外层超时在 OnEarlyDrop 看来是同一件事;InFlightRequests 分不清「还在处理」和「SSE 开着」。真实连接上,客户端关掉 socket 时 hyper 立刻丢掉 body,计数马上回落,下不来的是开着的长连接(常见坑:在途数)。另一个代价写在源码注释里:Drop 里不能 expect,正在 unwind 时再 panic 会直接 abort,所以回调本身不许 panic。

6. 不暴露 http-body-util

公开 API 的外部类型白名单里有 http_body,没有 http_body_util(源码地图)。内部用的 Full、Limited、UnsyncBoxBody 都包一层再出现在签名里(body 模块);ServeDir 用 opaque_future! / opaque_body! 两个宏,连 futures-util 的类型也藏起来(四件套)。

换来的是 http-body-util 这个 0.1 的 crate 升级时,tower-http 的公开 API 不跟着破坏。代价是多一层包装,而且 0.6.x 里 tower_http::body::UnsyncBoxBody 能拿到、造不出来;0.7.0 公开了 UnsyncBoxBody::new,并给 ServeDir 的 body 加了 From,避免二次装箱。

7. feature 默认全关

default = [],23 个模块 feature 加压缩、解压各 5 个;不开 feature 时连 http-body 都不依赖(源码地图)。用多少编译多少。

代价藏在隐式 feature 上:可选依赖 tracing 没用 dep: 引用,留下了一个同名 feature,ServeDir 的错误日志就挂在它上面。0.6.9 起 fs 不再顺带打开它,于是有的项目升级后 500 的日志消失,有的项目因为依赖树里别人开了 trace 又看得到,取决于整个树(常见坑:ServeDir 没日志)。快捷方法也不是全的:ServiceBuilderExt 没有 timeout、cors、csrf 等方法,ServiceBuilder::timeout 是 tower 的(ServiceBuilderExt 与 ServiceExt)。

8. 安全默认值偏向「能用」

几个默认值都选择了不破坏现有部署,把剩下的交给层的顺序和部署配置:

  • CSRF 在 scheme 不一致时放行。Origin 和请求的 authority 按字节比较、不看 scheme,http:// 的 Origin 对 https 站点会放行,源码注释自称 fail open,指望 HSTS 补上。反过来,反向代理改写 Host 时,不带 Sec-Fetch-Site 的同源请求会被拒,只能让代理保留 Host 或登记受信来源(CSRF、常见坑:代理后 403)。
  • 请求解压没有解压后上限。上限要靠 RequestBodyLimitLayer 放在解压里面;放在外面时,10208 字节的 gzip 让 handler 读到 10 MiB(请求解压、常见坑:限额被绕过)。
  • 重定向宁可返回 3xx 也不重放。Standard 不复制非空 body,307/308 原样交回;Limited 用完也只是返回 3xx,不报错(常见坑:307)。凭据过滤要求 AddAuthorization 放在 FollowRedirect 外面:放在里面每一跳都会重新加上,跨源也照发(拼一个客户端栈)。
  • 非法 CORS 配置直接 panic。在装配时就让服务起不来,比请求时出错好查;但 0.7.1 里没有不 panic 的检查方式,谓词形式的凭据还能绕过检查(CORS 的内部)。
  • 敏感头只是一个标记。SetSensitiveHeaders 只改 Debug 输出,放错位置就照样打进日志(常见坑:日志里的 token)。

这些都不是 bug,而是一个只认 http 类型的库能做到的边界:它看不到部署拓扑,也不知道调用方的意图,于是默认值选择「不拦」,把拦的位置交给使用者。代价是正确性取决于层的顺序,而顺序错了编译器不会说话。

小结

  • 只认 http + http_body 换来两侧通用,代价是 body 类型传染,axum 替用户补上了大半。
  • 失败变成响应、poll_ready 转发,让 tower-http 和 axum 天然契合,背压和「本地还是对端」的区分留给 tower。
  • 结局拆成 classify、body 包装、Drop 守卫三件事,第一件目前只有 trace 在用,第三件只知道被丢、不知道为什么。
  • 安全相关的默认值偏向不破坏部署,正确性落在层的顺序上;「常见坑」里一半的条目都是这一条的后果。

💬 关联讨论

发起讨论
加载中...