跳到主要内容
预计阅读 31 分钟

用Axum构建Web API —— 从零到一搭建后端服务

学了这么多Rust基础知识,是时候用它做点”看得见、摸得着”的东西了。在这一章里,我们要用Axum框架从零开始搭建一个RESTful API——一个简单但完整的待办事项(Todo)服务。你会看到Rust在Web开发领域是多么高效和优雅。

📋 开篇自测:你已经知道多少?

  1. 你知道RESTful API的基本概念吗?GET、POST、PUT、DELETE分别用于什么?
  2. 你了解HTTP请求和响应的基本结构吗?
  3. 你用过其他语言的Web框架吗(如Express、Flask、Spring Boot)?

一、为什么选Axum?

Rust生态中有几个主流的Web框架:

框架特点
Actix-web老牌框架,性能极高,功能丰富
AxumTokio团队出品,设计现代,和Tokio生态深度集成
Rocket对开发者最友好,API设计优雅
Warp基于Filter组合的函数式风格

我们选择Axum,因为:

  • 它由Tokio团队维护,和Rust异步生态完美兼容
  • 设计理念现代,利用Rust的类型系统实现零成本抽象
  • 错误信息友好,新手容易上手
  • 社区活跃,更新迅速

二、项目初始化——搭建骨架

2.1 创建项目

cargo new todo_api
cd todo_api

2.2 添加依赖

编辑Cargo.toml

[package]
name = "todo_api"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"

各依赖的作用:

  • axum:Web框架本体
  • tokio:异步运行时
  • serde:序列化/反序列化(把Rust结构体和JSON互转)
  • uuid:生成唯一ID
  • tower-http:HTTP中间件(跨域支持等)
  • tracing:日志系统

2.3 最简单的Hello World服务器

use axum::{routing::get, Router};

async fn hello() -> &'static str {
    "Hello, Axum!"
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("服务器启动在 http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

运行cargo run,在浏览器打开http://localhost:3000,你就能看到”Hello, Axum!”。三十行代码,一个Web服务器就跑起来了!

🤔 想一想 Axum的handler函数就是一个普通的async函数。和Spring Boot的@GetMapping注解方式相比,哪种设计你更喜欢?各有什么优缺点?


三、设计Todo API——数据模型与路由

3.1 定义数据模型

use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
    pub id: String,
    pub title: String,
    pub description: Option<String>,
    pub completed: bool,
}

#[derive(Debug, Deserialize)]
pub struct CreateTodoRequest {
    pub title: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateTodoRequest {
    pub title: Option<String>,
    pub description: Option<String>,
    pub completed: Option<bool>,
}

impl Todo {
    pub fn new(title: String, description: Option<String>) -> Self {
        Todo {
            id: Uuid::new_v4().to_string(),
            title,
            description,
            completed: false,
        }
    }
}

#[derive(Serialize, Deserialize)]让serde自动帮我们处理JSON的序列化和反序列化——再也不用手动解析JSON了。

3.2 应用状态管理

我们用内存存储来简化,用Arc<Mutex<Vec<Todo>>>在多个请求之间共享数据:

use std::sync::{Arc, Mutex};

// 这里使用 std::sync::Mutex 而非 tokio::sync::Mutex。
// 因为我们的 handler 在持有锁期间不会跨越 .await 点(锁在同一个代码块内获取和释放),
// 所以 std::sync::Mutex 就够了,而且它不需要 .await 来获取锁,开销更小。
// 如果你需要在持有锁的同时做异步操作(如数据库查询),则应改用 tokio::sync::Mutex。
type TodoStore = Arc<Mutex<Vec<Todo>>>;

fn create_store() -> TodoStore {
    Arc::new(Mutex::new(Vec::new()))
}

3.3 设计路由

use axum::{routing::get, Router};

fn create_router(store: TodoStore) -> Router {
    Router::new()
        .route("/todos", get(list_todos).post(create_todo))
        .route(
            "/todos/{id}",
            get(get_todo).put(update_todo).delete(delete_todo),
        )
        .with_state(store)
}
// 注意:这里只导入了 routing::get,而 .post()、.put()、.delete() 是
// MethodRouter 上的链式方法,不需要单独导入。如果你偏好每种方法独立定义路由,
// 也可以 use axum::routing::{get, post, put, delete} 然后用 post(handler) 等。

REST风格的路由设计:

