Rust in Everything
基于 tower-http 0.7.1

一个请求穿过三个时刻

主线位置:作用点主线的全貌。本页用一个四层的服务端栈,把三个时刻从头到尾走一遍,再把同一张图换成客户端栈;每一层的细节留给后面的章节。
flowchart TB
  subgraph cors["Cors:最外层"]
    subgraph trace["Trace"]
      subgraph comp["Compression"]
        subgraph tmo["Timeout"]
          h["handler"]
        end
      end
    end
  end
  caller[调用方] --> cors

一张时序图

一个普通的 GET,带着 Origin 和 Accept-Encoding: gzip。call 从外往里,响应 future 从里往外完成,之后调用方每拉一次 body,poll_frame 再从外往里走一趟:

sequenceDiagram
  participant C as 调用方
  participant Co as Cors
  participant T as Trace
  participant Cm as Compression
  participant Tm as Timeout
  participant H as handler
  Note over C,H: 时刻一 call 从外往里
  C->>Co: call(req)
  Note right of Co: 读 Origin 算好要加的头
  Co->>T: call(req)
  Note right of T: 造 span 调 on_request
  T->>Cm: call(req)
  Note right of Cm: 从 Accept-Encoding 选编码
  Cm->>Tm: call(req)
  Note right of Tm: 创建 Sleep
  Tm->>H: call(req)
  Note over C,H: 时刻二 响应 future 从里往外完成
  H-->>Tm: Response
  Note right of Tm: Sleep 没到 原样交出
  Tm-->>Cm: Response
  Note right of Cm: 值得压 换成 CompressionBody 改头
  Cm-->>T: Response
  Note right of T: 分类 调 on_response 包 body
  T-->>Co: Response
  Note right of Co: 把 CORS 头并进响应
  Co-->>C: Response
  Note over C,H: 时刻三 调用方拉 body
  C->>T: poll_frame
  T->>Cm: poll_frame
  Cm->>H: poll_frame 读原文
  Cm-->>T: 压缩后的帧
  T-->>C: 帧 调 on_body_chunk 结束时调 on_eos

Cors 和 Timeout 都不碰 body,所以第三个时刻里没有它们:Cors 的响应 body 就是内层的 body,Timeout 也一样。每一层在每个时刻做什么:

层call 时响应 future 完成时body 流动时
Cors读 Origin,算好要加的头;OPTIONS 直接短路把算好的头并进响应,Vary 追加不覆盖不参与
Trace造 span,调 on_request分类,调 on_response,失败再调 on_failure,把 body 包成 trace::ResponseBodyon_body_chunk,结束时 on_eos
Compression从 Accept-Encoding 选好编码判断值不值得压,换成 CompressionBody,改 Content-Encoding 等头边读边压
Timeout创建 Sleep先 poll Sleep,到点了不等内层,造一个空 body 的响应不参与

对应源码:Cors::call、Trace::call、Compression::call、Timeout::call。Compression 的 call 只有一行有实质内容:

    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
        let encoding = Encoding::from_headers(req.headers(), self.accept);

        ResponseFuture {
            inner: self.inner.call(req),
            encoding,
            // ...
        }
    }

编码在 call 时就选好了,因为请求头在这之后就交给内层、拿不回来了;压不压要等响应头出来才知道。一个模块跨几个时刻,往往就是因为它需要的信息分散在几个时刻里。

实测:预检、正常、超时

下面在 axum 里搭出这一栈。axum 的 Router::layer 后写的在外层,所以从下往上读就是从外到里。handler 里放了一个守卫,future 被 drop 时打印一行;Trace 的回调各打印一行。时钟暂停(start_paused = true),超时不用真等。

tower-http = { version = "=0.7.1", features = ["cors", "trace", "compression-gzip", "timeout"] }
tower = { version = "=0.5.3", features = ["util"] }
axum = { version = "=0.8.9", default-features = false }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
tracing = "0.1"
use axum::{body::Body, routing::get, Router};
use bytes::Bytes;
use http::{header, HeaderValue, Method, Request, Response, StatusCode};
use http_body_util::BodyExt;
use std::time::Duration;
use tower::ServiceExt;
use tower_http::{
  classify::ServerErrorsFailureClass, compression::CompressionLayer, cors::CorsLayer,
  timeout::TimeoutLayer, trace::TraceLayer,
};
use tracing::Span;

