Rust in Everything
基于 topcoat-router 0.10.0

tower 互操作与可信代理

主线位置:请求主线的两个边缘。入口处,客户端地址在 rewrite 循环之前按可信代理解析一次;出口处,压缩在所有 layer 之外;中间,tower 生态的服务和中间件通过三个适配器接进 layer 链,或者反过来把整个 topcoat 交给别人。
sequenceDiagram
  participant N as Next(topcoat)
  participant A as TowerLayer::handle
  participant M as tower 中间件
  participant T as TowerNext
  participant C as 剩下的 layer 和路由
  N->>A: handle(cx, body, next)
  A->>A: 从 Cx 取 Parts,拼回 http::Request
  A->>A: extensions 里放一个 Relay(mpsc 发送端)
  A->>M: service.oneshot(request)
  M->>T: inner.call(request)
  T->>A: 经 Relay 发来 (request, oneshot 发送端)
  A->>C: next.run(cx.with(parts), body)
  C-->>A: Result<Response>
  A->>T: 经 oneshot 送回结果
  T-->>M: Response 或 TowerNextError
  M-->>A: 中间件的最终结果
  A-->>N: finish:错误还原或包成 TowerServiceError

三个适配器

tower feature(不在默认里)打开 tower.rs,三个类型各管一个方向:

类型方向用法
TowerRoute<S>tower 服务 → topcoat 路由.route(TowerRoute::any("/legacy/{*rest}", axum_router))
TowerLayer<S>tower 中间件 → topcoat layer.layer(TowerLayer::new(TimeoutLayer::new(..)).at("/api"))
TowerServicetopcoat 路由器 → tower 服务axum::Router::new().fallback_service(TowerService::new(router))

前两个要求服务 Clone + Send + Sync:topcoat 的路由表是多个任务共享的 &self,而 tower 的 call 要 &mut self,所以每个请求克隆一份再 oneshot。不是 Sync 的服务要先套 tower::buffer。三者对 body 的处理是对称的:进 tower 之前,topcoat 的 Body 本身就实现了 http_body::Body<Data = Bytes>,可以直接用;从 tower 出来的响应体用 Body::new 装箱回 topcoat 的 Body。

TowerRoute:把 axum 应用挂进来

TowerRoute::handle 用 Cx 上的 Parts 拼回一个 http::Request,交给服务:

fn handle<'cx>(&'cx self, cx: &'cx Cx, body: Body) -> RouteFuture<'cx> {
    let service = self.service.clone();
    Box::pin(async move {
        // Reassemble the http request the service consumes from a copy of
        // the parts on the context; the originals stay available to outer
        // layers and error rendering.
        let request = Request::from_parts(parts(cx).clone(), body);
        match service.oneshot(request).await {
            Ok(response) => Ok(response.map(Body::new)),
            Err(error) => Err(TowerServiceError(error.into()).into()),
        }
    })
}

服务收到的是完整 URI,包括挂载前缀。axum 应用如果按「挂在根上」写的路由,要配一个 StripPrefixLayer。实验:

topcoat = { version = "=0.10.0", features = ["full"] }
tokio = { version = "=1.54.1", features = ["full"] }
axum = { version = "=0.8.9", default-features = false, features = ["tokio", "http1"] }
tower = { version = "=0.5.3", features = ["timeout", "util"] }
let legacy = axum::Router::new()
  .route("/users/{id}", axum::routing::get(|uri: axum::http::Uri| async move { format!("axum saw {uri}\n") }));
// ...
let router = module_router!()
  .route(TowerRoute::any("/legacy/{*rest}", legacy))
  .layer(StripPrefixLayer::new("/legacy"))
  .layer(TowerLayer::new(TimeoutLayer::new(Duration::from_millis(100))).at("/api"))
  .trusted_proxies(proxies)
  .build();
### curl http://127.0.0.1:38105/legacy/users/7?page=2
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 25

axum saw /users/7?page=2

