Skip to content

第1章 为什么需要 Axum:Web 框架的抽象边界

裸用 hyper:80 行代码写一个路由

假设你不用任何 Web 框架,直接用 hyper 写一个 HTTP 服务。需求很简单:GET /users 返回用户列表,POST /users 创建用户,GET /users/:id 返回单个用户。三个路由,两个 HTTP 方法,最基本不过的增删改查起步。

你开始写。首先实现 Service trait——hyper 的核心抽象,你在《Hyper 与 Tower》第 2 章已经见过它:

rust
use hyper::{Request, Response, Body};
use tower::Service;

struct UserService;

impl Service<Request<Body>> for UserService {
    type Response = Response<Body>;
    type Error = hyper::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

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

    fn call(&mut self, req: Request<Body>) -> Self::Future {
        // 在这里写路由匹配
    }
}

仅仅实现 trait 本身就需要 20 行。关联类型 ResponseErrorFuture——每一个都必须显式声明。Future 的类型尤其冗长:Pin<Box<dyn Future<Output = Result<...>> + Send>>。这不是 hyper 在刁难你——这是 Rust 的 async fn 在 trait 里还不稳定时代的标准写法。你不得不手写这个类型,即使你的 handler 只有一行业务逻辑。

然后你需要在 call 里手动做路由匹配:

rust
fn call(&mut self, req: Request<Body>) -> Self::Future {
    let method = req.method().clone();
    let path = req.uri().path().to_owned();

    match (method.as_str(), path.as_str()) {
        ("GET", "/users") => { /* 返回用户列表 */ },
        ("POST", "/users") => { /* 创建用户 */ },
        ("GET", path) if path.starts_with("/users/") => {
            let id = path.strip_prefix("/users/").unwrap();
            /* 返回单个用户 */
        },
        _ => { /* 404 */ },
    }
}

这段代码至少有三个问题。第一,starts_with + strip_prefix 不是真正的路径匹配——/users/ 后面带 / 或带 ?query=1 的路径会产生错误的行为。你还需要处理尾部斜杠、空参数、路径编码等问题。第二,每次请求都做字符串比较,没有路由树,路由数量增长后复杂度退化为 O(n)。第三,也是最大的痛点——POST /users 的请求体需要你手动从 Body 流里收集字节、手动反序列化成结构体。这意味着你要自己处理 http_body::Body::data() 返回的 Result<Option<Data>, Error>,自己把 Chunks 拼成完整的 Bytes,自己调用 serde_json::from_slice,自己处理 MalformedRequestBody 错误、自己设置正确的 Content-Type 响应头、自己在反序列化失败时返回 422 Unprocessable Entity 而不是 500 Internal Server Error

三个路由就写到了 80 行——而且这还是最简单的版本,没有错误处理、没有中间件、没有共享状态。如果加上请求体解析、错误处理、中间件(日志、认证、限流),轻松突破 200 行。而这 200 行里,真正与业务逻辑相关的可能只有 20 行——剩下 180 行全是在解决"Web 框架应该帮你解决的问题"。

更致命的是,这 180 行几乎无法复用。下一个服务你又得写一遍路由匹配、一遍请求体解析、一遍错误映射。你可能会想把这些通用逻辑抽成函数或结构体——恭喜你,你正在重新发明一个 Web 框架,而且大概率比已有的框架更粗糙。

还有一层更隐蔽的代价:测试。当你裸用 hyper 时,测试一个路由意味着你必须在集成测试里启动一个真实的 HTTP 服务、发送真实的 HTTP 请求、解析真实的 HTTP 响应。单元测试几乎不可能——因为你的路由逻辑嵌在 Service::callmatch 分支里,而 call 方法的签名要求你构造一个完整的 Request<Body>,包括 HTTP 版本号、header map、body 流。这种测试的成本极高,导致大多数裸用 hyper 的项目干脆跳过路由层的单元测试,只在集成测试里做端到端验证。问题被推迟了,但没有被消除。

这就是"裸用 hyper"的真实代价。hyper 是一个优秀的 HTTP 协议栈实现,它精确地处理了 HTTP/1.1 和 HTTP/2 的字节级协议细节。但 hyper 不关心路由、不关心请求体反序列化、不关心中间件组合——这些是 Web 框架的领域。你需要一个建立在其上的抽象层。

其他语言怎么做的

"HTTP 协议太底层"不是 Rust 独有的问题——每种语言的 Web 开发者都面临同样的困境。但不同语言的解决方式差异巨大,而这个差异指向了 Rust 生态的一个根本特殊性:Rust 拥有一个显式的、类型化的中间件抽象层,而其他语言把这个层隐式化了

Go 的 net/http 把路由匹配和 handler 签名绑在标准库里:

go
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case "GET":
        // 返回用户列表
    case "POST":
        // 创建用户
    }
})

