Rust in Everything
基于 tower 0.5.3

401 响应没有 x-frame-options:层的顺序反了

主线位置:两条主线开始之前的装配阶段。顺序一反,请求主线上谁先拿到请求、谁最后改响应就跟着反;拦截请求的那层在外面,里面的层根本见不到这个请求。
flowchart TB
  src["同样写 A 再写 B"] --> q{用哪个 API}
  q -->|ServiceBuilder| s1["A 在外:A 先看到请求"]
  s1 --> s2[B 再看到]
  q -->|"Router::layer"| r1["B 在外:B 先看到请求"]
  r1 --> r2[A 再看到]

tower 的 ServiceBuilder 和 axum 的 Router::layer 都是「一个接一个地加 layer」,但规则相反:ServiceBuilder 先写的在最外层,Router::layer 后写的在最外层。 从一种写法搬到另一种,不改顺序,中间件的顺序就整个倒过来了。

复现

一个给所有响应加 x-frame-options 的层,一个没有 token 头就返回 401 的鉴权层。习惯了 ServiceBuilder 的人想让「加头」包住一切,会先写它:

tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["full"] }
axum = "=0.8.9"
use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::{from_fn, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use tower::util::MapResponseLayer;
use tower::{ServiceBuilder, ServiceExt};

// Rejects every request without a `token` header.
async fn require_token(req: Request, next: Next) -> Response {
  if req.headers().contains_key("token") {
    next.run(req).await
  } else {
    StatusCode::UNAUTHORIZED.into_response()
  }
}

fn add_header(mut res: Response) -> Response {
  res.headers_mut().insert("x-frame-options", HeaderValue::from_static("DENY"));
  res
}

async fn show(name: &str, app: Router) {
  let req = Request::get("/").body(Body::empty()).unwrap();
  let res = app.oneshot(req).await.unwrap();
  println!(
    "{name}: {} x-frame-options={:?}",
    res.status(),
    res.headers().get("x-frame-options")
  );
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  // Written "header first, auth second", as one would with ServiceBuilder.
  let app = Router::new()
    .route("/", get(|| async { "ok" }))
    .layer(MapResponseLayer::new(add_header))
    .layer(from_fn(require_token));
  show("Router::layer, header first", app).await;

  let app = Router::new()
    .route("/", get(|| async { "ok" }))
    .layer(from_fn(require_token))
    .layer(MapResponseLayer::new(add_header));
  show("Router::layer, header last ", app).await;

  let app = Router::new().route("/", get(|| async { "ok" })).layer(
    ServiceBuilder::new()
      .layer(MapResponseLayer::new(add_header))
      .layer(from_fn(require_token)),
  );
  show("ServiceBuilder, header first", app).await;
}

M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:

Router::layer, header first: 401 Unauthorized x-frame-options=None
Router::layer, header last : 401 Unauthorized x-frame-options=Some("DENY")
ServiceBuilder, header first: 401 Unauthorized x-frame-options=Some("DENY")

第一行就是这个坑:在 Router::layer 里先写加头,鉴权层到了外面,它直接返回 401,请求没往里走,响应也就没经过加头那层。正常请求的响应照样有头:给请求加上 token 头再跑,三行都是 200 OK x-frame-options=Some("DENY")。所以只看 200 的测试发现不了。

两种 API 各是什么顺序

把两个层换成只打印的 LogLayer,看它们各在什么时候见到请求和响应:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

use axum::body::Body;
use axum::http::Request;
use axum::routing::get;
use axum::Router;
use tower::{Layer, Service, ServiceBuilder, ServiceExt};

#[derive(Clone)]
struct LogLayer(&'static str);

#[derive(Clone)]
struct Log<S> {
  name: &'static str,
  inner: S,
}

impl<S> Layer<S> for LogLayer {
  type Service = Log<S>;
  fn layer(&self, inner: S) -> Log<S> {
    Log { name: self.0, inner }
  }
}

impl<S, R> Service<R> for Log<S>
where
  S: Service<R>,
  S::Future: Send + 'static,
{
  type Response = S::Response;
  type Error = S::Error;
  type Future = Pin<Box<dyn Future<Output = Result<S::Response, S::Error>> + Send>>;

  fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
    self.inner.poll_ready(cx)
  }

  fn call(&mut self, req: R) -> Self::Future {
    println!("  {} sees request", self.name);
    let name = self.name;
    let fut = self.inner.call(req);
    Box::pin(async move {
      let res = fut.await;
      println!("  {} sees response", name);
      res
    })
  }
}

async fn handler() -> &'static str {
  println!("  handler");
  "ok"
}

fn req() -> Request<Body> {
  Request::get("/").body(Body::empty()).unwrap()
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  println!("ServiceBuilder::new().layer(A).layer(B).service(..)");
  let svc = ServiceBuilder::new()
    .layer(LogLayer("A"))
    .layer(LogLayer("B"))
    .service(Router::new().route("/", get(handler)));
  svc.oneshot(req()).await.unwrap();

  println!("Router::new().route(..).layer(A).layer(B)");
  let app = Router::new()
    .route("/", get(handler))
    .layer(LogLayer("A"))
    .layer(LogLayer("B"));
  app.oneshot(req()).await.unwrap();

  println!("Router::new().route(..).layer(ServiceBuilder::new().layer(A).layer(B))");
  let app = Router::new()
    .route("/", get(handler))
    .layer(ServiceBuilder::new().layer(LogLayer("A")).layer(LogLayer("B")));
  app.oneshot(req()).await.unwrap();
}