StripPrefixLayer 的路径是 /legacy,TowerRoute 注册在 /legacy/{*rest},前缀匹配上了,所以它包住这个路由;它在子上下文里把 Parts 的 URI 换成去掉前缀的版本,查询串保留。TowerRoute 从 Cx 取 Parts 时拿到的就是改过的那份。catch-all 至少要一段,/legacy 本身不会被转发,要的话再注册一个 TowerRoute::any("/legacy", ...)。

TowerLayer:一条通道接起两种中间件模型

这是三个适配器里最有意思的一个。难点在于类型:tower 的 Layer::layer(inner) 要一个具体的、'static 的内层服务,建一次、所有请求共用;而 topcoat 的「内层」是 Next<'a>,借用着这次请求的 cx,每个请求都不一样,没法当成一个 tower 服务交出去。

TowerLayer 的解法是给 tower 中间件一个替身内层服务 TowerNext,在构造时就套好:

pub fn new<L>(layer: L) -> Self
where
    L: tower::Layer<TowerNext, Service = S>,
{
    Self {
        path: None,
        service: layer.layer(TowerNext::new()),
    }
}

TowerNext 自己什么也不会做。真正的「内层」在每个请求里通过一条通道接上,handle:

let mut request = Request::from_parts(parts, body);
let (relay, mut chain_calls) = relay_channel();
request.extensions_mut().insert(relay);

let mut middleware = pin!(service.oneshot(request));

// Drive the middleware until it either responds on its own or
// calls through to the wrapped chain.
let (request, respond_to) = tokio::select! {
    result = &mut middleware => return finish(result),
    called = chain_calls.recv() => match called {
        Some(call) => call,
        // The middleware dropped the request without calling the
        // chain; it produces a response on its own.
        None => return finish(middleware.await),
    },
};
  1. 把请求拼回 http::Request,在 extensions 里塞一个 Relay:容量为 1 的 mpsc::Sender。extensions 会随请求一起被中间件传下去,大多数中间件不会动它。
  2. 开始 poll 中间件。中间件要么自己就给了响应(比如 CORS 预检、限流拒绝),要么调用内层,也就是 TowerNext::call。
  3. TowerNext::call 从请求的 extensions 里取出 Relay,把「请求 + 一个 oneshot 发送端」从通道发回来,然后等 oneshot。
  4. handle 从通道收到这一对,用中间件改过的请求 Parts 建一个子 Cx,跑剩下的 next.run,结果经 oneshot 送回给 TowerNext,中间件拿到响应继续它的后半段。

第 4 步里,跑 layer 链和继续 poll 中间件是同时进行的(又一个 select!),这样像超时这种「和内层赛跑」的中间件仍然能在内层没完成时先返回,内层的 future 随之被丢掉。

这个设计带来几条限制,文档都写明了:

  • 内层每个请求只能调一次。第二次调用收到 TowerNextError(「consumed」),所以 tower::retry 之类会重试的中间件不支持。
  • 中间件如果换掉了请求的 extensions,TowerNext 找不到 Relay,返回「detached」错误。
  • 错误分两种:从内层冒出来的 topcoat 错误(404、handler 的错误)会被原样「隧道」回来,recover 把它还原成原来的 Error,外层 layer 照样能 downcast;中间件自己产生的错误(超时、负载拒绝)包成 TowerServiceError,最后是 500。

实测,TimeoutLayer 100 毫秒,/api/slow 要睡 300 毫秒:

### curl http://127.0.0.1:38105/api/fast
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 5

fast

### curl http://127.0.0.1:38105/api/slow
HTTP/1.1 500 Internal Server Error
content-type: text/plain; charset=utf-8
content-length: 21

internal server error

超时变成了 500,没有 408 或 504,原因也不会出现在日志里(请求、响应与错误讲过未识别的错误怎么处理)。想要别的状态码,要在 TowerLayer 外面再放一个 topcoat layer,看到 TowerServiceError 时自己换成对应的响应。

和 axum 比:axum 的中间件就是 tower Service,类型在 .layer() 时套好(axum 卷的 layer 与 route_layer),不需要这层转接,也支持重试;topcoat 为了让 layer 能借用 cx、按路径前缀选择,放弃了直接嵌套,换来这条每个请求一次的通道和一次 mpsc + 一次 oneshot 的开销。

