楼层: 首页/ 软件技术/ Rust 后端技术栈/ Axum Web 框架实战
02

Axum Web 框架实战

Axum · Router, Extractor, Middleware

Axum 就是 Rust 世界的 Spring Boot。它的核心理念就一句话:函数参数就是请求的数据——你想从 URL 拿参数,就把函数签名写成 Path(id);想拿 JSON body,就写 Json<T>。这叫"提取器(Extractor)",比 Spring 的注解式 @PathVariable 更优雅,因为类型系统直接替你校验。

创建项目与依赖

cargo new rust-todo-api cd rust-todo-api

Cargo.toml

[dependencies] axum = { version = "0.8", features = ["macros", "ws"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "limit"] } anyhow = "1" thiserror = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] }

路由:Router + get/post/put/delete

Axum 0.7 以后路径参数从 :id 改成了 {id},网上很多老教程还在写冒号,抄的时候注意。下面是一个完整的最小服务器。

// src/main.rs —— 最小 Axum 服务器 use axum::{ routing::{get, post}, Router, Json, }; use serde::Serialize; #[derive(Serialize)] struct Hello { msg: String } // handler 就是普通 async 函数 async fn hello() -> Json<Hello> { Json(Hello { msg: "你好,Rust!".into() }) } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); // 路由树:Router::new() 像搭积木一样拼接 let app = Router::new() .route("/", get(hello)) .route("/api/health", get(|| async { "ok" })) .route("/api/echo", post(|body: String| async move { body })); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; tracing::info!("监听 http://{}", listener.local_addr()?); axum::serve(listener, app).await?; Ok(()) }
$ cargo run INFO 监听 http://0.0.0.0:3000 $ curl http://localhost:3000/ {"msg":"你好,Rust!"}

提取器 Extractor:Axum 的灵魂

论什么是提取器

Axum 收到一个 HTTP 请求,要把里面的东西"拆"成 Rust 类型交给你的 handler。这个拆的过程就是提取器。你 handler 的参数列表里放什么类型,Axum 就替你拆什么——这是 Axum 最优雅的设计。

顺序铁律:提取器有"消费 body"的和"不消费 body"的区别。能读 body 的提取器(Json、Form、Bytes、Multipart)只能有一个,而且必须放最后,因为 body 只能读一次。不碰 body 的(Path、Query、State、HeaderMap)随便放前面。

Path / Query / Json / State 四种常用提取器