Go 的做法是"标准库做路由 + 全局隐式中间件链"。http.Handler 接口只有一个方法 ServeHTTP(ResponseWriter, *Request),中间件就是返回 http.Handler 的函数。没有 Service trait,没有 poll_ready,没有背压——所有复杂性被隐式地吞掉了。代价是:中间件只能通过闭包嵌套来组合,没有统一的 Layer 抽象,没有类型级别的中间件顺序保证,错误处理全靠 if err != nil 一路传递,中间件的执行顺序完全取决于注册顺序——你无法在编译期发现"认证中间件在日志中间件之前还是之后执行"这个问题。

Go 社区后来发展出了 chigorilla/muxgin 等第三方路由库,它们各自提供了更强大的路由匹配和中间件组合能力,但彼此之间不兼容。如果你从 chi 迁移到 gin,你的中间件全部要重写。Go 没有一个统一的中间件抽象——这也是为什么 net/http 的中间件至今仍然是"闭包套闭包"的模式。Go 社区的解决方式是"事实标准"——chi 的中间件格式成了非官方标准,但标准库本身并不认可这种格式。这种碎片化在 Rust 里会被放大——因为 Rust 的中间件是 trait,不是闭包,碎片化意味着类型不兼容,而不只是"换个函数签名"。

Node.js 的 Express 走得更远——路由和方法绑定在一起,中间件是按注册顺序执行的函数数组:

javascript
app.get('/users', (req, res) => { /* ... */ });
app.post('/users', (req, res) => { /* ... */ });
app.get('/users/:id', (req, res) => { /* ... */ });

Express 的极致简洁来自 JavaScript 的动态类型和运行时多态。reqres 是普通的对象,路由参数通过 req.params.id 动态访问,请求体解析通过 body-parser 中间件往 req.body 上挂属性。没有任何编译期检查——如果路由模板写的是 :id 而 handler 里访问的是 req.params.userId,只有运行时才能发现。中间件之间通过修改 reqres 对象来通信,这种隐式共享状态在复杂场景下会导致难以追踪的副作用。

Express 的中间件模型有一个精巧的设计:next() 函数。调用 next() 把控制权交给下一个中间件,下一个中间件执行完毕后再返回当前中间件——这形成了一个类似栈的调用链。但这个模型是纯运行时的——你无法在类型层面表达"这个中间件要求前一个中间件已经设置了 req.user"这种约束。TypeScript 可以通过声明合并给 req 加类型,但这个类型是全局的、松散的,无法精确地追踪中间件链中每个位置的类型变化。

Express 的隐式状态共享还有另一个问题:中间件的执行顺序至关重要,但这个顺序没有任何显式声明。一个认证中间件必须在业务 handler 之前执行——这在 Express 里靠的是注册顺序(先 app.use(auth)app.get('/users', handler))。但如果有人在另一个文件里先注册了 app.get('/users', handler) 再注册 app.use(auth),认证就失效了。这种错误在大型项目里并不罕见,而且只能通过代码审查来发现——没有任何工具能帮你在编译期或运行时自动检测。Rust 的类型系统可以部分解决这个问题——如果你用 trait 来编码中间件的输入输出类型,编译器就能在编译期检测到类型不匹配。但前提是所有中间件都使用同一个 trait——这就是为什么 tower 的统一 Service trait 如此重要。

actix-web 是 Rust 生态里 Axum 之前最成熟的 Web 框架。它有自己的 Service trait、自己的中间件系统、自己的 Handler trait:

rust
async fn get_users(req: HttpRequest) -> impl Responder {
    HttpResponse::Ok().json(vec!["alice", "bob"])
}

App::new()
    .route("/users", web::get().to(get_users))

actix-web 的 handler 系统设计精良——参数提取通过 trait 自动完成,不需要显式声明。但它有一个根本性的架构选择:它没有用 tower::Service 作为中间件接口。actix-web 有自己的 actix_service::Service trait,有自己的 actix_service::Transform(等价于 tower 的 Layer),有自己的一套 Dev 中间件链。这意味着你在 tower 生态里写的中间件——tower-httpTraceCompressionBodyRequestIdTimeoutLimit——无法直接用在 actix-web 上。你需要写适配层来桥接两个 Service trait,或者干脆用 actix-web 自己的等价实现。

这不是 actix-web 的设计失误。actix-web 的 Service trait 与 tower 的 Service trait 有不同的设计目标:actix-web 的版本把 call&mut self 改成了 &self,允许同一个 Service 实例被多个请求并发调用,不需要 clone。这在某些场景下性能更好——不需要为每个连接 clone 整个服务链。但代价是与 tower 生态完全隔离。

这就是 Rust Web 生态与其他语言的根本差异。Go 和 Node.js 不存在"中间件接口不兼容"的问题,因为它们的中间件没有类型——所有东西都是函数或接口,运行时动态分派,任何函数只要签名匹配就能做中间件。但 Rust 的中间件是 Service<Request> -> Future<Output = Response> 的 trait 约束,不同的 trait 定义意味着类型不兼容。你不能把一个实现了 actix_service::Service 的中间件传给一个期望 tower::Service 的函数——它们是不同的 trait,编译器会直接报错。这不是某个框架的缺陷——这是 Rust 选择"零成本抽象"的必然结果。当你用 trait 来编码中间件接口时,trait 的定义就成了一个类型层面的"锁定"——所有使用这个 trait 的代码都被锁定在这个 trait 的签名上,除非你写适配器。