TowerService:把 topcoat 交给 axum

反方向最简单。TowerService 的 call 和 RouterService 几乎一样,只是实现的是 tower 的 Service,poll_ready 永远就绪,错误类型是 Infallible:

fn call(&mut self, request: Request<B>) -> Self::Future {
    let router = Arc::clone(&self.router);
    Box::pin(async move { Ok(router.handle(request.map(Body::new)).await) })
}

实验:axum 应用自己处理 /api/health,其余的交给 topcoat。

let topcoat_router = module_router!().discover().build();
let app = axum::Router::new()
  .route("/api/health", axum::routing::get(|| async { "axum: ok\n" }))
  .fallback_service(TowerService::new(topcoat_router));
let listener = tokio::net::TcpListener::bind("127.0.0.1:38106").await?;
axum::serve(listener, app).await
### GET /api/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 9

axum: ok

### GET /hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 15

topcoat: hello

### GET /nope
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 9

not found

/nope 的 404 是 topcoat 给的(体是 not found),axum 的 fallback 不再兜底。两点要注意:服务器是 axum 的,连接不经过 RouterService,请求里没有 RemoteAddr,remote_addr(cx) 是 None,要在 axum 那边用 ConnectInfo 取出地址、插一个 RemoteAddr 进 extensions(tower.md 里有现成的代码);topcoat 按自己注册的绝对路径匹配和生成链接,所以只适合挂在「不剥前缀」的位置,比如根上的 fallback。这正是从 axum 渐进迁移的一种路子:新页面写在 topcoat 里,老接口留在 axum。

TrustedProxies:从右往左走

反向代理后面,连接的对端地址是代理的。代理把客户端地址写进 X-Forwarded-For 之类的头,但客户端自己也能写这个头。TrustedProxies 决定信谁。默认谁也不信,client_ip(cx) 就是对端地址。

解析发生在 handle_inner 的开头、rewrite 循环之前,只做一次,结果以 ClientIp 存进每一轮的 Cx。算法在 client_ip 和 walk:

fn walk(
    &self,
    remote: Option<IpAddr>,
    addresses: impl Iterator<Item = Option<IpAddr>>,
) -> Option<IpAddr> {
    let mut farthest = remote;
    for (hop, address) in addresses.enumerate() {
        let hop = hop + 1;
        // A hop trusted by position needs no readable address; any other
        // hop must name one to be checked against the networks.
        if hop >= self.nearest {
            let ip = address?;
            if !self.is_trusted(Some(ip), hop) {
                return Some(ip);
            }
        }
        farthest = address;
    }
    farthest
}

对端连接是第 0 跳;头里的地址从右往左依次是第 1、2、……跳(每个代理把它看到的对端追加在最右边)。从第 0 跳往外走,返回第一个「不被信任」的地址。一跳被信任有两种方式:

  • networks([...]):地址落在给定网段里。
  • nearest(n):位置小于 n,不管地址是什么。适合代理地址会变、或者代理经 Unix 套接字连进来没有 IP 的情况。

同一个请求(对端 127.0.0.1,头是 X-Forwarded-For: 203.0.113.9, 198.51.100.1)在三种配置下:

程序按环境变量 PROXIES 选配置,/ip 路由返回 remote_addr(cx) 和 client_ip(cx)。每段第一行是配置,第二行是响应体:

== TrustedProxies::new()
remote_addr=Some(127.0.0.1) client_ip=Some(127.0.0.1)
== TrustedProxies::new().networks(["127.0.0.1"])
remote_addr=Some(127.0.0.1) client_ip=Some(198.51.100.1)
== TrustedProxies::new().nearest(2)
remote_addr=Some(127.0.0.1) client_ip=Some(203.0.113.9)
  • 默认:第 0 跳不被信任,直接返回对端地址,头被忽略。
  • networks(["127.0.0.1"]):第 0 跳在网段里,看第 1 跳 198.51.100.1,不在网段里,返回它。
  • nearest(2):第 0、1 跳按位置都被信任,看第 2 跳 203.0.113.9,返回它。