Log 要派生 Clone、Future 要带 Send,是因为 Router::layer 要求包出来的服务 Clone + Send + Sync、future Send。依赖同上,连跑 3 次输出相同:

ServiceBuilder::new().layer(A).layer(B).service(..)
  A sees request
  B sees request
  handler
  B sees response
  A sees response
Router::new().route(..).layer(A).layer(B)
  B sees request
  A sees request
  handler
  A sees response
  B sees response
Router::new().route(..).layer(ServiceBuilder::new().layer(A).layer(B))
  A sees request
  B sees request
  handler
  B sees response
  A sees response
写法最外层请求经过的顺序
ServiceBuilder::new().layer(A).layer(B)A(先写的)A → B → handler
Router::new().layer(A).layer(B)B(后写的)B → A → handler
Router::new().layer(ServiceBuilder::new().layer(A).layer(B))A(builder 内部按 builder 的规则)A → B → handler

为什么相反

tower 这边。 ServiceBuilder::layer 返回 ServiceBuilder<Stack<T, L>>:新 layer 放进 Stack 的 Inner 位置,之前攒下的整串放在 Outer 位置。Stack::layer 先用 Inner 包、再用 Outer 包,所以最后写的离服务最近。.service(svc) 之前没有服务,builder 只是在攒一串「待会儿怎么包」,细节见 ServiceBuilder 和 Layer 与 Stack。

axum 这边。 Router 里已经有服务了,每次 .layer(x) 立刻把 x 套在现有服务的外面。Router::layer 把 layer 交给 PathRouter::layer,它对每个路由的 endpoint 调用 layer,最后落到 Route::layer:

    pub(crate) fn layer<L, NewError>(self, layer: L) -> Route<NewError>
    where
        // ...
    {
        let layer = (MapErrLayer::new(Into::into), layer);

        Route::new(layer.layer(self))
    }

self 是这个路由到目前为止的样子,已经包着之前所有 .layer 加的层;新 layer 包住它,所以后写的在外面。元组 (MapErrLayer, layer) 的第一个元素在最外层(元组的规则见 Layer 与 Stack),axum 只是在最外面再转一次错误类型。

axum 0.8.9 自己的文档 middleware.md 的 Ordering 一节 说的也是这两条:Router::layer 「generally speaking, this results in middleware being executed from bottom to top」;ServiceBuilder 「works by composing all layers into one such that they run top to bottom」,并且推荐用后者。

📝NOTE
冷知识:ServiceBuilder 差一点也是「后写的在外」

ServiceBuilder 是 #175 加入的(tower 0.1.0)。PR 描述里就写着「the order of services is actually opposite (I think) than what you would think」。评审时有人确认当时的语义:「Just checking, add means the new layer will wrap everything before it, right? So this ends up being RateLimit<InFlightLimit<Hyper>>, yea?」也就是今天 axum 的规则。后来一条列出待定事项的评论写道:「For the last one, I believe we should now reverse the order aka the FIFO instead of the current LIFO.」合并进去的版本是 Chain::new(layer, self.layer)(Chain 后来改名 Stack),即先写的在外,一直保持到 0.5.3。

修法

二选一,上面的实验都跑过:

  • 在 Router::layer 里按「从里往外」写:最先写的离 handler 最近,想包住一切的层(加安全头、记日志、统一错误)写在最后。
  • 把一组中间件放进一个 ServiceBuilder,只调用一次 Router::layer:builder 内部按「从外往里」读,和 tower 的其他代码一致,也是 axum 文档推荐的写法。

不管哪种,凡是「每个响应都要经过」的层,都要检查一遍被拒绝的响应(401、429、503)是不是也经过了它。本站的中间件栈就是这样排的,见 在本站中:中间件栈。

📝NOTE
没有蠢问题

问: Router::layer 是把整个 Router 包一层吗?

答: 不是。PathRouter::layer 遍历已有的路由,给每一个分别套上;Router::layer 对 fallback 也各套一次。所以中间件在路由匹配之后才见到请求,而且只作用于调用 .layer 之前已经加进去的路由。.layer 之后再 .route(...) 的路由没有这层:把上面的程序改成 .route("/", ..).layer(LogLayer("A")).route("/late", ..),请求 / 会打印 A sees request,请求 /late 只打印 handler。

小结

  • ServiceBuilder 先写的在最外层:.layer(x) 把 x 放进 Stack 的 Inner,服务要等 .service() 才传进来。
  • axum Router::layer 后写的在最外层:路由已经存在,Route::layer 用新 layer 包住当前的路由。
  • 症状通常出现在被拒绝的响应上:拦截请求的层跑到了外面,里面加头、记录的层见不到它。实验里 401 少了 x-frame-options。
  • 修法:Router::layer 里把要包住一切的层写在最后,或者把整组放进一个 ServiceBuilder 再交给 Router::layer。

💬 关联讨论

发起讨论
加载中...