上图展示的核心差异:Go/Node.js 用运行时动态性绕过了中间件接口问题,代价是零编译期保证;actix-web 选择了自己的中间件 trait,与 tower 生态隔离;Axum 选择了完全拥抱 tower 生态,代价是设计空间被 tower::Service 的签名约束。

为什么是 tower

上一节留下了一个关键问题:为什么 Rust 需要一个统一的中间件抽象,而不能像 Go 或 Node.js 那样"函数即中间件"?

答案在于 Rust 的所有权模型和零成本抽象目标。

在 Go 里,中间件就是一个函数 func(h http.Handler) http.Handler。这个函数接收一个 handler,返回一个包装后的 handler。包装后的 handler 可以在调用原始 handler 之前和之后执行自己的逻辑。这种模式非常简单,但它隐式地假设了三件事:handler 可以被随意克隆(Go 里函数是一等公民,闭包捕获的变量通过垃圾回收管理);中间件不需要背压控制(Go 的 HTTP 服务不做显式的 poll_ready 检查);中间件之间通过修改共享状态通信(reqres 是可变引用,任何中间件都能改)。

这三条假设在 Rust 里都不成立。Rust 没有垃圾回收——闭包捕获的变量的生命周期必须在编译期确定,捕获方式(移动还是借用)直接影响闭包的类型。Rust 强调所有权——如果你想让中间件修改请求,你必须在"借用"和"移动"之间做出选择,而这个选择会影响整个中间件链的类型签名。Rust 追求零成本——如果你不需要中间件的某种能力(比如背压),你就不应该为它付出运行时开销。

tower::Service trait 正是为了解决这些问题而设计的。它的核心签名:

rust
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
    fn call(&mut self, req: Request) -> Self::Future;
}

poll_ready 提供了显式的背压控制——在调用 call 之前,调用方必须先 poll_ready 确认服务就绪。这在 Go 和 Node.js 里是没有的——它们的中间件总是"就绪"的,因为所有状态都在堆上,不受栈空间限制。但在 Rust 里,一个 Buffer 中间件可能因为内部缓冲区满了而返回 Pending——调用方必须等待它就绪后再发起新请求。这种背压机制在《Hyper 与 Tower》第 4 章有详细讨论,它让 Rust 的中间件栈可以安全地处理过载,而不是在内存里无限积压请求。

call(&mut self, req) 要求 &mut self——这意味着 Service 不是 Sync 的,不能被多个线程同时调用。这是为了支持有状态的中间件(比如 RateLimit 需要维护计数器),而不需要加锁。但代价是:每个需要并发访问的 Service 实例必须被 clone——这就是 Axum 的 Router 必须实现 Clone 的根本原因。

Layer trait 提供了中间件组合的标准接口:

rust
pub trait Layer<S> {
    type Service;
    fn layer(&self, inner: S) -> Self::Service;
}

Layer 接收一个内层 Service,返回一个包装后的 Service。这和 Go 的 func(h Handler) Handler 模式本质相同,但它是类型化的、泛型的——编译器能在编译期检查中间件的输入输出类型是否匹配。如果你试图把一个期望 Service<String> 的中间件包在一个 Service<Request> 外面,编译器会直接报错。这种类型级别的安全保证,在 Go 和 Node.js 里是不可能的。

正是因为 tower::Servicetower::Layer 提供了这套统一的、类型化的中间件抽象,tower 生态才能围绕它构建出一整套中间件库。tower 本身提供了 TimeoutRetryRateLimitBufferLoadShedBalance 等通用中间件;tower-http 提供了 TraceCompressionBodyRequestIdCorsLimitNormalizePathSetStatusPropagateHeader 等 HTTP 专用中间件——总共 30 多个。任何一个使用 tower::Service 作为中间件接口的框架,都能免费获得这 30 多个中间件。

这就是 Axum 选择拥抱 tower 的核心原因。不是"tower 更好"这种主观判断,而是"tower 已经有了 30 多个生产级中间件"这个客观事实。如果 Axum 自己发明中间件系统,它要么重新实现这 30 多个中间件(巨大的工程量,而且每个都需要独立维护和测试),要么写适配层来桥接 tower(增加复杂度,而且适配层本身也可能有缺陷)。两种方案都不如直接用 tower 来得干净。

tokio-rs 团队的设计哲学

Axum 是 tokio-rs 团队的项目。这个团队同时维护 tokio、hyper、tower、tracing——Rust 异步生态的核心基础设施。Axum 的设计哲学不是"做一个最好的 Web 框架",而是"在 tower 生态里做一个最好的 Web 框架"。

这个哲学直接写在源码里。打开 axum/src/lib.rs 的第 1-17 行,你会看到模块级文档的第一段:

axum doesn't have its own middleware system but instead uses [tower::Service]. This means the barrier to writing an axum middleware is very low if you're already familiar with tower.

