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,一行一个。