Rust in Everything
基于 tower 0.5.3

本站的中间件栈

主线位置:请求主线。这四层的 poll_ready 都直接放行,事情全在 call 和响应回来的路上做;限流超额时直接回 429,不走就绪主线的背压,这正是它和 tower::limit::RateLimit 的分别。

flowchart TB
  req[请求] --> sec[security_headers_mw]
  sec --> comp[CompressionLayer]
  comp --> slash[trailing_slash_mw]
  slash -->|尾斜杠| redir[308 跳转]
  slash --> rl[rate_limit_mw]
  rl -->|桶空了| r429[429]
  rl --> route[路由与 handler]
  redir -.->|往外| comp
  r429 -.->|往外| slash
  route -.->|响应往外| rl

本页的源码链接都指向本站仓库的提交 89a61b1。

四层,后加的在外层

路由组装好以后,main.rs 连着调了四次 Router::layer(第 382、385、390、394 行):

    // S2(风险 R4):应用层 per-IP 限流,仅作用于 /api/*(auth / pay 更严)。
    // ...
    let router = router.layer(axum::middleware::from_fn(crate::server::rate_limit::rate_limit_mw));

    // G7:`/blog/` 这类尾斜杠地址 301 到 `/blog`,避免重复 URL。
    let router = router.layer(axum::middleware::from_fn(crate::server::seo::trailing_slash_mw));

    // G4:按 Accept-Encoding 压缩响应(br / gzip)。HTML、CSS、JS、wasm、
    // sitemap / feed 都受益;图片和流式 SSE 由默认判定跳过,已带
    // Content-Encoding 的响应不重复压缩。
    let router = router.layer(tower_http::compression::CompressionLayer::new());

    // S1(风险 R6):全站安全响应头(CSP / nosniff / Referrer-Policy / X-Frame-Options)。
    // 挂在最外层,覆盖页面、server fn、静态资源与上面全部自定义路由(含 429 响应)。
    let router =
      router.layer(axum::middleware::from_fn(crate::server::security::security_headers_mw));

每次 layer 都把「到目前为止的全部」包进新的一层,所以后加的在外层。请求从外往里走,响应从里往外走:

