怎么读这一卷
主线位置:两条主线的总览。本页把装配主线和请求主线各画一遍,标出每一步落在哪个函数上;下一部分的「一个请求穿过 axum」用一个程序把两条线同时跑一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1[01 全景]
use1 --> use2[02 Handler 与提取器]
use2 --> use3[04 路由]
use3 --> use4[10 常见坑]
q -->|读懂实现| all[00 到 12 按顺序]
锁定版本
全卷只对着一组版本写:
| 项 | 值 |
|---|---|
| crate | axum 0.8.9,axum-core 0.5.6,axum-macros 0.5.1,tower-http 0.7.1 |
| tag | axum-v0.8.9(axum、axum-macros),axum-core-v0.5.6,tower-http-0.7.1 |
| commit | axum c59208c8(2026-04-06),axum-core e710a97a(2025-12-26),tower-http c941451 |
| 路由树 | matchit 0.8.4(axum 的 Cargo.toml 写死了 =0.8.4) |
| 实验用的底层 | hyper 1.12.0、hyper-util 0.1.21、tower 0.5.3、tokio 1.54.1,和前三卷相同 |
源码链接都用 tag,形如 https://github.com/tokio-rs/axum/blob/axum-v0.8.9/axum/src/...#L123;axum-core 的链接用 axum-core-v0.5.6,tower-http 的用 tower-http-0.7.1。matchit、serde_path_to_error 这些不在两个仓库里的依赖只写 crate 名和版本。
axum-core 为什么按 0.5.6
axum 和 axum-core 在同一个仓库里,按理 axum-v0.8.9 这个 tag 下的 axum-core/ 就是配套的版本。但这个 tag 的最后一个提交叫「revert axum-core changelog changes」,tag 里 axum-core/Cargo.toml 写的是 version = "0.5.5"。而 axum 对它的依赖是 axum-core = { path = "../axum-core", version = "0.5.5" },是个 ^0.5.5 的要求;crates.io 上已经有 0.5.6(2025-12-26 发布),Cargo 解析出来的就是它。你的项目里真正编译的 axum-core 是 0.5.6。
两份源码差得很少。git diff axum-v0.8.9 axum-core-v0.5.6 -- axum-core 是 6 个文件、增删各 29 行,其中 Cargo.toml、CHANGELOG.md、LICENSE 占了大头;src/ 下只有三个文件不同:0.5.6 在 option.rs、request_parts.rs 里多了 4 行 #[allow(clippy::use_self)],tag 里的 from_ref.rs 文档多一个指向「Sharing state with handlers」的链接。没有一行行为上的差别。本卷讲 axum-core 时行号一律从 axum-core-v0.5.6 取。
仓库的 main 已经走过了 tag
写作时 axum 仓库的 main 比 axum-v0.8.9 多 298 个提交,其中有几个直接改了本卷要讲的东西:去掉 0.7 路径语法检查(#3889,「remove v07 path checks」)、把 tower-log 从默认 feature 里拿掉(#3928)、合并进来的服务路由不再多包一层 Route(#3909)。这些都不在本卷的版本里,读 main 会看到对不上的代码和行号。
本站自己锁的是 axum 0.8.9、axum-core 0.5.6,和本卷相同;tower-http 锁的是 0.6.11,比本卷旧一个大版本,差异放在「在本站中」一部分讲。
两条主线
axum 的工作分在两个时刻:启动时把一堆 async fn 装配成一棵 tower 服务树,运行时把每个 Request 送进这棵树。全卷沿这两条线展开,每页开头都会标出本页在哪条主线上。
装配主线(启动时,类型在变):
flowchart TB f["async fn handler"] --> h["impl Handler T S"] h --> mr["get:MethodRouter S"] mr --> rt["Router::route:插进 matchit"] rt --> ly["Router::layer:套中间件"] ly --> ws["with_state:Router S 变 Router"] ws --> sv["axum::serve"]
- 一个
async fn本身不实现任何 axum 的 trait。impl_handler!由all_the_tuples!展开,为「参数都是提取器、返回值实现IntoResponse」的函数实现Handler<T, S>。T是参数类型组成的标记,S是状态类型。见 Handler trait。 get(handler)调用MethodRouter::on,把 handler 包成BoxedIntoRoute(擦掉具体类型、但还没拿到状态)放进get这个方法槽,同时记下Allow头要写的GET,HEAD。Router::route校验路径,给它分配一个RouteId,把「路径 →RouteId」插进 matchit 的路由树,「RouteId→ 端点」放进一张HashMap。路径冲突在这一步 panic。Router::layer给已经加进来的每个端点和 fallback 套上一个 towerLayer。已经是服务的端点当场套上;还欠着状态的 handler 只把「怎么套」记下来,等拿到状态再套。Router::with_state把状态交给每个方法槽:BoxedIntoRoute变成Route,也就是一个BoxCloneSyncService;Router<S>变成Router<()>。只有Router<()>实现了Service<Request>,才能交给axum::serve。axum::serve的 accept 循环、hyper-util 的auto::Builder、TowerToHyperService都在 hyper 卷讲过,见 hyper-util 的 auto 和 本站的服务端路径。
请求主线(每个请求,值在流动):
flowchart TB rc["Router::call"] --> pr["PathRouter:matchit 匹配"] pr -->|没匹配上| fb["fallback:默认 404"] pr -->|匹配上| mrc["MethodRouter:按方法选"] mrc -->|没有这个方法| e405["405,带 Allow"] mrc --> route["Route:装箱的服务"] route --> hc["Handler::call"] hc --> ex["提取器依次执行"] ex -->|失败| rej["rejection 转成响应"] ex --> fn["调用你的函数"] fn --> ir["into_response"]
Router<()>的call把 body 统一成axum::body::Body,进call_with_state。PathRouter::call_with_state用 matchit 匹配路径:匹配上,就把路径参数存进请求的 extensions(UrlParams,Path提取器之后从这里取),开着matched-path时再存一份MatchedPath;没匹配上,交给 fallback,默认是 404。MethodRouter::call_with_state按方法挑方法槽,HEAD没有专门的端点就用GET的;没有对应的方法,走方法级的 fallback,默认回405 Method Not Allowed并带上Allow头。- 选中的
Route克隆一份,用 tower 的oneshot调用。里面最内层是HandlerService,它的call克隆 handler 和状态,调用Handler::call。 Handler::call把请求拆成Parts和 body,前 n-1 个参数依次跑FromRequestParts(只能看Parts),最后一个参数跑FromRequest(可以拿走 body)。任何一个失败,就把它的 rejectioninto_response()直接返回,后面的提取器和你的函数都不会执行。见 FromRequestParts 与 FromRequest。- 你的函数返回后,返回值
into_response()成Response,沿原路交回 hyper。见 IntoResponse。
两条线的交点是 Route:装配主线的终点是把每个端点都变成 Route,请求主线在路由之后调用的也正是它。.layer 套上的中间件包在 Route 外面,所以它们在路由之后、提取器之前执行。
阅读路线
- 只想会用:读 01 全景、02 Handler 与提取器、04 路由,以及常见坑。读完能看懂
Handler<_, _>不满足这类报错从哪来,知道状态、提取器、中间件各在哪一步起作用。 - 想读懂实现:按顺序读。02 是重点,
Handler的宏实现、两种提取器 trait、State的类型参数都在那里;04、05 讲路由树和中间件怎么包在Route外面;09 把各章的取舍汇总。
摘自 axum、axum-core、tower-http 的代码片段一律保留原来的格式(4 空格缩进),方便和原文件对照;本站自己写的示例用 2 空格缩进。每个示例上方写出它需要的依赖和 feature,只开用到的那几个。实验的平台是 M2 Max、rustc 1.99.0。
前置知识
这一卷默认你会写 async Rust,也写过一两个 axum 的 hello world。axum 的大部分机制直接建在 tower 和 hyper 上,用到的东西在前几卷讲过,这里只链过去:
| 概念 | 讲它的页 | 本卷用到它的地方 |
|---|---|---|
tower 的 Service 与 poll_ready | Service trait | Router、MethodRouter、Route、HandlerService 都是 Service,poll_ready 一律直接返回 Ready |
Layer | Layer 与 Stack | Router::layer、from_fn |
| 层的顺序 | ServiceBuilder、层的顺序反了 | 连续调用 .layer 时,后加的在外层 |
BoxCloneSyncService | 类型擦除 | Route 就是它,Router 因此能装任意 handler、还能克隆 |
oneshot | ServiceExt 与 Oneshot | Route 每次调用都是一次 oneshot;测试里直接 router.oneshot(req) |
MakeService | Steer 与 MakeService | axum::serve 每条连接造一个服务 |
| hyper 服务端主线 | 怎么读 hyper 卷、Service 与 Body | TowerToHyperService 把 Router 交给 hyper,每个请求克隆一次服务 |
axum 仓库的 examples/ 有 50 多个完整的示例程序,crate 文档本身也很详细(它的大部分内容来自 src/docs/ 下的 markdown,见源码地图)。读源码之前先照着写一个带状态和提取器的小程序,会轻松很多。
目录
| 部分 | 内容 |
|---|---|
| 00 导读 | 本页;源码地图 |
| 01 全景 | axum 解决什么问题;一个请求穿过 axum |
| 02 Handler 与提取器 | 本卷重点:Handler trait、两种提取器 trait、State 与 FromRef、Path 与 Query、body 提取器 |
| 03 响应 | IntoResponse 与 IntoResponseParts;SSE 与 Redirect |
| 04 路由 | Router 的内部、matchit、MethodRouter、nest 与 merge、Route 与装箱 |
| 05 中间件 | layer 与 route_layer;from_fn |
| 06 tower-http | ServeDir、压缩,以及超时、body 限制、CORS、Trace、CatchPanic |
| 07 WebSocket | 升级握手与 WebSocket |
| 08 测试 | 不起服务器,用 oneshot 测 Router |
| 09 设计取舍 | 把各章的取舍汇总到一起 |
| 10 常见坑 | 用报错原文做标题,讲它在源码里怎么产生 |
| 11 在本站中 | 本站(开源)代码里怎么用 axum,tower-http 0.6.11 与 0.7.1 的差异 |
| 12 动手 | 不用 axum,从零写一个最小的 Handler trait 和 Router |
小结
- 全卷锁定 axum
axum-v0.8.9(c59208c8)、axum-coreaxum-core-v0.5.6(e710a97a)、tower-httptower-http-0.7.1(c941451);main 已经走过 tag 298 个提交,行号以 tag 为准。 - tag 里的 axum-core 版本号是 0.5.5,实际编译的是 0.5.6;两者的
src/只差 4 行 lint 属性和一个文档链接。 - 装配主线:
async fn→Handler→get放进MethodRouter的方法槽 →route插进 matchit →layer→with_state把方法槽变成Route,Router<S>变Router<()>→axum::serve。 - 请求主线:
Router::call→ matchit 匹配、存路径参数 → 按方法选端点(没有就 405)→Route的oneshot→ 提取器依次执行 → 你的函数 →into_response。 .layer包在Route外面:中间件在路由之后、提取器之前执行。- 只想会用读 01、02、04 和常见坑;想读懂实现按顺序读,02 是重点。