use axum::{ extract::{Path, Query, State}, routing::{get, post}, Router, Json, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; // 共享状态:通常包成 Arc,全局只有一份 #[derive(Clone)] struct AppState { db_url: String, } // GET /users/42 —— 路径参数 async fn get_user(Path(id): Path<u64>) -> String { format!("用户 {id}") } // GET /search?q=rust&page=1 —— Query 参数自动反序列化 #[derive(Deserialize)] struct SearchReq { q: String, page: Option<u32> } async fn search(Query(p): Query<SearchReq>) -> String { format!("搜索 {} 第 {} 页", p.q, p.page.unwrap_or(1)) } // POST /users —— JSON body #[derive(Deserialize, Serialize)] struct CreateUser { name: String, age: u8 } async fn create_user( State(state): State<Arc<AppState>>, // State 不碰 body,放前面 Json(body): Json<CreateUser>, // Json 消费 body,必须最后 ) -> Json<CreateUser> { println!("连接到 {},创建 {}", state.db_url, body.name); Json(body) } fn router() -> Router { let state = Arc::new(AppState { db_url: "postgres://localhost".into() }); Router::new() .route("/users/{id}", get(get_user)) .route("/search", get(search)) .route("/users", post(create_user)) .with_state(state) }

状态共享:with_state + State 提取器

数据库连接池、配置、缓存这些全局对象,不能每次请求都新建。Axum 的做法是:构造一个 AppState,在 Router 上 .with_state(state),然后在 handler 里用 State(s): State<AppState> 取出来。注意 AppState 必须 Clone,Axum 会在每个请求里 clone 一份(通常只是 Arc 指针拷贝,很便宜)。

中间件:tower-http 全家桶

Axum 本身很薄,中间件全靠 tower-http。常用的几个:CorsLayer 跨域、TraceLayer 访问日志、CompressionLayer gzip 压缩、TimeoutLayer 超时、RequestBodyLimitLayer 限制 body 大小。

use axum::Router; use tower_http::{ cors::{CorsLayer, Any}, trace::TraceLayer, compression::CompressionLayer, timeout::TimeoutLayer, }; use std::time::Duration; fn build_app() -> Router { Router::new() // ... 路由 ... .layer(TraceLayer::new_for_http()) // 访问日志 .layer(CompressionLayer::new()) // gzip .layer(TimeoutLayer::new(Duration::from_secs(10))) .layer( CorsLayer::new() .allow_origin(Any) // 生产环境换成具体域名 .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) .allow_headers(Any) ) }

错误处理:thiserror + IntoResponse

Axum 的 handler 必须返回实现了 IntoResponse 的类型。你定义一个业务错误枚举,然后手动给它实现 IntoResponse,把它翻译成 HTTP 状态码 + JSON 错误体。库层错误用 thiserror,应用层错误用 anyhow——这是 Rust 圈的惯例。

use axum::{http::StatusCode, response::IntoResponse, Json}; use serde_json::json; use thiserror::Error; #[derive(Debug, Error)] pub enum AppError { #[error("用户不存在")] NotFound, #[error("参数校验失败: {0}")] BadRequest(String), #[error(transparent)] Internal(#[from] anyhow::Error), // anyhow 自动转换 } // 把错误翻译成 HTTP 响应 impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let (status, msg) = match self { AppError::NotFound => (StatusCode::NOT_FOUND, "未找到".into()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "服务器开小差".into()), }; (status, Json(json!({ "error": msg }))).into_response() } } // handler 里就可以这么写: async fn get_todo(Path(id): Path<u64>) -> Result<Json<serde_json::Value>, AppError> { if id == 0 { return Err(AppError::NotFound); } Ok(Json(json!({ "id": id, "title": "学 Axum" }))) }

实战:任务管理 CRUD 一把梭

把上面的零件拼起来:内存存储的 Todo 列表,支持增删改查。代码不复杂,但涵盖了路由、提取器、状态、错误处理、JSON 响应全部核心。

// src/main.rs —— Todo CRUD use axum::{ extract::{Path, State}, routing::{get, post, put, delete}, Router, Json, http::StatusCode, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize)] struct Todo { id: u64, title: String, done: bool } // 用 std::sync::Mutex 包内存数据——这里不碰 async,用 std 的就行 #[derive(Clone, Default)] struct Db { items: Arc<Mutex<Vec<Todo>>>, next_id: Arc<Mutex<u64>>, } async fn list(State(db): State<Db>) -> Json<Vec<Todo>> { Json(db.items.lock().unwrap().clone()) } async fn create( State(db): State<Db>, Json(mut body): Json<Todo>, ) -> (StatusCode, Json<Todo>) { let mut id = db.next_id.lock().unwrap(); *id += 1; body.id = *id; body.done = false; db.items.lock().unwrap().push(body.clone()); (StatusCode::CREATED, Json(body)) } async fn toggle( Path(id): Path<u64>, State(db): State<Db>, ) -> Result<Json<Todo>, StatusCode> { let mut items = db.items.lock().unwrap(); let t = items.iter_mut().find(|t| t.id == id) .ok_or(StatusCode::NOT_FOUND)?; t.done = !t.done; Ok(Json(t.clone())) } async fn remove(Path(id): Path<u64>, State(db): State<Db>) -> StatusCode { let mut items = db.items.lock().unwrap(); let len = items.len(); items.retain(|t| t.id != id); if items.len() < len { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } #[tokio::main] async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/todos", get(list).post(create)) .route("/todos/{id}/toggle", put(toggle)) .route("/todos/{id}", delete(remove)) .with_state(Db::default()); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; Ok(()) }
$ curl -X POST localhost:3000/todos -H 'Content-Type: application/json' \ -d '{"id":0,"title":"学Axum","done":false}' {"id":1,"title":"学Axum","done":false} $ curl localhost:3000/todos [{"id":1,"title":"学Axum","done":false}]

静态文件与 WebSocket

除了 JSON API,Axum 还能直接服务静态文件和 WebSocket。静态文件用 ServeDir,WebSocket 用 WebSocketUpgrade 提取器。

use axum::{ extract::ws::{WebSocketUpgrade, WebSocket, Message}, response::IntoResponse, routing::get, Router, }; use tower_http::services::ServeDir; // WebSocket:连接建立后 Echo async fn ws_echo(ws: WebSocketUpgrade) -> impl IntoResponse { ws.on_upgrade(|mut socket| async move { while let Some(Ok(msg)) = socket.recv().await { if let Message::Text(t) = msg { let _ = socket.send(Message::Text(t)).await; } } }) } let app = Router::new() .route("/ws", get(ws_echo)) .nest_service("/static", ServeDir::new("assets"));

自定义提取器:FromRequestParts

Axum 最强大的地方是你可以写自己的提取器。比如从 Authorization header 里解出 JWT,验证后把 User 直接注入 handler——业务代码完全不用关心鉴权细节。

use axum::{extract::Request, http::header, response::Response}; use axum::extract::FromRequestParts; use axum::http::request::Parts; // 自定义提取器:登录用户 pub struct AuthUser(pub u64); #[axum::async_trait] impl FromRequestParts<()> for AuthUser { type Rejection = (axum::http::StatusCode, String); async fn from_request_parts( parts: &Parts, _state: &(), ) -> Result<Self, Self::Rejection> { let token = parts.headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or((axum::http::StatusCode::UNAUTHORIZED, "缺 token".into()))?; // ... 验签、解析 user_id ... Ok(AuthUser(42)) } } // handler 里直接用,业务代码零鉴权代码 async fn me(AuthUser(uid): AuthUser) -> String { format!("当前用户 {uid}") }
坑:提取器顺序错了直接编译错误

Axum 0.8 对提取器顺序检查很严:Json<T> 这种消费 body 的必须是最后一个参数,且整个 handler 里只能有一个。如果你把 State 放到 Json 后面,编译器会报一坨 trait bound 错误。记住:State、Path、Query 在前,Json/Form/Bytes 在后。

记
本章小结

① Axum 三件套:Router 拼路由、提取器拆请求、State 共享状态。

② 错误处理用 thiserror 定义枚举 + impl IntoResponse,handler 返回 Result<Json<T>, AppError>。

③ 中间件交给 tower-http:CorsLayer、TraceLayer、CompressionLayer、TimeoutLayer,一行一个。