这不是一句营销话。它是整个框架的架构约束——所有设计决策都要在这条约束下做出。具体而言:

  1. Handler 必须适配成 tower::Service。你写的 async fn hello() -> &'static str 不是 Service,但 Axum 必须把它变成 Service 才能接入 tower 中间件链。这就是 Handler<T, S> trait 和 HandlerService 存在的原因——我们会在第 5 章详细读这段代码。

  2. Router 必须实现 tower::ServiceRouter<S> 最终要能 .call(request) 返回 response。axum/src/routing/mod.rs:86 定义了 pub struct Router<S = ()>,它包裹 Arc<RouterInner<S>>——RouterInner 实现了 Service<Request<B>>

  3. 中间件就是 Layer。Axum 不提供自己的中间件 trait。axum::middleware::from_fn 返回的 FromFn 本质上是 tower::util::AndThen 的一种变体;axum::middleware::from_extractor 本质上是 tower::util::Then 的一种变体。所有 Axum 中间件都是 tower Layer,可以和 tower-httpTraceCompressionBodyRequestId 等中间件无缝组合。

这个选择的收益是巨大的:tower-http 提供了 30+ 个生产级中间件,Axum 不需要自己实现任何一个。但代价也很明确:tower::Service 的签名——fn call(&mut self, req: Request) -> Self::Future——要求 &mut self,这意味着 Service 不是 Sync 的。这直接影响了 Axum 的并发模型——每个连接需要一个独立的 Router clone,这又影响了 Router 的内部实现(必须用 Arc 共享路由树的不可变部分)。我们会在第 2 章和第 15 章看到这个连锁反应。

更深一层地看,tokio-rs 团队维护 Axum 的动机不是"做一个与 actix-web 竞争的框架",而是"确保 hyper 和 tower 的抽象有一个一等公民的使用入口"。在 Axum 出现之前,如果你想用 hyper + tower 写一个 Web 服务,你只能裸用——就像本章第一节展示的那样,80 行代码写三个路由。tower 的中间件虽然强大,但它的 API 面向的是库作者,不是应用开发者。ServiceLayer 的概念需要一定的抽象思维才能理解——应用开发者只想写 async fn handler() -> Json<Response>,不想关心 poll_readyPin<Box<dyn Future>>。Axum 的角色是一个"桥梁"——它在 tower 的底层抽象之上构建了一套面向应用开发者的 API,同时不丢失任何底层能力。你可以用 Axum 的 Router::new().route() 来注册路由,也可以随时 .layer() 一个原生 tower 中间件——两者无缝衔接。

如果你把视角拉远,Axum 在 tokio-rs 技术栈中的位置其实非常明确:tokio 负责异步运行时,hyper 负责 HTTP 协议解析,tower 负责中间件抽象,tracing 负责可观测性——这四层构成了 Rust 后端的核心基础设施。但它们都是面向库作者的底层抽象,应用开发者需要一个入口。Axum 就是这个入口。它不是基础设施的替代品,而是基础设施的消费者——在每一层都选择"用已有的"而不是"重新发明"。这种定位决定了 Axum 的迭代方向:不是追求功能大而全,而是追求在 tower 约束下把人体工学做到极致。

这种"桥梁"角色也解释了 Axum 的版本节奏。Axum 0.8.9 对应 axum-core 0.5.6 对应 axum-macros 0.5.1 对应 axum-extra 0.12.6——版本号各走各的。这不是管理混乱,而是有意为之。axum-core 定义的是提取器和响应的 trait——这些接口一旦发布就很少变动,所以它走 0.5.x 的慢版本。axum 主 crate 包含路由和 handler 的实现——每次路由 API 的调整都可能导致版本号跳变,所以它走 0.8.xaxum-extra 是新功能的试验田——TypedPathProtobufEither 这些 API 可能随时调整,所以它走 0.12.x 的高速版本。semver 的独立版本号精确地反映了这种稳定性梯度。

架构总览:四个 crate 的依赖方向

Axum 工作区包含四个 crate,总共约 35,000 行代码。它们的依赖关系是单向的——这是一个经过深思熟虑的 crate 边界设计。

axum-core 定义了三个核心 trait:FromRequestParts<S>axum-core/src/extract/mod.rs:53)、FromRequest<S, M>axum-core/src/extract/mod.rs:79)和 IntoResponse。它还定义了 FromRefIntoResponseParts 等辅助 trait。这个 crate 不依赖 axum——它只依赖 httphttp-bodybytes 这些协议层 crate。任何人想写 axum 生态的提取器或响应类型,只需要依赖 axum-core,不需要拉入整个 axum

这个 crate 边界的设计意图是清晰的:提取器和响应是"被框架消费"的 trait,而不是"框架自身"的逻辑。假设你写了一个 axum-sqlx 库,提供 SqlxPool 提取器——你只需要 axum-core 作为依赖,不需要 axum。这大大降低了生态库的依赖图。如果 axum 升级了路由 API(从 0.70.8),axum-sqlx 不需要跟着升级——因为 axum-coreFromRequestParts trait 没变。这种解耦在实际工程中的价值是巨大的——你不会因为框架的次要 API 变化而被迫升级所有生态库。