由外到内层做什么会不会自己回响应
1security_headers_mw先 next.run(req).await,再给响应补 CSP、nosniff、X-Frame-Options 等头;已有同名头的不覆盖不会
2CompressionLayer(tower-http)按 Accept-Encoding 压缩响应体不会
3trailing_slash_mwGET / HEAD 的旧博客地址和尾斜杠地址永久跳转会:Redirect::permanent
4rate_limit_mw只管 /api/*,按客户端 IP 取令牌会:429 加 Retry-After: 1

这和 ServiceBuilder 的顺序正好相反:ServiceBuilder 是先写的在外层(见 ServiceBuilder),axum 的 Router::layer 是一次包一层,后写的在外层。两种写法混用时最容易排错顺序,见 layer 顺序。

尾斜杠那一层的注释写的是「301」,实际状态码是 308:axum 0.8.9 的 Redirect::permanent 用的是 308 Permanent Redirect,下面的实验输出也是 308。

还有一点:Router::layer 不是把整个 Router 包起来,而是把 layer 逐条套在每个路由上,fallback 也套一份。路由匹配发生在这四层之前。一个没有匹配到任何路由的地址(比如 /blog/)走的是 fallback,照样经过这四层,所以尾斜杠跳转能生效。

安全头为什么在最外层

rate_limit_mw 超限时不调用 next,直接构造 429 返回;trailing_slash_mw 遇到要跳转的地址也一样。短路返回的响应只会经过它外面的层。安全头挂在最外层,429 和 308 也带安全头;要是挂在里面,这些响应就没有安全头。

下面这个独立的 axum 程序按同样的顺序叠了四层(三个 from_fn 是简化版:限流只给 1 个令牌,安全头只补 X-Frame-Options),每一层进入时记一笔,然后依次发 /api/x、/api/x、/blog/。第二组把安全头挪到最里层做对照。

axum = "=0.8.9"
tower-http = { version = "=0.6.11", features = ["compression-gzip"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use std::sync::Mutex;

use axum::{
  Router,
  body::Body,
  extract::Request,
  http::{HeaderValue, StatusCode, header},
  middleware::{Next, from_fn},
  response::{IntoResponse, Redirect, Response},
  routing::get,
};
use tower::ServiceExt;
use tower_http::compression::CompressionLayer;

static TRACE: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
static TOKENS: Mutex<u32> = Mutex::new(0);

fn trace(step: &'static str) {
  TRACE.lock().unwrap().push(step);
}

fn take_token() -> bool {
  let mut tokens = TOKENS.lock().unwrap();
  let ok = *tokens > 0;
  if ok {
    *tokens -= 1;
  }
  ok
}

// 简化版 rate_limit_mw:只管 /api/*,整个进程共用 1 个令牌
async fn rate_limit_mw(req: Request, next: Next) -> Response {
  trace("rate_limit");
  if req.uri().path().starts_with("/api/") && !take_token() {
    return (StatusCode::TOO_MANY_REQUESTS, "请求过于频繁,请稍后重试").into_response();
  }
  next.run(req).await
}

// 简化版 trailing_slash_mw:GET /xxx/ 跳到 /xxx
async fn trailing_slash_mw(req: Request, next: Next) -> Response {
  trace("trailing_slash");
  let path = req.uri().path();
  if path.len() > 1 && path.ends_with('/') && !path.starts_with("/api/") {
    return Redirect::permanent(path.trim_end_matches('/')).into_response();
  }
  next.run(req).await
}

// 简化版 security_headers_mw:先跑内层,再补头
async fn security_headers_mw(req: Request, next: Next) -> Response {
  trace("security");
  let mut resp = next.run(req).await;
  resp.headers_mut().entry("x-frame-options").or_insert(HeaderValue::from_static("DENY"));
  resp
}

fn routes() -> Router {
  Router::new()
    .route("/api/x", get(|| async { trace("handler"); "x".repeat(100) }))
    .route("/blog", get(|| async { "blog" }))
}

async fn send(router: &Router, path: &str) {
  TRACE.lock().unwrap().clear();
  let req = Request::builder()
    .uri(path)
    .header(header::ACCEPT_ENCODING, "gzip")
    .body(Body::empty())
    .unwrap();
  let res = router.clone().oneshot(req).await.unwrap();
  let h = |name| res.headers().get(name).and_then(|v| v.to_str().ok()).unwrap_or("-").to_string();
  println!(
    "  {path:<8} {} enc={:<4} xfo={:<4} order={:?}",
    res.status().as_u16(),
    h("content-encoding"),
    h("x-frame-options"),
    TRACE.lock().unwrap()
  );
}

async fn run(name: &str, router: Router) {
  println!("{name}");
  *TOKENS.lock().unwrap() = 1;
  for path in ["/api/x", "/api/x", "/blog/"] {
    send(&router, path).await;
  }
}

#[tokio::main]
async fn main() {
  // 和 main.rs 相同的顺序:先加的在里层
  let site = routes()
    .layer(from_fn(rate_limit_mw))
    .layer(from_fn(trailing_slash_mw))
    .layer(CompressionLayer::new())
    .layer(from_fn(security_headers_mw));
  run("site order (security outermost):", site).await;

  // 反例:安全头最先加,落在最里层
  let inner = routes()
    .layer(from_fn(security_headers_mw))
    .layer(from_fn(rate_limit_mw))
    .layer(from_fn(trailing_slash_mw))
    .layer(CompressionLayer::new());
  run("security innermost:", inner).await;
}

按本站的顺序,order 一列是 security → trailing_slash → rate_limit → handler,和上表由外到内一致;第二个 /api/x 停在 rate_limit,/blog/ 停在 trailing_slash,但三个响应都带着 xfo=DENY,因为 security 在它们外面。完整输出在文末。

❗IMPORTANT
动脑筋:安全头挪到最里层以后,三行输出里哪几行还带 xfo=DENY?

第二组把 security_headers_mw 最先加上,它落在 rate_limit_mw 里面、紧贴着路由。三个请求和第一组相同:一个正常的 /api/x、一个被限流的 /api/x、一个要跳转的 /blog/。哪几行的 xfo 还是 DENY?order 里 security 排在第几?

输出里还有一个顺带的事实:429 的响应体(请求过于频繁,请稍后重试,36 字节)也被 gzip 了,因为压缩层同样在限流外面。308 没有响应体,也就没有压缩。

为什么不用 tower::limit::RateLimit

tower 自带 RateLimit,本站没用它,自己在 rate_limit.rs 里写了一个按 IP 分桶的令牌桶。对照一下就知道 tower 那个为什么不合用:

tower::limit::RateLimit本站的 rate_limit_mw
算法固定窗口:每个窗口 num 次令牌桶:每秒补 rps 个,最多攒 burst 个
计数范围一个实例一个窗口,所有调用方共用每个客户端 IP 一个桶,另分普通桶和敏感桶
超额时poll_ready 返回 Pending,等到窗口结束立刻回 429 和 Retry-After: 1
作用范围包住的整个服务只管 /api/*,页面和静态资源不限
Clone没有中间件是一个 async fn,状态放在 static 里

逐条看:

一、超额时是等,不是拒绝。 RateLimit 用完本窗口的次数后,poll_ready 等 Sleep 到期,这是就绪主线上的背压,细节见 RateLimit。放在 axum 里,Route::poll_ready 永远返回 Ready,每个请求再用 oneshot 去等内层就绪,结果是超额的请求挂在内存里排队,而不是被拒掉。对公网接口,本站要的是直接告诉客户端「过一秒再来」。

二、粒度不对。 一个 RateLimit 实例只有一个窗口,所有客户端共用。本站按 client_key 分桶:先取 x-forwarded-for 的第一个地址,再看 x-real-ip,都没有就落进共享桶 "global";classify_path 只认 /api/ 开头的路径,登录、支付、下单走更严的敏感桶。

三、编译不过。 Router::layer 要求 layer 产出的服务能 Clone,RateLimit 没有:

use std::time::Duration;

use axum::{Router, routing::get};
use tower::limit::RateLimitLayer;

fn main() {
  let _router: Router = Router::new()
    .route("/api/x", get(|| async { "x" }))
    .layer(RateLimitLayer::new(20, Duration::from_secs(1)));
}
error[E0277]: the trait bound `RateLimit<Route>: Clone` is not satisfied
   --> src/bin/ratelimit_layer.rs:9:12
    |
  9 |     .layer(RateLimitLayer::new(20, Duration::from_secs(1)));
    |      ----- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `RateLimit<Route>`
    |      |
    |      required by a bound introduced by this call
    |
note: required by a bound in `Router::<S>::layer`
   --> axum-0.8.9/src/routing/mod.rs:306:40
    |
303 |     pub fn layer<L>(self, layer: L) -> Router<S>
    |            ----- required by a bound in this associated function
...
306 |         L::Service: Service<Request> + Clone + Send + Sync + 'static,
    |                                        ^^^^^ required by this bound in `Router::<S>::layer`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `exp-tower-site` (bin "ratelimit_layer") due to 1 previous error

(依赖要多开 tower 的 limit feature。)tower 仓库里要求给 RateLimit 实现 Clone 的 issue #794 写作时仍然开着,讨论里引了 axum 维护者的说法:要是能复制,「you'd be able to just clone them to fake being able to handle more load」。

四、就算能 Clone,状态也留不住。 axum 的 Route 处理每个请求时都是 self.0.clone().oneshot(req):先复制一份套好 layer 的服务,再在副本上调用。存在服务实例身上的计数,每个请求看到的都是一份新的。下面的中间件在自己身上存一个 local,另在 Arc 里存一个 shared:

use std::{
  sync::{
    Arc,
    atomic::{AtomicU32, Ordering},
  },
  task::{Context, Poll},
};

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

// 一个把计数存在自己身上的中间件,像 tower::limit::RateLimit 那样
#[derive(Clone)]
struct Count<S> {
  inner: S,
  local: u32,              // 每个实例自己的计数
  shared: Arc<AtomicU32>,  // 所有副本共用的计数
}

#[derive(Clone)]
struct CountLayer(Arc<AtomicU32>);

impl<S> Layer<S> for CountLayer {
  type Service = Count<S>;
  fn layer(&self, inner: S) -> Count<S> {
    Count { inner, local: 0, shared: self.0.clone() }
  }
}

impl<S: Service<Request>> Service<Request> for Count<S> {
  type Response = S::Response;
  type Error = S::Error;
  type Future = S::Future;
  fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
    self.inner.poll_ready(cx)
  }
  fn call(&mut self, req: Request) -> S::Future {
    self.local += 1;
    let shared = self.shared.fetch_add(1, Ordering::SeqCst) + 1;
    println!("  {} local={} shared={}", req.uri().path(), self.local, shared);
    self.inner.call(req)
  }
}

fn req(path: &str) -> Request {
  Request::builder().uri(path).body(Body::empty()).unwrap()
}

#[tokio::main]
async fn main() {
  println!("same Router (with_state), three requests:");
  let mut router = Router::new()
    .route("/a", get(|| async {}))
    .route("/b", get(|| async {}))
    .layer(CountLayer(Arc::new(AtomicU32::new(0))))
    .with_state(()); // 先把 layer 套好,排除「每次请求重新套」
  let mut svc = router.as_service::<Body>();
  for path in ["/a", "/a", "/b"] {
    svc.ready().await.unwrap().call(req(path)).await.unwrap();
  }
}
same Router (with_state), three requests:
  /a local=1 shared=1
  /a local=1 shared=2
  /b local=1 shared=3

(M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同。)同一个 Router、同一个路由连发两次,local 都是 1;只有放在 Arc 里的 shared 在累加。.with_state(()) 是为了排除另一个因素:没 with_state 的 Router 每个请求还会把 handler 外面的 layer 重新套一遍,见 不起服务器的测试。

所以本站的限流器状态放在服务实例之外:rate_limit_mw 第一次被调用时初始化一个 static OnceLock<Option<RateLimiters>>,之后所有副本、所有路由读的都是它。

📝NOTE
没有蠢问题

问: 桶表放在 static 里,整个进程只有一份。那不是和「一个 RateLimit 实例一个窗口」一样,所有人共用了吗?

答: 共用的是表,不是桶。表是 Mutex<HashMap<String, Bucket>>,键是客户端 IP,每个 IP 一个桶,互不影响。必须共用的恰恰是这张表:axum 每个请求都复制一份服务,表要是放在服务身上,每个请求拿到的都是空表,谁都限不住。只有拿不到 IP 的请求才落进同一个 "global" 桶。

令牌桶本身很短,acquire_in:

  fn acquire_in(&self, map: &mut HashMap<String, Bucket>, key: &str, now: Instant) -> bool {
    let bucket = map.entry(key.to_string()).or_insert(Bucket { tokens: self.burst, last: now });
    let dt = now.duration_since(bucket.last).as_secs_f64();
    bucket.tokens = (bucket.tokens + dt * self.rps).min(self.burst);
    bucket.last = now;
    if bucket.tokens >= 1.0 {
      bucket.tokens -= 1.0;
      true
    } else {
      false
    }
  }

新 IP 的桶是满的(burst 个令牌);每次来请求,先按距上次的时间补令牌,上限 burst,再扣 1 个。不需要定时器,也不需要后台任务。默认值是普通桶每秒 20、最多攒 60,敏感桶每秒 5、最多攒 15,都能用环境变量改。表的大小有上限 MAX_TRACKED_KEYS = 50 000:满了先清掉 600 秒(STALE_AFTER)没动过的桶,清完还满,新 IP 就并进一个叫 "__overflow__" 的共享桶,防止有人伪造海量 x-forwarded-for 把内存撑爆。

网关:钩子,不是洋葱

本站另有一个网关 crates/gateway,基于 Pingora,不用 tower。它组织逻辑的方式是实现 ProxyHttp trait 的几个回调,Pingora 在固定的阶段依次调用它们。网关的 impl ProxyHttp for AppGateway 实现了四个:

钩子阶段网关里做什么
request_filter收到请求后可以自己写出响应,返回 Ok(true) 结束处理
upstream_peer选上游固定转给 app
upstream_request_filter发往上游前改写 X-Forwarded-*、X-Real-IP
response_filter回给客户端前补响应头

和 tower / axum 的「一层套一层」比:

  • 顺序由谁定。 tower 里顺序就是嵌套的顺序,写代码的人决定;Pingora 的阶段顺序是框架定死的,你只决定每个阶段做什么。
  • 请求和响应在不在一个函数里。 from_fn 中间件在同一个函数里 next.run(req).await 前处理请求、后处理响应,局部变量自然跨过这一步。Pingora 里请求和响应是两个钩子,要跨钩子传状态只能放进每个请求一份的 CTX(网关里 type CTX = (),没有用到)。
  • 短路的响应走哪些步骤。 tower 里短路的响应还会穿过外面的每一层,上面安全头的实验就靠这一点。Pingora 里 request_filter 自己写出响应、返回 Ok(true) 后,pingora-proxy 0.8.0 直接记日志、结束这个请求,不会再调 response_filter。网关里把 80 端口跳到 HTTPS 的 HttpToHttps::request_filter 就是这样:在钩子里写出 301,然后 Ok(true)。想让这类响应也带上某个头,得在写出它的那个钩子里自己加。
  • 背压。 Pingora 的钩子里没有 poll_ready 这一步,钩子只在请求已经到来时被调用。

动脑筋答案

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

site order (security outermost):
  /api/x   200 enc=gzip xfo=DENY order=["security", "trailing_slash", "rate_limit", "handler"]
  /api/x   429 enc=gzip xfo=DENY order=["security", "trailing_slash", "rate_limit"]
  /blog/   308 enc=-    xfo=DENY order=["security", "trailing_slash"]
security innermost:
  /api/x   200 enc=gzip xfo=DENY order=["trailing_slash", "rate_limit", "security", "handler"]
  /api/x   429 enc=gzip xfo=-    order=["trailing_slash", "rate_limit"]
  /blog/   308 enc=-    xfo=-    order=["trailing_slash"]
  • 第二组只有第一行(200)带 xfo=DENY:请求一直走到了 handler,路上经过了最里层的 security,响应往外走时补上了头。order 里 security 排第三,紧挨着 handler(CompressionLayer 不记录,所以不在列表里)。
  • 429 停在 rate_limit,308 停在 trailing_slash,都没走到 security,于是没有安全头。本站把安全头挂在最外层,防的就是这两行。
  • 两组的 enc 一样:压缩层在两组里都包着限流,429 都被压缩了。

小结

  • Router::layer 后加的在外层,和 ServiceBuilder 相反;它把 layer 套在每个路由和 fallback 上,路由匹配在这些层之前。
  • 本站由外到内:安全头、压缩、尾斜杠跳转、限流。短路返回的响应只经过它外面的层,所以安全头放在最外面,429 和 308 才带得上。
  • tower::limit::RateLimit 超额时让请求等,一个实例一个固定窗口,不能 Clone;axum 又在每个请求上复制服务,实例上的状态留不住。本站改用 async fn 中间件加 static 里的按 IP 令牌桶,超额直接 429。
  • 网关用 Pingora 的回调钩子:阶段顺序由框架定,请求和响应分在两个钩子里,request_filter 短路后不再经过 response_filter。

💬 关联讨论

发起讨论
加载中...