  • GET /todos → 获取所有待办
  • POST /todos → 创建新待办
  • GET /todos/{id} → 获取指定待办
  • PUT /todos/{id} → 更新指定待办
  • DELETE /todos/{id} → 删除指定待办

四、实现Handler——业务逻辑

4.1 获取所有待办

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};

async fn list_todos(
    State(store): State<TodoStore>,
) -> Json<Vec<Todo>> {
    let todos = store.lock().unwrap();
    Json(todos.clone())
}

Axum的”提取器”(Extractor)是一个很巧妙的设计——通过函数参数的类型来自动提取请求中的数据。State(store)自动从应用状态中提取TodoStore,Json(...)自动把返回值序列化为JSON。

4.2 创建待办

async fn create_todo(
    State(store): State<TodoStore>,
    Json(input): Json<CreateTodoRequest>,
) -> (StatusCode, Json<Todo>) {
    let todo = Todo::new(input.title, input.description);
    let mut todos = store.lock().unwrap();
    todos.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

Json(input)会自动把请求体的JSON反序列化为CreateTodoRequest结构体。如果JSON格式不对,Axum会自动返回400错误。

4.3 获取单个待办

async fn get_todo(
    State(store): State<TodoStore>,
    Path(id): Path<String>,
) -> Result<Json<Todo>, StatusCode> {
    let todos = store.lock().unwrap();
    match todos.iter().find(|t| t.id == id) {
        Some(todo) => Ok(Json(todo.clone())),
        None => Err(StatusCode::NOT_FOUND),
    }
}

Path(id)从URL路径中提取参数。返回Result让我们可以返回不同的HTTP状态码。

4.4 更新待办

async fn update_todo(
    State(store): State<TodoStore>,
    Path(id): Path<String>,
    Json(input): Json<UpdateTodoRequest>,
) -> Result<Json<Todo>, StatusCode> {
    let mut todos = store.lock().unwrap();
    match todos.iter_mut().find(|t| t.id == id) {
        Some(todo) => {
            if let Some(title) = input.title {
                todo.title = title;
            }
            if let Some(desc) = input.description {
                todo.description = Some(desc);
            }
            if let Some(completed) = input.completed {
                todo.completed = completed;
            }
            Ok(Json(todo.clone()))
        }
        None => Err(StatusCode::NOT_FOUND),
    }
}

4.5 删除待办

async fn delete_todo(
    State(store): State<TodoStore>,
    Path(id): Path<String>,
) -> StatusCode {
    let mut todos = store.lock().unwrap();
    let len_before = todos.len();
    todos.retain(|t| t.id != id);

    if todos.len() < len_before {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

五、完整代码与运行

5.1 完整的main.rs

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use uuid::Uuid;

// ===== 数据模型 =====

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
    id: String,
    title: String,
    description: Option<String>,
    completed: bool,
}

#[derive(Debug, Deserialize)]
struct CreateTodoRequest {
    title: String,
    description: Option<String>,
}

#[derive(Debug, Deserialize)]
struct UpdateTodoRequest {
    title: Option<String>,
    description: Option<String>,
    completed: Option<bool>,
}

impl Todo {
    fn new(title: String, description: Option<String>) -> Self {
        Todo {
            id: Uuid::new_v4().to_string(),
            title,
            description,
            completed: false,
        }
    }
}

type TodoStore = Arc<Mutex<Vec<Todo>>>;

// ===== Handler函数 =====

async fn list_todos(State(store): State<TodoStore>) -> Json<Vec<Todo>> {
    let todos = store.lock().unwrap();
    Json(todos.clone())
}

async fn create_todo(
    State(store): State<TodoStore>,
    Json(input): Json<CreateTodoRequest>,
) -> (StatusCode, Json<Todo>) {
    let todo = Todo::new(input.title, input.description);
    let mut todos = store.lock().unwrap();
    todos.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

async fn get_todo(
    State(store): State<TodoStore>,
    Path(id): Path<String>,
) -> Result<Json<Todo>, StatusCode> {
    let todos = store.lock().unwrap();
    todos
        .iter()
        .find(|t| t.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn update_todo(
    State(store): State<TodoStore>,
    Path(id): Path<String>,
    Json(input): Json<UpdateTodoRequest>,
) -> Result<Json<Todo>, StatusCode> {
    let mut todos = store.lock().unwrap();
    match todos.iter_mut().find(|t| t.id == id) {
        Some(todo) => {
            if let Some(title) = input.title {
                todo.title = title;
            }
            if let Some(desc) = input.description {
                todo.description = Some(desc);
            }
            if let Some(completed) = input.completed {
                todo.completed = completed;
            }
            Ok(Json(todo.clone()))
        }
        None => Err(StatusCode::NOT_FOUND),
    }
}

async fn delete_todo(
    State(store): State<TodoStore>,
    Path(id): Path<String>,
) -> StatusCode {
    let mut todos = store.lock().unwrap();
    let len_before = todos.len();
    todos.retain(|t| t.id != id);
    if todos.len() < len_before {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

async fn health_check() -> &'static str {
    "OK"
}

// ===== 主函数 =====

#[tokio::main]
async fn main() {
    // 初始化日志
    tracing_subscriber::fmt::init();

    let store: TodoStore = Arc::new(Mutex::new(Vec::new()));

    let app = Router::new()
        .route("/health", get(health_check))
        .route("/todos", get(list_todos).post(create_todo))
        .route(
            "/todos/{id}",
            get(get_todo).put(update_todo).delete(delete_todo),
        )
        .with_state(store);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    tracing::info!("服务器启动在 http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

5.2 测试API

启动服务器后,用curl测试:

# 健康检查
curl http://localhost:3000/health

# 创建待办
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"学习Rust","description":"完成前三章"}'

# 获取所有待办
curl http://localhost:3000/todos

# 更新待办(替换{id}为实际的ID)
curl -X PUT http://localhost:3000/todos/{id} \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'

# 删除待办
curl -X DELETE http://localhost:3000/todos/{id}

🤔 想一想 我们用Arc<Mutex<Vec<Todo>>>做内存存储。在生产环境中,这有什么问题?(提示:服务器重启后数据就没了,而且Mutex在高并发下可能成为瓶颈。)


六、添加中间件与错误处理

6.1 添加CORS支持

use tower_http::cors::{CorsLayer, Any};

// 注意:allow_origin(Any) 仅适用于开发环境。
// 生产环境应指定允许的域名,如 .allow_origin("https://yourdomain.com".parse().unwrap())
let cors = CorsLayer::new()
    .allow_origin(Any)
    .allow_methods(Any)
    .allow_headers(Any);

let app = Router::new()
    // ... routes ...
    .layer(cors)
    .with_state(store);

6.2 统一错误处理

use axum::response::{IntoResponse, Response};

#[derive(Debug)]
enum ApiError {
    NotFound(String),
    BadRequest(String),
    InternalError(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            ApiError::InternalError(msg) => {
                (StatusCode::INTERNAL_SERVER_ERROR, msg)
            }
        };

        let body = serde_json::json!({
            "error": message
        });

        (status, Json(body)).into_response()
    }
}

这样你的handler可以返回Result<Json<T>, ApiError>,错误会自动转换为格式统一的JSON响应。

⚠️ 常见误区

  1. 在handler中长时间持有Mutex锁——Mutex的lock()返回的MutexGuard跨越了await点会导致问题(MutexGuard不是Send的)。解决方案:在一个代码块内完成所有锁操作,或者使用tokio::sync::Mutex
  2. 忘了处理JSON解析错误——当客户端发送格式错误的JSON时,Axum默认返回的错误信息不够友好。可以自定义JSON提取器来改善。
  3. 不加健康检查端点——生产环境中的服务都需要一个/health端点,让负载均衡器和监控系统检查服务是否正常。
  4. 在生产环境使用内存存储——内存存储仅适合原型开发。下一章我们会讲如何接入真正的数据库。

📝 掌握度自测

  1. 基础概念:Axum的”提取器”(Extractor)是什么?StatePathJson各提取什么数据?
  2. 路由设计:如果要添加一个”按标题搜索待办”的功能(GET /todos/search?keyword=xxx),路由和handler该怎么写?
  3. 状态管理:为什么需要用Arc包裹Mutex?如果不用Arc会怎样?
  4. 错误处理:handler返回Result<Json<Todo>, StatusCode>时,不同的StatusCode会导致什么HTTP响应?
  5. 实践扩展:给Todo添加一个created_at字段(使用chrono库),在创建时自动记录时间。

💡 自我评估

  • 答对5题:你已经具备用Rust构建Web API的能力了。
  • 答对3-4题:核心流程已经掌握,建议动手把完整代码跑起来,用curl或Postman测试。
  • 答对0-2题:Web开发涉及多个概念的交叉应用。建议先把完整代码跑通,然后逐个handler理解它的工作方式。

购买课程解锁全部内容

内存安全 + 零成本抽象:Rust 系统编程实战

¥29.90