Rust in Everything
基于 axum 0.8.9

取舍汇总

主线位置:两条主线的总账。前四条取舍落在装配主线上(函数怎么变成 handler、提取器怎么排、状态什么时候给、路径怎么进树),后三条决定请求主线的形状(每个请求怎么穿过擦除后的 Route、中间件包在哪里、连接归谁管)。

flowchart TB
  d1[handler 是普通函数] --> c1["报错只到 Handler"]
  c1 --> f1[debug_handler]
  d2[提取器分两种] --> c2[参数顺序有讲究]
  d3["状态在 Router S 里"] --> c3[with_state 前是半成品]
  c3 --> f3["serve 替你 with_state"]
  d4[路由树交给 matchit] --> c4[语法跟着 matchit 变]
  d5[全部擦成 Route] --> c5[每请求克隆加装箱]
  c5 --> f5[ServiceBuilder 合并]
  d6[中间件就是 tower] --> c6["错误必须 Infallible"]
  c6 --> f6[HandleErrorLayer]
  d7["serve 故意很薄"] --> c7[没有超时]
  c7 --> f7["hyper-util 自己写"]

每一节的格式和 hyper 卷、tower 卷 的同名页相同:做了什么决定,换来了什么,代价是什么,代价由谁来补。机制都在别的页讲过,这里只给结论和链接;引到的数字都来自那些页的实验,最后一节的实验是本页自己跑的。

handler 是普通函数,不用路由宏

决定:路由表是 main 里用方法调用造出来的普通值,crate 文档的第一条特性就是「Route requests to handlers with a macro-free API.」(lib.rs 第 5 行)。async fn 能交给 get,是因为 impl_handler! 为 1–16 个参数的函数实现了 Handler<T, S>,T 是参数类型组成的标记类型(见 Handler trait)。

换来的:

  • handler 就是一个没有标注的 async fn,路由表是值:可以写在函数里返回、按配置决定加不加某条路由、用 nest 和 merge 拼起来(见 axum 解决什么问题)。
  • 「这个函数能不能当 handler」由 trait 求解决定,不需要宏去解析函数签名。

代价:参数类型、个数、返回值、future 是否 Send,任何一处不对,rustc 只能报到「Handler<_, _> is not satisfied」这一层,不往下说是哪个参数。第 17 个参数也是同一句话,因为宏只展开到 16。几种常见成因的复现见常见坑:Handler 不满足。

谁来补:

  • #[debug_handler],文档第一句是「Generates better error messages when applied to handler functions.」它只在 debug 构建里生成检查代码,release 下原样返回输入;body 提取器放错位置的提示靠一张类型名字表,自己写的 body 提取器不在表里(见 FromRequestParts 与 FromRequest)。
  • 编译器提示本身:axum 0.8.9 / axum-core 0.5.6 给宏生成的 impl 加了 #[diagnostic::do_not_recommend](#3588),报错里的候选列表不再是一排元组,而是真正能用的提取器(见 Handler trait 的冷知识)。

提取器分两种 trait:只有最后一个能拿 body

决定:FromRequestParts<S> 借 &mut Parts,可以有任意多个;FromRequest<S, M> 按值拿走整个请求,只能是最后一个参数。这是 0.6 的 #1272 拆出来的,CHANGELOG 原话:「It is now only possible for one extractor per handler to consume the request body. In 0.5 doing so would result in runtime errors but in 0.6 it is a compile error」(见 FromRequestParts 与 FromRequest 的冷知识)。

换来的:「body 只能读一次」从运行时的 BodyAlreadyExtracted 拒绝变成了编译错误,BodyAlreadyExtracted 随之删除。提取器从左到右依次执行,任何一个失败就把 rejection 变成响应返回,后面的提取器和你的函数都不执行。