axum 是主 crate,包含路由系统(RouterMethodRouter)、handler 系统(Handler<T, S>)、内置提取器(PathQueryStateJson 等)、中间件函数(from_fnfrom_extractor)、serve 入口、以及 body 模块的流处理工具。它依赖 axum-core 和大量的 tower 生态 crate(towertower-layertower-servicetower-http)。代码量最大的是 axum/src/routing/method_routing.rs——1723 行,几乎占整个 crate 的十分之一。这个文件实现了 get()post()put()delete() 等 HTTP 方法路由的组合子,以及 MethodRouter<S>Service 实现。

axum-macros 提供过程宏:#[derive(FromRequest)]#[derive(IntoResponse)]#[debug_handler]。它只依赖 axum-core,不依赖 axum——因为 derive 宏生成的方法只涉及 axum-core 定义的 trait。#[debug_handler] 是唯一需要了解 Handler<T, S> 签名的宏,它通过硬编码的参数数量(1-16)来生成更友好的错误信息,而不是真正解析 Handler trait。我们会在第 19 章完整展开 axum-macros 的实现。

axum-extra 放"不够通用"的扩展:TypedPath(类型安全路由)、CookieJarProtobuf 提取器、Either 响应类型。它依赖 axum,可选依赖 axum-macros(用于 TypedPath 的 derive 宏)。这些功能放在 axum-extra 而不是 axum 里,是因为它们引入了额外的依赖(protobufcookie 等),或者 API 还不够稳定。axum-extra 的定位是"官方实验室"——功能先在这里试水,API 稳定后再考虑移入 axum 主 crate。

这种单向依赖的设计有一个直接后果:版本号不同步。axum 0.8.9、axum-core 0.5.6、axum-macros 0.5.1、axum-extra 0.12.6——四个版本号各走各的。这不是管理上的疏忽,而是 crate 边界的自然反映。axum-core 的 API 比 axum 稳定得多(提取器 trait 几乎不会变),而 axum-extra 的 API 最不稳定(新功能先放 extra 试水,稳定后再移到 axum)。semver 的独立版本号精确地反映了这种稳定性梯度。

三大支柱

Axum 的 35,000 行代码围绕三个核心抽象展开。理解了这三个抽象,你就理解了 Axum 的全部设计。这三个抽象不是孤立的——它们通过 tower::Service 这条主线串联在一起,构成了从用户代码到底层协议栈的完整链路。

路由:Router<S> + matchit

Router<S> 是 Axum 的入口类型。axum/src/routing/mod.rs:86 的定义:

rust
pub struct Router<S = ()> {
    inner: Arc<RouterInner<S>>,
}

Router<S> 的泛型参数 S 是状态类型。Router<()> 表示"没有状态",Router<AppState> 表示"状态类型是 AppState"。这不是普通的泛型——它是类型状态模式(type-state pattern)的实现。当你调用 .with_state(state) 时,Router<AppState> 被消费,返回 Router<()>。之后你不能再调用 .with_state(),因为 Router<()> 没有这个方法。编译器在编译期就阻止了你"忘记提供状态"或"重复提供状态"。

类型状态模式在 Rust 里并不罕见——std::io::Cursortokio::net::TcpListener 都用过类似的技巧。但 Axum 把它用在了整个路由系统的入口处,这是有意为之的。在 actix-web 里,状态通过 Data<T> 包装后注入 App::app_data(),没有任何编译期检查——你可以注册一个需要 Data<DbPool> 的 handler 但忘记提供 DbPool,运行时才会 panic。Axum 用类型状态模式在编译期消除了这种错误。当你写 Router::new().route("/", get(handler)) 时,如果 handler 的签名包含 State<AppState>,编译器会推导出路由类型是 Router<AppState>——然后你必须调用 .with_state(state) 把它变成 Router<()> 才能传给 serve()。如果你忘了,编译器会报出一个类型错误,而不是运行时 panic。

路由匹配的底层引擎是 matchit——一个基于基数树(radix tree)的路由匹配库,支持路径参数(/users/{id})、通配符(/static/{*path}),查找复杂度 O(k)(k 为路径长度)。Axum 不自己实现路由树——matchit 已经是一个经过 fuzzing 测试的独立库,Axum 只是把它包了一层。这种"不自己实现"的选择贯穿了 Axum 的整个设计:路由用 matchit,HTTP 协议用 hyper,中间件用 tower,异步运行时用 tokio,序列化用 serde。Axum 做的事情是"粘合",而不是"重新发明"。

RouterInner<S> 内部维护一个 matchit::Router<Node>,其中 Node 存储路由对应的 MethodRouter 和嵌套的子路由信息。每次调用 .route().nest() 都会往这个 matchit::Router 里插入一条路由。当请求到来时,Router 先用 matchit 做路径匹配,找到对应的 Node,然后根据 Node 里存储的 MethodRouter 做 HTTP 方法分发——如果请求的方法在 MethodRouter 里注册了,就调用对应的 handler;否则返回 405 Method Not Allowed 并自动设置 Allow 头。这套"路径匹配在前、方法分发在后"的两阶段路由模型,是 Axum 路由系统的核心架构,我们会在第 2 章完整追踪这个插入过程,在第 3 章展开方法分发的实现。

Handler trait:从 async fn 到 Service

