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")) |
TowerService | topcoat 路由器 → 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),
},
};
- 把请求拼回
http::Request,在 extensions 里塞一个Relay:容量为 1 的mpsc::Sender。extensions 会随请求一起被中间件传下去,大多数中间件不会动它。 - 开始 poll 中间件。中间件要么自己就给了响应(比如 CORS 预检、限流拒绝),要么调用内层,也就是
TowerNext::call。 TowerNext::call从请求的 extensions 里取出Relay,把「请求 + 一个 oneshot 发送端」从通道发回来,然后等 oneshot。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() 关掉,免得压两遍。
小结
towerfeature 提供三个适配器: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 字节以下不压。