Rust in Everything
基于 tower-http 0.7.1

tower-http 解决什么问题

主线位置:作用点主线的起点。本页回答为什么 tower-http 的中间件只认 http 和 http-body 两个 crate,以及这个选择让后面每一章都要面对的那个代价。

flowchart TB
  tower["tower:Service<Request>,不知道 HTTP"] --> th["tower-http:Service<http::Request<B>>"]
  th --> axum["axum Router"]
  th --> hyper["纯 hyper 服务"]
  th --> tonic["tonic gRPC"]
  th --> client["hyper-util 客户端、reqwest 内部"]

问题:tower 不知道 HTTP,框架只认自己

tower 把「收一个请求、异步回一个响应」抽象成 Service<Request>,超时、限流、负载均衡写一次,任何协议都能用。代价是它对请求一无所知:Request 是个泛型参数,tower 的 Timeout 超时了只能返回一个 Elapsed 错误,没法回一个 408;它也读不到请求头,做不了压缩协商、CORS、按状态码记日志。

另一头是各个框架自带的中间件。它们懂 HTTP,但只认自己的类型:

  • axum 的 from_fn 把内层服务装箱成 Next,Next 里是一个 BoxCloneSyncService<Request, Response, Infallible>,Request 和 Response 都是 axum 的别名,body 固定是 axum::body::Body。写出来的中间件只能套在 axum 的服务上。
  • actix-web 的中间件实现 Transform<S, ServiceRequest>,内层是 Service<ServiceRequest, Response = ServiceResponse<B>>,ServiceRequest 是 actix-web 自己的类型,带着连接信息和应用数据。同样只能在 actix-web 的服务端用。

两者都只在服务端。HTTP 客户端要加 User-Agent、跟随重定向、解压响应、按状态码记日志,得另写一套。

做法:只认 http 和 http-body

tower-http 的 crate 文档第一行是:

`async fn(HttpRequest) -> Result<HttpResponse, Error>`

紧接着 第 8 行:

All middleware uses the [http] and [http-body] crates as the HTTP abstractions. That means
they're compatible with any library or framework that also uses those crates, such as
[hyper], [tonic], and [warp].

每个中间件都是 impl<S, ReqBody, ResBody> Service<http::Request<ReqBody>> for Xxx<S>,要求内层是 Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,再按需要对 ReqBody / ResBody 加 http_body::Body 约束。请求头、URI、状态码来自 http,body 的逐帧读取来自 http_body::Body::poll_frame(见 hyper 卷 Service 与 Body)。tower-http 不依赖 hyper,也不依赖任何框架。

下面同一个 CompressionLayer 值,先套在一个纯 tower 的 service_fn 上,再套在 axum 的 Router 上:

tower-http = { version = "=0.7.1", features = ["compression-gzip"] }
tower = { version = "=0.5.3", features = ["util"] }
axum = { version = "=0.8.9", default-features = false }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
bytes = "1"
use bytes::Bytes;
use http::{header, Request, Response};
use http_body_util::{BodyExt, Full};
use std::convert::Infallible;
use tower::{Layer, Service, ServiceExt};
use tower_http::compression::CompressionLayer;

fn short<T>(_: &T) -> String {
  // 去掉路径,只留类型名,方便看
  let mut out = String::new();
  for part in std::any::type_name::<T>().split_inclusive(['<', '>', ',']) {
    out.push_str(part.trim_start().rsplit("::").next().unwrap());
  }
  out
}

async fn hit<S, B, RB>(name: &str, svc: S, req: Request<B>)
where
  S: Service<Request<B>, Response = Response<RB>>,
  S::Error: std::fmt::Debug,
  RB: http_body::Body<Data = Bytes>,
  RB::Error: std::fmt::Debug,
{
  let res = svc.oneshot(req).await.unwrap();
  println!("{name}");
  println!("  body type:        {}", short(res.body()));
  println!("  content-encoding: {:?}", res.headers().get(header::CONTENT_ENCODING));
  let bytes = res.into_body().collect().await.unwrap().to_bytes();
  println!("  {} bytes on the wire, gzip magic: {:02x?}", bytes.len(), &bytes[..2]);
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let text = "tower-http ".repeat(100);
  let layer = CompressionLayer::new();

  // 1. 一个纯 tower 服务:请求、响应都是 http 的类型,body 用 http-body-util 的 Full
  let t = text.clone();
  let plain = tower::service_fn(move |_req: Request<Full<Bytes>>| {
    let t = t.clone();
    async move { Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(t)))) }
  });
  let req = Request::get("/").header(header::ACCEPT_ENCODING, "gzip").body(Full::default()).unwrap();
  hit("tower service_fn", layer.layer(plain), req).await;

  // 2. axum 的 Router:同一个 layer 值
  let app = axum::Router::new().route("/", axum::routing::get(move || async move { text }));
  let req = Request::get("/").header(header::ACCEPT_ENCODING, "gzip").body(axum::body::Body::empty()).unwrap();
  hit("axum Router", layer.layer(app), req).await;
}

