Rust in Everything
基于 tower-http 0.7.1

拼一个客户端栈

主线位置:作用点主线换到连接的另一端。客户端栈里,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。

📝NOTE
没有蠢问题

问: 既然层的顺序这么要紧,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 也会把它记成失败。

💬 关联讨论

发起讨论
加载中...