一个请求穿过三个时刻
主线位置:作用点主线的全貌。本页用一个四层的服务端栈,把三个时刻从头到尾走一遍,再把同一张图换成客户端栈;每一层的细节留给后面的章节。
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::ResponseBody | on_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());
}
}
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 字节。层的顺序决定每一层在每个时刻看到的是什么。
问: 这个顺序我用 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的镜像。