代价:

  • 参数顺序有讲究:Json<T>、String、Request 这类要放最后。放错了不是「顺序不对」,而是又一句 Handler<_, _> 不满足。
  • trait 多了一个标记参数 M(ViaParts / ViaRequest),让只读头的提取器经由 blanket impl 也能放在最后,同时不和 Result 等的实现冲突。写提取器的人要读懂它。
  • Option<T> 不再是「出错就 None」:0.8 起要 T 实现 OptionalFromRequestParts,Query 没有实现(#3088),Option<Query<T>> 编译不过(见常见坑:Option Query)。

谁来补:#[debug_handler] 对表里的 body 提取器给出「放到最后」的直接提示;想自己处理提取失败,用 Result<T, T::Rejection> 作参数,它永不拒绝。

状态放进类型:Router<S> 与 with_state

决定:应用状态用 State<T> 提取,Router<S> 的 S 表示「还欠一个 S」,只有 Router<()> 实现 Service。#1155 的说明:「Attempting to extract states that haven't seen set results in compile errors, rather than runtime errors.」旧的 Extension 仍在,运行时按类型查找(见 状态:State 与 FromRef)。

换来的:

  • 忘了给状态是编译错误;Extension 缺了是运行时 500。CHANGELOG 建议迁移的理由是「more type safe and faster」。
  • with_state 返回 Router<S2>,S2 由调用方定(#1552),子路由器可以先填自己的状态再 nest、merge。

代价:

  • 类型里多一个参数,with_state 前后类型不同。函数签名写错就是常见坑:Router 状态不匹配。
  • handler 拿到状态之前造不出服务,只能先存成 BoxedIntoRoute,.layer 对它只记下闭包(见 Route 与类型擦除)。从没调用过 with_state 的路由器被直接当服务用,就只能每个请求现做:#1552 的原话是「That means we need to do it for each request」。一个请求穿过 axum 实测:不调用 with_state 时 2 个请求 Layer::layer 被调用 2 次,with_state(()) 之后 0 次。
  • 状态按值逐层传,每个请求还要再克隆:一个请求穿过 axum 的例子启动时克隆 6 次、每个请求 5 次(其中每层 from_fn 占 2 次);状态:State 与 FromRef 里带 State 的请求是 3 次。

谁来补:

  • axum::serve 和 into_make_service 替你调用 with_state(())。前者是 0.7.5 才补上的(#2586):issue #2582 发现 get(handler).layer(ConcurrencyLimitLayer::new(64))「compiles, but no concurrency limit is enforced」,因为每个请求都套出一个新的 ConcurrencyLimit。
  • 状态放进 Arc,克隆只是计数加一。
  • 没有人替 Handler::layer 补:它的套法在 Layered::call 里,到 0.8.9 仍是每个请求一次,见下面「中间件就是 tower」一节。

路由树交给 matchit

决定:路径匹配交给 matchit,Cargo.toml 里锁死 =0.8.4;树里只存 RouteId,端点在旁边的表里(见 Router 的内部结构)。写法错误和路由冲突都在 route 时 panic。

换来的:

  • matchit 自己的定位是「A high performance, zero-copy URL router.」(crate 描述)。axum 没有给出它和别的匹配方式比较的数字,本卷也不补。
  • 冲突在启动时暴露,不会等到某个请求才发现两条路由抢同一条路径;静态段优先、走不通再回溯(见 matchit 与路径语法)。

代价:

  • 路径语法就是 matchit 的语法,matchit 一改,axum 就跟着破坏性升级。0.8 的 #2645 把 /:id、/*rest 换成 /{id}、/{*rest};旧写法在 matchit 0.8 里是合法的字面路径,所以 axum 特意让它 panic,CHANGELOG:「the old syntax produces a panic to avoid silent change in behavior」。锁死小版本的理由也在 #2645:「the code currently expects that if there is a match it encompasses the whole path segment」。
  • 参数只能占整段的尾部,/{id}.json 不行(见常见坑:路径语法 panic)。
  • /foo 和 /foo/ 是两条路由。0.6 之前 axum 会在两者之间自动重定向,issue #1118 认为这是个错误:「I think its a surprising feature and not something most users would expect the default behavior to be. Its almost like adding a hidden route.」#1119 删掉了它(见常见坑:尾斜杠 404)。

谁来补:确实要用字面 : 开头的段,调 without_v07_checks;尾斜杠用 axum-extra 的 route_with_tsr,或者在路由之前改写 URI 的中间件(要包住整个 Router,见 layer 与 route_layer)。

全部擦成 Route

决定:每个端点最后都变成 Route:一个 BoxCloneSyncService<Request, Response, E> 加一层 MapIntoResponse。0.8 起要求所有 handler 和服务 Sync(#2473),PR 的理由:「This will allow us to use Arc inside Router to make it cheaper to clone.」Router 本身是 Arc<RouterInner>(见 Route 与类型擦除、Router 的内部结构)。

换来的:

  • 参数不同的 handler、套了不同中间件的服务,都能放进同一张 HashMap<RouteId, Endpoint>、同一个方法槽;响应类型统一成 Response。
  • 克隆 Router 只是计数加一。issue #2449 报告 0.7.2 在 1000 条路由时只有 13334 req/s(0.6.20 是 48525),原因是每个请求克隆整个路由器;0.7.4 的 #2483 收进 Arc 后回到 47294 req/s(见 Router 的内部结构 的冷知识)。

代价:

  • 每个请求克隆一次选中的 Route、装箱一次 future。没有 layer 时服务克隆 1 次、和路由有关的分配 2 次;1 层 layer 是 2 次和 5 次,2 层是 3 次和 9 次(Route 与类型擦除)。层数一多,增长比层数快,本页最后一节专门讲。
  • axum::serve(listener, router) 每条连接调用一次 self.clone().with_state(())(第 561 行),Arc 被 serve 共享着,于是所有端点复制一遍。3 条路由、每条连接 2 个请求,服务被克隆 5 次;into_make_service() 只要 2 次(Router 的内部结构)。
  • 服务里带着状态,克隆服务就是克隆状态,这是上一节「每个请求克隆状态好几次」的来源。

谁来补:服务和状态的 Clone 做便宜(几个 Arc);路由多、连接短时,先 into_make_service() 再交给 serve;多个中间件先用 ServiceBuilder 合成一个再 .layer 一次。Route 内部的克隆链,issue #3913 提议放进 Arc,在 0.8.9 里还没有改。

📝NOTE
没有蠢问题

问: 既然 Router 已经是 Arc 了,为什么每个请求还要克隆 Route,不能大家共享同一个吗?

答: 因为 tower 的 Service::call 要 &mut self,而同一个 Router 被很多连接同时用,手里只有 &self。维护者在 issue #2449 里的解释:「hyper's Service trait uses &self whereas tower uses &mut self. So we have to clone the router to get a &mut Router that we can call.」克隆出来的副本还要先 poll_ready 再 call(oneshot),就绪状态不跨请求共享(tower 卷 在本站中:中间件栈)。所以「每个请求克隆一次服务」是 tower 语义要求的;#3913 想省掉的是另一部分:外层服务被克隆时,连带把里面每一层 Route 的盒子都复制一遍。

中间件就是 tower

决定:axum 没有自己的中间件系统。crate 文档:「axum doesn't have its own middleware system but instead uses tower::Service.」Router::layer 收的是 tower 的 Layer<Route>,from_fn 的产物也是一个 Layer(见 axum 解决什么问题)。

换来的:tower-http 的超时、压缩、CORS、Trace 直接能用(见 tower-http),文档说还能「share middleware with applications written using hyper or tonic」。

代价:

  • 错误类型必须能转成 Infallible。error_handling.md 的理由:错误一路传到 hyper,「the connection will be terminated without sending a response」。返回 BoxError 的 tower 中间件(Timeout)不能直接 .layer,而且要和 HandleErrorLayer 放进同一次 .layer(见常见坑:Infallible)。
  • 顺序:后 .layer 的在外层,和 ServiceBuilder 相反(tower 卷层的顺序反了)。
  • Router::layer 不是给整个 Router 包一层,而是给调用时已有的每个 Route 各包一层:中间件在路由之后执行,改 URI 不影响路由;之后加的路由不受影响;Router::route_layer 仍然套住 405(见 layer 与 route_layer、常见坑:中间件没生效)。
  • Handler::layer 在 Layered::call 里每个请求重套一遍(0.6 引入状态参数时改成这样,#1155)。ConcurrencyLimitLayer::new(1) 挂在这里,3 个请求调了 3 次 Layer::layer,三个请求都在约 100ms 同时完成,上限没起作用(layer 与 route_layer);而它自己的文档例子正是 handler.layer(ConcurrencyLimitLayer::new(64))。
  • from_fn 的便利要付分配:同样什么都不做,一层 from_fn 每个请求 16 次分配,tower 的 MapRequestLayer 13 次,不加中间件 11 次(from_fn 与它的兄弟)。

谁来补:HandleErrorLayer(或在 MethodRouter 上 handle_error)把错误变成响应;route_layer 跳过 404;要在路由之前执行,用 Layer::layer(router) 包住整个 Router;要限流就挂在 MethodRouter / Router 上,或者用信号量在 Arc 里的 GlobalConcurrencyLimitLayer。

不管连接:serve 故意很薄,绑定 tokio

决定:axum::serve 的文档(serve/mod.rs 第 28 行):「This method of running a service is intentionally simple and doesn't support any configuration. Use hyper or hyper-util if you need configuration.」运行时方面,lib.rs 第 20 行:「Runtime and transport layer independence is not a goal, at least for the time being.」

换来的:起一个服务只要 axum::serve(listener, app).await,连接交给 hyper-util 的 auto::Builder,开了 http2 feature 就能在同一个端口上接两种协议;需要时加 with_graceful_shutdown。Router 本身只是 tower Service,测试可以不起服务器直接 oneshot(见 不起服务器测路由),也可以交给自己写的 accept 循环。

代价:

  • 一个 hyper 选项都改不了。serve 不给 hyper 设 timer,HTTP/1 默认 30 秒的读头超时静默失效,慢客户端能一直占着连接(hyper 卷取舍汇总最后一节、常见坑:慢客户端)。
  • 不限连接数;升级成 WebSocket 的连接不在停机名单上,with_graceful_shutdown 既不通知也不等它们(见 WebSocket 升级)。
  • 每条连接 with_state(()) 一次的复制,见上面「全部擦成 Route」。

谁来补:自己用 hyper-util 写 accept 循环(auto、graceful),或者让前面的代理挡住慢连接;本站的实际情况见 hyper 卷的在本站中:服务端路径。

两个取舍叠在一起:issue #3913

「全部擦成 Route」的结果是:Route 被调用时会克隆自己的盒子再 oneshot。oneshot_inner:

    pub(crate) fn oneshot_inner(&mut self, req: Request) -> RouteFuture<E> {
        let method = req.method().clone();
        RouteFuture::new(method, self.0.clone().oneshot(req))
    }

「中间件就是 tower、按 Route 去包」的结果是:每调一次 .layer,Route::layer 就把旧的 Route 按值交给 layer、再装进一个新盒子:

        let layer = (MapErrLayer::new(Into::into), layer);

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

单看任何一条都只是常数开销。叠在一起:最外层的盒子被克隆时,里面每一层 Route 都跟着复制;中间件调内层 Route::call 时,内层又把它下面的整条链复制一遍。n 次 .layer,每个请求盒子要复制 1+2+…+(n+1) 次。#3913(2026-10 提出,尚未关闭)的原话:「Every level then clones the rest of the stack again when it calls the next one, so the number of clones per request grows faster than the number of layers」。

下面量一下。端点是一个计数 Clone 的服务,中间件用 tower 的 MapRequestLayer(它自己不分配,量到的全是 axum 的包装),比较「分 n 次 .layer」和「同样 n 层先放进一个 ServiceBuilder、只 .layer 一次」。和 axum::serve 一样先 with_state(()),预热一次,再发 100 000 个请求取平均,请求在计时、计数前造好:

axum = { version = "=0.8.9", default-features = false }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use std::alloc::{GlobalAlloc, Layout, System};
use std::convert::Infallible;
use std::future::{ready, Ready};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use std::time::Instant;

use axum::body::Body;
use axum::extract::Request;
use axum::routing::get_service;
use axum::Router;
use tower::util::MapRequestLayer;
use tower::{Service, ServiceBuilder, ServiceExt};

// count heap allocations
struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for Counting {
  unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
    ALLOCS.fetch_add(1, Ordering::Relaxed);
    unsafe { System.alloc(layout) }
  }
  unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
    unsafe { System.dealloc(ptr, layout) }
  }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

// count how often the endpoint service is cloned
static CLONES: AtomicUsize = AtomicUsize::new(0);

struct Svc(u64);

impl Clone for Svc {
  fn clone(&self) -> Self {
    CLONES.fetch_add(1, Ordering::Relaxed);
    Svc(self.0)
  }
}

impl Service<Request> for Svc {
  type Response = &'static str;
  type Error = Infallible;
  type Future = Ready<Result<&'static str, Infallible>>;

  fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
    Poll::Ready(Ok(()))
  }

  fn call(&mut self, _req: Request) -> Self::Future {
    ready(Ok("ok"))
  }
}

fn ident(req: Request) -> Request {
  req
}

fn base() -> Router {
  Router::new().route("/", get_service(Svc(1)))
}

// n separate Router::layer calls
fn separate(n: usize) -> Router {
  let mut app = base();
  for _ in 0..n {
    app = app.layer(MapRequestLayer::new(ident));
  }
  app
}

// the same n layers, composed first, one Router::layer call
macro_rules! grouped {
  ($($x:tt)*) => {
    base().layer(ServiceBuilder::new() $(.layer({ let _ = stringify!($x); MapRequestLayer::new(ident) }))*)
  };
}

async fn measure(name: &str, app: Router) {
  let app = app.with_state(()); // what axum::serve does
  let req = || Request::get("/").body(Body::empty()).unwrap();
  app.clone().oneshot(req()).await.unwrap(); // warm up

  const N: usize = 100_000;
  let mut reqs: Vec<_> = (0..N).map(|_| req()).collect();
  let (a0, c0) = (ALLOCS.load(Ordering::Relaxed), CLONES.load(Ordering::Relaxed));
  let start = Instant::now();
  for r in reqs.drain(..) {
    let res = app.clone().oneshot(r).await.unwrap();
    drop(res);
  }
  let ns = start.elapsed().as_nanos() as f64 / N as f64;
  let allocs = (ALLOCS.load(Ordering::Relaxed) - a0) as f64 / N as f64;
  let clones = (CLONES.load(Ordering::Relaxed) - c0) as f64 / N as f64;
  println!("{name:<16} svc clones/req = {clones:>4.1}  allocs/req = {allocs:>5.1}  ns/req = {ns:>6.0}");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  for n in [0, 1, 2, 4, 8] {
    measure(&format!("separate x{n}"), separate(n)).await;
  }
  measure("grouped x8", grouped!(a a a a a a a a)).await;
}

M2 Max、rustc 1.99.0、--release,跑了 3 次。克隆数和分配数 3 次完全相同,耗时各次相差不到 4%,下面是第一次:

separate x0      svc clones/req =  1.0  allocs/req =   9.0  ns/req =    384
separate x1      svc clones/req =  2.0  allocs/req =  12.0  ns/req =    454
separate x2      svc clones/req =  3.0  allocs/req =  16.0  ns/req =    548
separate x4      svc clones/req =  5.0  allocs/req =  27.0  ns/req =    771
separate x8      svc clones/req =  9.0  allocs/req =  61.0  ns/req =   1367
grouped x8       svc clones/req =  2.0  allocs/req =  12.0  ns/req =    458
  • 服务的克隆随层数线性增长:n 层是 n+1 次:MethodRouter 克隆整条链时 1 次,n 个中间件各调一次内层 Route::call,各 1 次。这一部分是 tower 语义要的(上面的「没有蠢问题」)。
  • 分配随层数平方增长。9 次里有 7 次和路由无关(造响应、Body 等),和 Route 与类型擦除 里 get_service(Svc) 那一行的 9 是同一个基数。减掉 7,和路由有关的是 2、5、9、20、54:盒子复制 (n+1)(n+2)/2 次,加上 n+1 次 future 装箱,n=8 时是 45+9。
  • 耗时跟着分配走:8 层分开挂,每个请求从约 0.38µs 涨到约 1.37µs。
  • 合并成一次 .layer 就回到 1 层的成本:grouped x8 和 separate x1 一样是 2 次克隆、12 次分配。ServiceBuilder 把 8 个 MapRequest 组合成一个不装箱的服务,axum 只看到一个 layer,只包一层 Route。

这就是两条取舍的交点:类型擦除让每次调用都要克隆,「按 Route 包」让 Router::layer 每调用一次就多一层可克隆的盒子。axum 文档本来就推荐「use tower::ServiceBuilder to apply multiple middleware at once, instead of calling layer (or route_layer) repeatedly」,给的理由是顺序:「Executing middleware top to bottom is generally easier to understand and follow mentally which is one of the reasons ServiceBuilder is recommended.」在 0.8.9 里它同时是唯一的性能补救,from_fn 与它的兄弟 里 3 层 from_fn 从 32 次降到 22 次也是这个原因。#3913 提议把 Route 里的盒子放进 Arc,对应的 PR #3915「Share layered route services through an Arc」还没有合并,0.8.9 和至今的任何发布版本都不包含它。

❗IMPORTANT
动脑筋:分 16 次 .layer,每个请求多少次分配?合并成一个 ServiceBuilder 呢?

平台 M2 Max、rustc 1.99.0。把上面程序的 main 换成:

#[tokio::main(flavor = "current_thread")]
async fn main() {
  measure("separate x16", separate(16)).await;
  measure("grouped x16", grouped!(a a a a a a a a a a a a a a a a)).await;
}

按上面的数法,两行的 allocs/req= 各是多少?

同一类「两条叠在一起」的事在 0.7.5 之前也出过一次:「状态放进类型、with_state 之前的 handler 只是半成品」加上「serve 很薄、直接 call 路由器」,结果是无状态应用每个请求都重套一遍 layer,ConcurrencyLimitLayer 形同虚设(#2582,由 #2586 修复,见 一个请求穿过 axum 的冷知识)。Handler::layer 今天还保留着同样的每请求重套。

动脑筋答案

separate x16     svc clones/req = 17.0  allocs/req = 177.0  ns/req =   3418
grouped x16      svc clones/req =  2.0  allocs/req =  12.0  ns/req =    422

跑了 3 次,克隆数和分配数相同,耗时三次是 3418、3339、3344 ns 和 422、430、421 ns。

  • separate x16:177 = 7 + 170。盒子复制 17×18/2 = 153 次,future 装箱 17 次。服务克隆 17 次,仍是线性的。每个请求约 3.4µs,是不挂中间件时的 9 倍左右。
  • grouped x16:12,和 grouped x8、separate x1 一样。层数全在 ServiceBuilder 组合出来的那个服务里,MapRequest 不分配,axum 只包了一层 Route。

小结

  • handler 是普通函数,路由表是普通值;代价是报错只到「Handler<_, _> 不满足」,由 debug 构建下的 #[debug_handler] 和 do_not_recommend 补一部分。
  • 提取器分 FromRequestParts / FromRequest,「body 只读一次」成了编译期规则;代价是参数顺序和一个标记参数 M,Option<T> 也要类型自己表态。
  • 状态放进 Router<S>,忘了给是编译错误;代价是 with_state 前的 handler 只是半成品,不 with_state 就每个请求现做;serve 和 into_make_service 替你补,Handler::layer 没人补。
  • 路由交给 matchit,冲突在启动时 panic;代价是语法跟着 matchit 走(0.8 的 {}),尾斜杠是两条路由。
  • 所有端点擦成 Route,能放进一张表、Router 克隆便宜;代价是每个请求克隆加装箱,serve(router) 每条连接复制全部端点。
  • 中间件就是 tower,生态通用;代价是 Infallible、顺序与 ServiceBuilder 相反、包在每个 Route 上而不是整个 Router 上。
  • serve 故意很薄、绑定 tokio;超时、连接数、hyper 选项都要自己写 accept 循环。
  • 「擦成 Route」加「每次 .layer 一层 Route」:分 n 次 .layer,每个请求的分配按 (n+1)(n+2)/2 增长(#3913,0.8.9 未修);合并进一个 ServiceBuilder 再 .layer 一次,成本回到一层。

💬 关联讨论

发起讨论
加载中...