拼一个客户端栈
主线位置:作用点主线换到连接的另一端。客户端栈里,
call时改的是发出去的请求(加 UA、加Authorization、补Accept-Encoding),响应 future 完成时决定要不要再发一次(重定向)或者把「没有响应」变成一个响应(超时),body 流动时解压。结局主线上,Trace用StatusInRangeAsFailures把 4xx、5xx 都算失败。
flowchart TB app[调用方] --> tr["Trace:StatusInRangeAsFailures"] tr --> auth["AddAuthorization:放在重定向外面"] auth --> fr["FollowRedirect:Standard 策略"] fr --> dec["Decompression:补 Accept-Encoding,解压响应"] dec --> ua["SetRequestHeader:User-Agent"] ua --> tmo["Timeout:每一跳 100 ms"] tmo --> box["MapResponseBody:Incoming 装箱"] box --> cli["hyper-util legacy Client"]
tower-http 的中间件只认 http::Request / Response 和 http_body::Body(tower-http 解决什么问题),所以同一批层也能套在客户端外面。hyper-util 的 legacy::Client 实现了 tower::Service<Request<B>>,响应是 Response<hyper::body::Incoming>,错误是 legacy::Error(它自己的连接池和连接器见 hyper 卷 legacy Client)。在它外面一层层加中间件,就是一个最小的 reqwest。
先撞上的一个编译错误
最直接的写法是把 TimeoutLayer 套在 Client 外面。用 四件套 里那个只要求 Service 的空函数钉住约束:
tower-http = { version = "=0.7.1", features = ["timeout"] }
tower = { version = "=0.5.3", features = ["util"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
use bytes::Bytes;
use http::{Request, StatusCode};
use http_body_util::Full;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use std::time::Duration;
use tower::{Layer, Service};
use tower_http::timeout::TimeoutLayer;
fn is_service<S: Service<Request<Full<Bytes>>>>(_: &S) {}
fn main() {
let client = Client::builder(TokioExecutor::new()).build_http::<Full<Bytes>>();
let svc = TimeoutLayer::with_status_code(StatusCode::GATEWAY_TIMEOUT, Duration::from_millis(100)).layer(client);
is_service(&svc);
}
rustc 1.99.0 的报错(registry 路径已缩短):
error[E0277]: the trait bound `hyper::body::incoming::Incoming: Default` is not satisfied
--> src/main.rs:14:14
|
14 | is_service(&svc);
| ---------- ^^^^ the trait `Default` is not implemented for `hyper::body::incoming::Incoming`
| |
| required by a bound introduced by this call
|
help: the trait `Service<http::Request<ReqBody>>` is conditionally implemented for `tower_http::timeout::Timeout<S>`
--> tower-http-0.7.1/src/timeout/service.rs:99:1
|
99 | / impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for Timeout<S>
100 | | where
101 | | S: Service<Request<ReqBody>, Response = Response<ResBody>>,
102 | | ResBody: Default,
| |______________-------^
| |
| unsatisfied requirement introduced here: `hyper::body::incoming::Incoming: Default`
Timeout 到点时用 ResBody::default() 造一个响应,hyper 的 Incoming 代表「从连接上读来的 body」,凭空造不出来,不实现 Default。修法是在 Timeout 和 Client 之间加一层 MapResponseBodyLayer::new(BodyExt::boxed_unsync),把 body 装箱成实现了 Default 的 UnsyncBoxBody。服务端没有这个问题:axum 的 Body 实现了 Default。
栈和实验
服务端是一个绑在 127.0.0.1 随机端口上的 axum 应用:/start 302 到同源的 /data;/cross 302 到 http://localhost:<同一端口>/data,同一台机器、同一个端口,但 host 字符串不同,按 eq_origin 算跨源;/data 的正文用 CompressionLayer 压成 gzip;/slow 睡 300 毫秒。服务端用 from_fn 打印收到的每个请求。客户端栈有一个开关:AddAuthorization 放在 FollowRedirect 里面还是外面。
tower-http = { version = "=0.7.1", features = ["trace", "follow-redirect", "decompression-gzip", "set-header", "auth", "timeout", "map-response-body", "compression-gzip"] }
tower = { version = "=0.5.3", features = ["util"] }
axum = "=0.8.9"
hyper = "1"
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "net", "time"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
tracing = "0.1"
use axum::{extract::Request as AxReq, middleware::{from_fn, Next}, response::Redirect, routing::get, Router};
use bytes::Bytes;
use http::{header, HeaderValue, Request, Response, StatusCode};
use http_body_util::{BodyExt, Full};
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use std::time::Duration;
use tower::{ServiceBuilder, ServiceExt};
use tower_http::{
auth::AddAuthorizationLayer,
classify::{StatusInRangeAsFailures, StatusInRangeFailureClass},
compression::CompressionLayer,
decompression::DecompressionLayer,
follow_redirect::FollowRedirectLayer,
map_response_body::MapResponseBodyLayer,
set_header::SetRequestHeaderLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
use tracing::Span;
// 服务端:打印收到的每个请求,正文用 gzip 压缩
async fn log(req: AxReq, next: Next) -> axum::response::Response {
let line = {
let h = |n| req.headers().get(n).map(|v: &HeaderValue| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
format!(" server: {} {:<6} host={:<9} auth={:<13} ae={}", req.method(), req.uri().path(),
h(header::HOST).split(':').next().unwrap().to_owned(), h(header::AUTHORIZATION), h(header::ACCEPT_ENCODING))
};
println!("{line}");
next.run(req).await
}
async fn serve() -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/start", get(|| async { Redirect::to("/data") }))
.route("/cross", get(move || async move { Redirect::to(&format!("http://localhost:{port}/data")) }))
.route("/data", get(|| async { "hello from /data ".repeat(4) }))
.route("/slow", get(|| async { tokio::time::sleep(Duration::from_millis(300)).await; "late" }))
.layer(CompressionLayer::new())
.layer(from_fn(log));
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
port
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let port = serve().await;
let client = Client::builder(TokioExecutor::new()).build_http::<Full<Bytes>>();
// auth_outside = true:AddAuthorization 在 FollowRedirect 外面
let stack = |auth_outside: bool| {
ServiceBuilder::new()
.layer(
TraceLayer::new(StatusInRangeAsFailures::new_for_client_and_server_errors().into_make_classifier())
.on_request(|req: &Request<Full<Bytes>>, _: &Span| println!(" trace: on_request {}", req.uri().path()))
.on_response(|res: &Response<_>, _: Duration, _: &Span| println!(" trace: on_response {}", res.status()))
.on_failure(|c: StatusInRangeFailureClass, _: Duration, _: &Span| println!(" trace: on_failure {c}")),
)
.option_layer(auth_outside.then(|| AddAuthorizationLayer::bearer("secret")))
.layer(FollowRedirectLayer::new())
.layer(DecompressionLayer::new())
.layer(SetRequestHeaderLayer::overriding(header::USER_AGENT, HeaderValue::from_static("mini/0.1")))
.option_layer((!auth_outside).then(|| AddAuthorizationLayer::bearer("secret")))
.layer(TimeoutLayer::with_status_code(StatusCode::GATEWAY_TIMEOUT, Duration::from_millis(100)))
// hyper 的 Incoming 不实现 Default,Timeout 造不出 504 的 body,先装箱
.layer(MapResponseBodyLayer::new(BodyExt::boxed_unsync))
.service(client.clone())
};
for (name, auth_outside, path) in [
("auth inside FollowRedirect", false, "/cross"),
("auth outside FollowRedirect", true, "/cross"),
("same origin", true, "/start"),
("timeout", true, "/slow"),
] {
println!("[{name}] GET {path}");
let req = Request::get(format!("http://127.0.0.1:{port}{path}")).body(Full::default()).unwrap();
let res = stack(auth_outside).oneshot(req).await.unwrap();
let (status, ce) = (res.status(), res.headers().get(header::CONTENT_ENCODING).cloned());
let body = res.into_body().collect().await.unwrap().to_bytes();
println!(" client: {status} content-encoding={ce:?} body={:?}", String::from_utf8_lossy(&body[..body.len().min(17)]));
}
}
输出(M2 Max、rustc 1.99.0,跑 3 次相同;端口每次不同,所以只打印路径):
[auth inside FollowRedirect] GET /cross
trace: on_request /cross
server: GET /cross host=127.0.0.1 auth=Bearer secret ae=gzip
server: GET /data host=localhost auth=Bearer secret ae=gzip
trace: on_response 200 OK
client: 200 OK content-encoding=None body="hello from /data "
[auth outside FollowRedirect] GET /cross
trace: on_request /cross
server: GET /cross host=127.0.0.1 auth=Bearer secret ae=gzip
server: GET /data host=localhost auth=- ae=gzip
trace: on_response 200 OK
client: 200 OK content-encoding=None body="hello from /data "
[same origin] GET /start
trace: on_request /start
server: GET /start host=127.0.0.1 auth=Bearer secret ae=gzip
server: GET /data host=127.0.0.1 auth=Bearer secret ae=gzip
trace: on_response 200 OK
client: 200 OK content-encoding=None body="hello from /data "
[timeout] GET /slow
trace: on_request /slow
server: GET /slow host=127.0.0.1 auth=Bearer secret ae=gzip
trace: on_response 504 Gateway Timeout
trace: on_failure Status code: 504 Gateway Timeout
client: 504 Gateway Timeout content-encoding=None body=""
层的顺序
鉴权:要在 FollowRedirect 外面
直觉上鉴权层越靠近连接越「安全」,好像放在 FollowRedirect 里面才会被 FilterCredentials 处理。实测正好相反,看第一段:AddAuthorization 在里面时,跳到 localhost 的那一跳照样带着 Bearer secret。
原因在 AddAuthorization::call,它每次都 insert:
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
req.headers_mut()
.insert(http::header::AUTHORIZATION, self.value.clone());
self.inner.call(req)
}
FollowRedirect 的过滤发生在它自己把下一跳交给内层之前(on_request,见 FollowRedirect 的策略)。内层的 AddAuthorization 在过滤之后才运行,每一跳都重新加一次,FilterCredentials 删掉的东西又回来了。放在外面时,Authorization 是原始请求的一部分,FollowRedirect 把它存进自己的头里,跨源那一跳删掉,并且 0.7.0 起删掉就一直删掉。
规律是:在 FollowRedirect 里面的层,每一跳都会再执行一次;在外面的层,只对整条链执行一次。 固定的凭据要放在外面,让重定向策略管它;按目标地址决定加什么的东西(cookie 罐子)应当放在里面,每一跳按新地址重新算。reqwest 正是这样排的:用户设置的头在进栈之前就在请求里,CookieService 在 FollowRedirect 里面(reqwest 卷 一个 Client)。
AddAuthorizationLayer 默认不把头标记为敏感,Debug 打印请求时会原样显示 token;要 .as_sensitive(true)。SetSensitiveRequestHeadersLayer 和日志的关系在 搬运请求标识与头。
解压:里外都行
Decompression::call 只在请求没有 Accept-Encoding 时补一个(链 响应解压)。放在 FollowRedirect 外面,这个头成了原始请求的一部分,每一跳都带着;放在里面,每一跳由它重新补。FilterCredentials 默认只删三个凭据头,不碰它,两种位置的结果一样;把 DecompressionLayer 挪到 FollowRedirect 外面重跑,前两段输出逐字相同。只有用了 FilterCredentials::remove_all()(跨源时清空全部请求头)时,外面那种会在跨源后丢掉 Accept-Encoding。另一个区别是中途的 3xx 响应:解压在外面时它们根本不会经过解压层,不过它们的 body 本来就被丢掉了。
Trace 与 Timeout:看一条链还是看一跳
Trace 在最外面,看到的是一个逻辑请求:上面每一段都只有一次 on_request、一次 on_response,中间那一跳 302 它看不见。想记录每一跳,把 Trace 放进 FollowRedirect 里面。
Timeout 在 FollowRedirect 里面,所以 100 毫秒是每一跳的预算;放到外面就是整条链的总预算。第四段的 504 是本地 Timeout 造的:服务端收到了请求,还没来得及回,客户端已经给出 504,Trace 的分类器把它当成对端的失败记了下来。中间件的失败变成响应而不是错误,在客户端这一侧的代价就是:拿到 504 时分不清是网关回的,还是自己这一层到点了。要区分,用 tower 自己的 Timeout,它返回 Err(Elapsed)。
StatusInRangeAsFailures::new_for_client_and_server_errors() 把 400 到 599 都算失败(classify)。on_failure 打印的是 Status code: 504 Gateway Timeout,这是 StatusInRangeFailureClass 的 Display。
问: 既然层的顺序这么要紧,reqwest 为什么不把这些层暴露出来让人自己排?
答: reqwest 把顺序当作实现细节固定下来,换来的是一个可以 Clone、类型名很短的 Client。自己拼栈的类型是 Trace<FollowRedirect<Decompression<SetRequestHeader<...>>>> 这样一长串,条件加层(上面的 option_layer)还会再包一层 Either。想要 reqwest 没有的组合(每一跳记日志、按跳超时、gRPC 风格的分类)时才值得自己拼;只是发几个请求,用 reqwest。
小结
- hyper-util 的
legacy::Client是一个tower::Service,tower-http 的层可以直接套在它外面。 TimeoutLayer要求响应 body 实现Default,hyper 的Incoming不实现;先用MapResponseBodyLayer装箱。- 在
FollowRedirect里面的层每一跳执行一次,外面的只执行一次。AddAuthorization要放在外面,FilterCredentials才能在跨源时删掉它;放在里面会把 token 发给跨源的目标。 Decompression放在重定向里外结果相同;Trace在外面看一条链、在里面看每一跳;Timeout在里面是每跳预算、在外面是总预算。- 本地超时产生的 504 和对端的 504 在客户端看来一样,
Trace也会把它记成失败。