你写的 handler 长这样:

rust
async fn create_user(
    State(state): State<AppState>,
    Json(body): Json<CreateUserRequest>,
) -> impl IntoResponse {
    // 业务逻辑
}

Axum 需要把这个函数变成 tower::Service。中间的桥梁就是 Handler<T, S> trait,定义在 axum/src/handler/mod.rs:148

rust
pub trait Handler<T, S>: Clone + Send + Sync + Sized + 'static {
    type Future: Future<Output = Response> + Send;
    fn call(self, state: S, req: Request) -> Self::Future;
}

注意那个幽灵参数 T。它不是你显式指定的——它是编译器通过 handler 函数的参数类型推导出来的标记类型。当你写 async fn hello(State(s): State<AppState>) 时,编译器推导出 T = (State<AppState>,);当你写 async fn create_user(State(s): State<AppState>, Json(b): Json<Req>) 时,T = (State<AppState>, Json<Req>)T 的唯一作用是让编译器为每种参数组合选择不同的 impl Handler<T, S>——这些实现通过 all_the_tuples! 宏生成,覆盖 1 到 16 个参数。

这个设计的精妙之处在于:你不需要理解 T 是什么就能写 handler。你只需要写一个普通的 async fn,参数类型是已知的提取器,Axum 的宏和 trait 系统会在后台完成"函数签名到类型标记到 trait 实现选择到参数提取到函数调用"这条链路。T 是一个纯粹的编译期机制——它不存在于运行时,零大小类型,不产生任何运行时开销。

Handler<T, S> 的约束 Clone + Send + Sync + Sized + 'static 值得仔细看。Clone 是必须的——因为 Handler 需要在 MethodRouter 里被 clone(每个连接 clone 一份 Router)。Send + Sync 是必须的——因为 Router 可能在多线程之间传递。Sized 是必须的——因为 Handler 要作为具体类型存储在 MethodRouter 里,而不是 dyn Handler'static 是必须的——因为 handler 的生命周期必须独立于创建它的作用域(它被移到了 tokio 的任务里)。这五个约束不是随意加上去的——它们每一个都有具体的工程原因。

Handler<T, S>tower::Service 的适配通过 HandlerService 完成——定义在 handler/service.rs:28,它 impl Service<Request<B>> for HandlerService<H, S, B>,在 call 里调用 H::call(self.state, req)。这个适配层是 Axum 人体工学的关键支撑——你写的函数签名是面向业务语义的(State<AppState>Json<Req>),但底层需要的是 Service<Request<B>> 的统一接口。HandlerService 把这两种视角粘在一起,而且这个过程完全发生在编译期——运行时只多了一次函数调用,内联后几乎零开销。第 5 章会完整展开这段代码。

提取器:FromRequest / FromRequestParts

提取器是 Axum 人体工学的核心。你不需要手动从 Request 里取路径参数、解析查询字符串、反序列化请求体——提取器帮你做。但提取器不是魔法,它是两个 trait 的实现。

FromRequestParts<S> 定义在 axum-core/src/extract/mod.rs:53

rust
pub trait FromRequestParts<S>: Sized {
    type Rejection: IntoResponse;
    fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send;
}

注意参数类型:parts: &mut Parts——是 http::request::Parts 的可变引用,不是所有权。这意味着 FromRequestParts 的实现者只能读取或修改请求的元数据(URI、header、method、extensions),不能消费请求体。所以 Path<T>Query<T>State<S>Header<T> 这些提取器都实现 FromRequestParts

FromRequest<S, M> 定义在 axum-core/src/extract/mod.rs:79