struct Dropped(&'static str);
impl Drop for Dropped {
  fn drop(&mut self) {
    println!("    handler future for {} dropped", self.0);
  }
}

async fn fast() -> String {
  println!("    handler /");
  let _guard = Dropped("/");
  "tower-http ".repeat(100)
}

async fn slow() -> String {
  println!("    handler /slow");
  let _guard = Dropped("/slow");
  tokio::time::sleep(Duration::from_secs(1)).await;
  "never".into()
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
  // Router::layer:后写的在外层。从外到里:Cors → Trace → Compression → Timeout → handler
  let app = Router::new()
    .route("/", get(fast))
    .route("/slow", get(slow))
    .layer(TimeoutLayer::with_status_code(StatusCode::SERVICE_UNAVAILABLE, Duration::from_millis(100)))
    .layer(CompressionLayer::new())
    .layer(
      TraceLayer::new_for_http()
        .on_request(|_: &Request<Body>, _: &Span| println!("  trace: on_request"))
        .on_response(|r: &Response<_>, _: Duration, _: &Span| println!("  trace: on_response {}", r.status()))
        .on_body_chunk(|c: &Bytes, _: Duration, _: &Span| println!("  trace: on_body_chunk {} bytes", c.len()))
        .on_eos(|_: Option<&http::HeaderMap>, _: Duration, _: &Span| println!("  trace: on_eos"))
        .on_failure(|c: ServerErrorsFailureClass, _: Duration, _: &Span| println!("  trace: on_failure {c}")),
    )
    .layer(CorsLayer::new().allow_origin(HeaderValue::from_static("https://a.example")).allow_methods([Method::GET]));

  let cases = [
    ("OPTIONS / (preflight)", Request::options("/").header(header::ORIGIN, "https://a.example").header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")),
    ("GET /", Request::get("/").header(header::ORIGIN, "https://a.example").header(header::ACCEPT_ENCODING, "gzip")),
    ("GET /slow", Request::get("/slow").header(header::ORIGIN, "https://a.example").header(header::ACCEPT_ENCODING, "gzip")),
  ];
  for (name, req) in cases {
    println!("{name}");
    let res = app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
    let mut names: Vec<_> = res.headers().keys().map(|k| k.as_str()).collect();
    names.sort();
    println!("  client: {} {:?}", res.status(), names);
    let body = res.into_body().collect().await.unwrap().to_bytes();
    println!("  client: body {} bytes", body.len());
  }
}
❗IMPORTANT
动脑筋:GET /slow 时,Trace 会不会调 on_failure?

handler 要睡 1 秒,Timeout 100 毫秒就到点,回 503。Trace 在 Timeout 外面,用的是 new_for_http。平台 M2 Max、rustc 1.99.0。

动脑筋答案

OPTIONS / (preflight)
  client: 200 OK ["access-control-allow-methods", "access-control-allow-origin", "allow", "content-length"]
  client: body 0 bytes
GET /
  trace: on_request
    handler /
    handler future for / dropped
  trace: on_response 200 OK
  client: 200 OK ["access-control-allow-origin", "content-encoding", "content-type", "vary"]
  trace: on_body_chunk 51 bytes
  trace: on_eos
  client: body 51 bytes
GET /slow
  trace: on_request
    handler /slow
    handler future for /slow dropped
  trace: on_response 503 Service Unavailable
  trace: on_failure Status code: 503 Service Unavailable
  client: 503 Service Unavailable ["access-control-allow-origin", "content-length"]
  trace: on_eos
  client: body 0 bytes

跑 3 次相同。会调:Timeout 造出来的 503 对外层的 Trace 来说和 handler 自己回的 503 没有区别,ServerErrorsAsFailures 看到 5xx 就判失败。三段逐一对应:

  • 预检:Trace 一行都没打印,handler 也没跑。Cors::call 看到 OPTIONS 就返回 Kind::PreflightCall,不调内层;短路发生在最外层,里面三层都不知道有过这个请求。allow 头不是 CORS 加的:axum 的 Router::layer 是在路由匹配之后套层的,这个 OPTIONS 先被路由到 / 的 MethodRouter,走它的方法级 fallback(被套上了 Cors),MethodRouter 再给 fallback 的响应补上 Allow(见 axum 卷 layer 与 route_layer)。
  • 正常:三个时刻都看得见。handler 的 future 在 on_response 之前就结束了,此时 body 还没被读过;on_body_chunk 报的 51 字节是压缩后的大小,因为 Trace 在 Compression 外面,看到的是压好的帧。响应头里 content-encoding、vary 是 Compression 在第二个时刻加的,access-control-allow-origin 是 Cors 加的。
  • 超时:handler 的 future 被 drop(守卫打印了),因为 Timeout 的 ResponseFuture 到点就返回 Response::new(B::default()) 并改掉状态码,内层 future 跟着它一起被丢掉。503 照样经过 Compression(空 body,不压)和 Cors(加了 access-control-allow-origin)。这是「响应 future 完成时」把「没有响应」变成一个响应。

把 Trace 挪到 Cors 外面,预检请求也会被记录;挪到 Compression 里面,on_body_chunk 报的就是压缩前的 1100 字节。层的顺序决定每一层在每个时刻看到的是什么。

📝NOTE
没有蠢问题

问: 这个顺序我用 ServiceBuilder 搭,.layer(CorsLayer).layer(TraceLayer)...service_fn(handler),为什么编译不过?

答: Cors 的 Service 实现要求内层的响应 body 实现 Default,它短路时要造一个空 body;Trace 的 body 包装 trace::ResponseBody 没有实现 Default。axum 的 Router::layer 会把每层的响应转回 axum::body::Body,所以上面能编译;纯 tower 里就得调换顺序或者先装箱。报错原文和两种修法见 四件套。

换成客户端栈

客户端的栈形状一样,只是最里面不是 handler,而是发请求的 hyper-util legacy Client(它实现了 tower 的 Service):

sequenceDiagram
  participant A as 调用方
  participant T as Trace
  participant F as FollowRedirect
  participant D as Decompression
  participant S as SetRequestHeader
  participant N as Client
  Note over A,N: 时刻一 call
  A->>T: call(req)
  T->>F: call(req) 记下方法 URI 头 留一份 body
  F->>D: call(req) 没有 Accept-Encoding 就补上
  D->>S: call(req) 设置 User-Agent
  S->>N: 发出请求
  Note over A,N: 时刻二 响应 future 完成
  N-->>D: Response
  Note right of D: 按 Content-Encoding 决定解不解
  D-->>F: Response
  Note right of F: 3xx 且策略说跟 就再 call 内层一次
  F-->>T: 最终的 Response
  T-->>A: 分类 调 on_response
  Note over A,N: 时刻三 读 body
  A->>T: poll_frame
  T->>D: poll_frame
  D->>N: 读压缩的字节
  D-->>A: 解压后的帧

和服务端对照:

  • FollowRedirect 的「再发一次」发生在第二个时刻:响应 future 完成时发现是 3xx,策略同意,就在 ResponseFuture::poll 里造一个新请求,再 call 一次内层,外层的 Trace 只看到最终那个响应。循环本身见 reqwest 卷 重定向。
  • Decompression 和服务端的 Compression 一样跨三个时刻:call 时补 Accept-Encoding,future 完成时看 Content-Encoding,body 流动时解压。
  • SetRequestHeader 放在 FollowRedirect 里面,每一跳都会重新设置一遍;放在外面,只有第一跳经过它。客户端栈的顺序怎么定放在「客户端」一部分里讲,见 拼一个客户端栈。

小结

  • 三个时刻:call 从外往里,响应 future 从里往外完成,poll_frame 在调用方拉 body 时再从外往里走一趟。
  • 不碰 body 的层(Cors、Timeout)不出现在第三个时刻;短路发生在哪一层,里面的层就完全不知道这个请求。
  • 一个模块跨几个时刻,是因为它要的信息分散在几个时刻:Compression 在 call 时读请求头,在 future 完成时看响应头,在 body 里干活。
  • Timeout 在第二个时刻把「没有响应」变成 503,外层的 Trace 照样把它算作失败。
  • 客户端栈形状相同;FollowRedirect 在第二个时刻决定再发一次,Decompression 是服务端 Compression 的镜像。

💬 关联讨论

发起讨论
加载中...