nearest(2) 的结果最「靠外」,也最危险:它假定每个请求都确实经过了两层代理。如果实际只有一层,客户端自己在头里写的那个地址就占了第 2 跳的位置,被当成真实地址。文档的原话是「If a client can connect directly to the load balancer, it occupies the second trusted hop and can fake its address through the header」。能写网段就写网段。

头也可以换:ForwardedHeader::Forwarded(RFC 7239,解析 for=,处理了引号里的逗号)或 ForwardedHeader::Single(name)(CF-Connecting-IP 这类只有一个地址的头,出现多次或有多个值就当没有)。只读你的代理会改写的那个头,其他的客户端都能伪造。

压缩:在所有 layer 之外

默认开启的 compression feature 让 handle_inner 在最后一步调 Compression::compress。它没有写成一个 layer,而是每个响应现场套一次 tower-http 的 Compression:

// The same skip conditions as tower-http's default, with the size
// threshold configurable.
let predicate = SizeAbove::new(self.min_size)
    .and(NotForContentType::GRPC)
    .and(NotForContentType::IMAGES)
    .and(NotForContentType::SSE);

// The response is already computed; the middleware wraps a one-shot
// inner service that just yields it.
let mut response = Some(response);
let inner = service_fn(move |_: http::Request<()>| {
    let response = response.take().expect("one-shot service called once");
    ready(Ok::<_, Infallible>(response))
});
let service = tower_http::compression::Compression::new(inner)
    .gzip(self.gzip)
    .br(self.brotli)
    .quality(self.level.into_tower())
    .compress_when(predicate);

响应已经算好了,交给 tower-http 的只是一个「调用一次就把它交出来」的 service_fn,再配一个只带 Accept-Encoding 头的空请求。协商、Vary、流式压缩全部复用 tower-http(axum 卷的 compression讲过它的实现)。默认 gzip 和 br 都开,质量 4,小于 32 字节的不压。

放在所有 layer 之外,router.rs 的注释给了理由:「so layers see uncompressed bodies」。layer 里可以放心地读、改响应体,不用先解压;layer 也改不了压缩决策,要换算法或关掉,只能在 RouterBuilder::compression 上配。

### big, gzip
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-encoding: gzip
transfer-encoding: chunked

### big, br
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-encoding: br
transfer-encoding: chunked

/big 是 4000 字节的文本。Accept-Encoding: br, gzip 时选了 br。前面 /legacy/users/7 的响应只有 25 字节,长度已知且小于 32,既没压缩也没有 vary 头。反向代理已经在压缩的部署,用 Compression::off() 关掉,免得压两遍。

小结

  • tower feature 提供三个适配器:TowerRoute 把 tower 服务当路由,TowerLayer 把 tower 中间件当 layer,TowerService 把 topcoat 当 tower 服务;前两个要求 Clone + Send + Sync,每个请求克隆一份。
  • TowerRoute 收到完整 URI,配 StripPrefixLayer 去掉挂载前缀;实测 axum 子应用看到的是 /users/7?page=2。
  • TowerLayer 给 tower 中间件一个替身内层 TowerNext,靠请求 extensions 里的 mpsc 发送端把调用转回 layer 链,并与中间件并发 poll;内层只能调一次,不支持重试;中间件自己的错误(如超时)变成 500。
  • TowerService 可以作 axum 的 fallback_service,此时没有 RemoteAddr,要自己从 ConnectInfo 插进去。
  • TrustedProxies 默认不信任任何代理;从对端往外、从右往左走转发头,返回第一个不被信任的地址;networks 按网段,nearest(n) 按位置,后者在代理层数不确定时会被客户端伪造。
  • 压缩在所有 layer 之外,每个响应现场套一次 tower-http 的 Compression;默认 gzip + br、质量 4、32 字节以下不压。

💬 关联讨论

发起讨论
加载中...