rust
pub trait FromRequest<S, M = private::ViaRequest>: Sized {
    type Rejection: IntoResponse;
    fn from_request(
        req: Request,
        state: &S,
    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send;
}

参数类型是 req: Request——完整的请求,包含请求体的所有权。所以 Json<T>StringBytes 这些需要消费请求体的提取器实现 FromRequest

为什么分裂成两个 trait?因为请求体是一个异步流,只能被消费一次。如果两个提取器都试图消费请求体,就会产生数据竞争——在 Rust 的所有权模型里,这直接违反了"一个资源只有一个所有者"的规则。通过把"不消费请求体"和"消费请求体"分成两个 trait,Axum 在类型系统层面保证了一个 handler 里最多只有一个 FromRequest 参数,并且它必须在参数列表的最后一位。这不是文档约定——这是编译器强制检查的约束。

这两个 trait 的分裂还有更深层的工程含义。因为 FromRequestParts 只需要 &mut Parts,一个 handler 的多个 FromRequestParts 参数可以按任意顺序提取——它们互不干扰,也不消耗请求体。而 FromRequest 参数必须独占整个 Request,所以它只能有一个。当你写一个自定义提取器时,你应该问自己的第一个问题是:**这个提取器需要消费请求体吗?**如果不需要,实现 FromRequestParts——这样它就能和其他非请求体提取器自由组合。如果你错误地实现了 FromRequest,你的提取器就无法和 Json<T> 等请求体提取器共存于同一个 handler——编译器会报出一个晦涩的 Handler<T, S> not implemented 错误,因为一个 handler 不能有两个 FromRequest 参数。

第三个泛型参数 M 是一个私有标记类型,用于区分"默认的 FromRequest 实现"和"通过 #[derive(FromRequest)] 生成的实现"。private::ViaRequest 是默认值,表示"这个提取器通过 FromRequest 消费整个请求"。private::ViaParts 表示"这个提取器实际上只消费 Parts,但通过 FromRequest trait 暴露"——这是 Json<T> 等提取器的实现方式:它们在 FromRequest 的实现里先调用 FromRequestParts::from_request_parts 提取元数据,再消费请求体。这种三层标记系统的设计目标只有一个:让编译器的错误信息尽可能精确。当你的 handler 签名有误时,#[debug_handler] 宏能根据 M 的值给出"第 N 个参数消费了请求体,但它不是最后一个参数"这种精确提示,而不是一个泛泛的"trait not implemented"。

Axum 主动放弃了什么

每个设计选择都是取舍。Axum 拥抱 tower 生态的代价不是免费的——它意味着 Axum 必须在三个维度上做出明确的"放弃"。理解这三重放弃,你才能判断 Axum 是否适合你的场景。

放弃运行时独立性。 Axum 绑定 tokio。这不是技术上的必须——tower::Service 本身与运行时无关,hyper 也支持自定义执行器。但 Axum 的 serve 函数(axum/src/serve/mod.rs:103)直接使用 tokio 的 TcpListenertokio::spawnServe::with_graceful_shutdowntokio::select! 实现信号监听;WebSocket 升级依赖 tokio::sync::mpscde9f13d 这个 commit 刚刚引入的 Executor trait(axum/src/serve/mod.rs)允许你注入自定义执行器,但这个执行器仍然运行在 tokio 运行时的上下文里。如果你需要在 async-stdsmol 上跑 Axum——目前不支持,未来也不太可能支持。

actix-web 选择了运行时独立性——它有自己的 actix-rt,可以在不同的异步运行时上跑。但代价是它无法直接使用 tokio 生态的大量库(tokio::fstokio::net::UdpSockettokio::signal),需要通过 actix_web::web::blocktokio::task::spawn_blocking 来桥接。Axum 的选择是:既然 Rust 异步生态已经事实上统一在 tokio 之下——tokio 是 Rust 最成熟的异步运行时,绝大多数异步库都依赖它——那就直接绑上去,不要假装运行时无关。这种务实的态度贯穿了 Axum 的整个设计。

放弃协议独立性。 Axum 绑定 HTTP。Router<S> 实现的是 Service<Request<B>>,不是 Service<GenericRequest>。提取器从 http::request::Parts 里取数据,响应类型实现 IntoResponse(返回 http::Response<B>),中间件操作的是 HTTP 请求和响应。如果你想用 Axum 写一个 gRPC 服务、一个 WebSocket-only 服务、或一个自定义协议的服务——你需要自己处理协议适配层。

这不是疏漏。HTTP 是 Web 框架的核心领域,试图支持多种协议只会让 API 变得复杂。gRPC 有 tonic、WebSocket 有 tokio-tungstenite——它们不需要通过 Axum 来工作。Axum 的 WebSocket 支持也只是一个提取器(axum-extra 提供),而不是协议层的抽象。协议独立性的代价是:所有 API 都要加一层泛型抽象(Request<P> 里的 P 是协议类型),所有提取器都要处理协议差异(HTTP 的 header 在 gRPC 里叫 metadata),所有中间件都要写两套(HTTP 的一套、gRPC 的一套)。对于一个 Web 框架来说,这个代价远大于收益。

放弃自己的中间件系统。 这是三重放弃中最根本的。Axum 不定义 Middleware trait、不提供 MiddlewareStack 类型、不设计自己的中间件组合原语。所有中间件都是 tower Layer,所有中间件实例都是 tower Service

这个放弃的收益已经讨论过——与 tower 生态完全兼容,包括 tower-http 的 30+ 个中间件。但代价是:tower Servicecall(&mut self, req) 签名要求 &mut self,这意味着每个中间件实例不能被多个并发请求共享。Axum 通过 Clone 来解决这个问题——每个连接 clone 一份 Service 链。这个 clone 的开销取决于中间件栈的深度和每个中间件 Clone 的成本。对于绝大多数场景,Arc 共享 + 浅 clone 的开销可以忽略不计;但在极端性能敏感的场景下(百万 QPS、微秒级延迟),这个 clone 链可能成为瓶颈。

这个代价是可以量化的。假设你的中间件栈有 10 层,每个中间件的 Clone 成本是一次 Arc::clone(原子引用计数加一)。在 x86_64 上,一次 Arc::clone 大约 10 纳秒。10 层中间件乘以 10 纳秒等于 100 纳秒每连接。如果每秒接受 10 万个连接,clone 的总开销是 10 毫秒每秒——完全可以忽略。但如果中间件栈里有深拷贝(比如某中间件的 Clone 实现做了 Vec::clone),开销就会急剧上升。所以在 Axum 里写中间件时,Clone 的实现必须高效——优先用 Arc 共享不可变数据,避免深拷贝。

这三个放弃不是缺陷,而是设计约束。它们缩小了 Axum 的适用范围,但在这个范围内,Axum 做到了极致的简洁和极致的可组合性。正如 lib.rs 第 1-17 行所说的——"the barrier to writing an axum middleware is very low if you're already familiar with tower"。

从 Hyper+Tower 到 Axum:抽象的落地

如果你读过《Hyper 与 Tower》第 2-4 章,你应该已经理解了 ServiceLayerpoll_ready 这三个核心概念。该书第 22 章甚至展示了如何用 hyper::Server::builder(app).serve(app) 启动一个服务——那个 app 必须实现 MakeService,而 MakeServicepoll_readymake_service 是整个服务启动和连接接受的骨架。

Axum 做的事情,就是把你在《Hyper 与 Tower》里学到的抽象变成一个对 Web 开发者友好的 API。具体映射关系如下:

Hyper + Tower 概念Axum 中的形态源码位置
Service<Request<B>>Router<S> 实现此 traitrouting/mod.rs
MakeServiceServe 通过 into_make_service() 转换serve/mod.rs:103
Layer / ServiceBuilder.layer() 方法,直接使用 tower Layerrouting/mod.rs
poll_readyRouterInnerpoll_ready 始终返回 Readyrouting/mod.rs
Body / http_body::Bodyaxum::body::Body 是 type-erased 包装body/mod.rs
Request<Body>handler 通过 FromRequest / FromRequestParts 拆解axum-core/src/extract/mod.rs
Response<Body>handler 返回 impl IntoResponseaxum-core/src/response/mod.rs

你会在后续章节里反复看到这张表的每一行。第 2 章读 Router<S> 实现 Service 的代码;第 5 章读 Handler<T, S> 如何适配成 Service;第 9 章读 IntoResponse 如何把你的返回值变成 http::Response;第 15 章读 Serve 如何用 MakeService 为每个连接 clone 一个 Router

这张表里有一个值得注意的细节:RouterInnerpoll_ready 始终返回 Ready(Ok(()))。为什么?因为 Router 是无状态的——路由匹配是纯函数式的,不涉及任何需要等待的资源。与 tower::Buffer 这种需要等待缓冲区有空间的中间件不同,Router 永远就绪。但 poll_ready 的存在仍然有意义——它让 Router<S> 可以被塞进任何期望 Service 的中间件栈里,而不需要适配。这是 tower 生态的"接口统一"带来的好处:即使你不需要 poll_ready,你仍然实现了它,因为整个中间件链都建立在 Service trait 之上。

另一个值得注意的细节是 Serveaxum/src/serve/mod.rs:103 定义了 pub fn serve<L, M, S, B>(listener: L, make_service: M) -> Serve<...>。这个函数签名里的 M 必须实现 MakeService——这是 tower 定义的一个 trait,它的 make_service 方法为每个新连接 clone 出一个 Service 实例。在《Hyper 与 Tower》第 3 章里,你学过了 MakeService 的语义:它是一个"Service 工厂"——每次有新连接到来时,hyper 调用 make_service 获取一个该连接专属的 Service,然后用这个 Service 处理该连接上的所有请求。Axum 的 Router<S> 通过 IntoMakeService 适配了这个模式——IntoMakeServicemake_service 实现就是 clone 一下 Router,然后返回。这就是为什么 Router 必须实现 Clone——它不是可选的,而是 MakeService 模式的强制要求。

《Hyper 与 Tower》教了你抽象——"什么是 Service、为什么需要 poll_ready、Layer 如何组合"。这本书教你在这些抽象上构建一个可用的 Web 框架——"Router 怎么实现 Service、为什么 Router 的 poll_ready 总是 Ready、Handler 怎么变成 Service、serve 怎么用 MakeService 模式"。两本书合在一起,你既理解了"为什么这样设计",也看到了"这样设计怎么落地"。

这本书的旅程

这一章建立了一个宏观视角:Axum 的存在是为了解决"裸用 hyper 太底层"和"其他框架与 tower 不兼容"的问题,它的方法是完全拥抱 tower 生态、在此基础上追求极致的人体工学。

接下来的旅程是:从宏观到中观到微观。

第 2 章进入 Router<S> 的内部——看路由树如何构建、路径参数如何提取、.nest().merge() 如何组合路由。第 3 章进入 MethodRouter——看 get().post().delete() 的链式 API 如何实现、Allow 头如何自动生成。第 4 章看嵌套、合并、回退三种路由组合模式的实现细节。

第 5 章是整本书的枢纽——Handler<T, S> trait。你将看到 all_the_tuples! 宏展开后的 16 个元组实现,看到编译器如何根据你的 handler 签名选择正确的实现,看到 HandlerService 如何把 Handler 适配成 tower::Service

第 6-8 章深入提取器系统。第 9-12 章读响应和错误处理。第 13-14 章读中间件。第 15-18 章读运行时和状态。第 19-20 章读宏和扩展。第 21-22 章收尾于测试和生产实践。

但这一切的起点,是下一章的 Router<S>——我们开始看路由树的第一行代码。

基于 VitePress 构建