输出(M2 Max、rustc 1.99.0,跑 3 次相同):

tower service_fn
  body type:        CompressionBody<Full<Bytes>>
  content-encoding: Some("gzip")
  51 bytes on the wire, gzip magic: [1f, 8b]
axum Router
  body type:        CompressionBody<Body>
  content-encoding: Some("gzip")
  51 bytes on the wire, gzip magic: [1f, 8b]

1100 字节的文本压成 51 字节,两边结果一样。CompressionLayer 不知道它包的是 axum 还是别的什么,它只看到 http::Request 进、http::Response 出。

代价:body 类型一路传染

注意两行 body type:同一个层,响应 body 的类型一个是 CompressionBody<Full<Bytes>>,一个是 CompressionBody<Body>。中间件不知道内层用什么 body,只能把它当类型参数收下,再把自己的包装类型交出去。每多套一层,响应 body 就多包一层:

flowchart TB
  h["handler:Response&lt;Full&gt;"] --> c["Compression:Response&lt;CompressionBody&lt;Full&gt;&gt;"]
  c --> t["Trace:Response&lt;trace::ResponseBody&lt;CompressionBody&lt;Full&gt;, ...&gt;&gt;"]
  t --> m["InFlightRequests:Response&lt;metrics::ResponseBody&lt;...&gt;&gt;"]

请求 body 也一样:RequestBodyLimit 把内层看到的请求 body 换成 Limited<B>,RequestDecompression 换成装箱的 body。这带来三个后果,后面的章节会反复遇到:

  • 条件加层会类型不匹配。if 一个分支套了层、另一个分支没套,两边的响应类型不同,编译不过,要装箱或者用 Either(body 模块)。
  • 层的顺序可能决定能不能编译。有些层要求内层的响应 body 实现 Default(它们短路时要造一个空 body),而 Trace 的 body 包装没有实现 Default。纯 tower 里把 CorsLayer 放在 TraceLayer 外面,编译器会拒绝(四件套)。
  • 类型名很长。报错信息里的 Trace<Compression<...>, SharedClassifier<...>, ...> 常常写满一屏。

axum 让人少碰到这些,是因为 Router::layer 把每一层的响应都转回 axum::body::Body(见 axum 卷 layer 与 route_layer)。

两侧都能用

客户端也是 Service<http::Request<B>>:hyper-util 的 legacy Client 实现了 tower 的 Service,reqwest 内部也是一摞 tower 层。tower-http 的 crate 文档给了一个客户端示例:TraceLayer(用 StatusInRangeAsFailures::new(400..=599),客户端把 4xx 也算失败)、SetRequestHeaderLayer(User-Agent)、DecompressionLayer 套在 Client 外面。

不是每个模块两侧都有意义:

只在服务端只在客户端两侧都能用
Cors、Csrf、Compression、RequestDecompression、RequestBodyLimit、CatchPanic、NormalizePath、ServeDirFollowRedirect、Decompression(解压响应)、AddAuthorizationTrace、SetRequestHeader / SetResponseHeader、Timeout、SetRequestId、PropagateHeader、InFlightRequests、SetSensitiveHeaders

压缩和解压最能说明「方向」:

flowchart TB
  subgraph res[响应 body]
    rc["压缩:服务端 Compression"]
    rd["解压:客户端 Decompression"]
  end
  subgraph req[请求 body]
    qc["压缩:没有"]
    qd["解压:服务端 RequestDecompression"]
  end

四个方向只实现了三个:服务端压缩响应、客户端解压响应、服务端解压请求,没有客户端压缩请求。

📝NOTE
没有蠢问题

问: 为什么偏偏少了请求压缩?

答: 响应压缩有协商:客户端用 Accept-Encoding 说自己能解什么,服务端挑一个。请求方向没有对应的预先协商,客户端发请求之前不知道服务端认不认 gzip,压了可能换来一个 415。tower-http 的 RequestDecompression 遇到不认识的编码就回 415,并在响应里带上 Accept-Encoding 告诉客户端自己支持什么(decompression/request/future.rs 第 84 行),这是事后的协商。真要压缩请求,可以用 MapRequestBody 自己包一层编码器,tower-http 没有现成的。

小结

  • tower 的中间件不懂 HTTP,框架的中间件只认本框架的类型;tower-http 只认 http 和 http-body,所以 axum、hyper、tonic 和 HTTP 客户端都能用。
  • 同一个 CompressionLayer 套在纯 tower 服务和 axum Router 上行为一样,响应 body 类型分别是 CompressionBody<Full<Bytes>> 和 CompressionBody<Body>。
  • 代价是 body 类型层层传染:条件加层要装箱,层的顺序可能影响能不能编译,报错类型很长;axum 的 Router::layer 替你把 body 转回 axum::body::Body。
  • 有的模块只在服务端有意义,有的只在客户端,Trace、设置头、超时两侧都能用。
  • 压缩与解压只实现了三个方向,没有请求压缩。

💬 关联讨论

发起讨论
加载中...