学习Rust:实现RESTful 任务管理 API(Todo API)

导读 :为了帮助大家从零开始掌握Rust,我启动了 rust-learning-example 开源项目。本章我们将挑战一个经典的Web开发任务:使用 Rust 生态中最强组合 Axum + SQLx,打造一个具备 CRUD、数据库持久化、分页查询及统一错误处理的 RESTful API。
🔗 项目源码GitHub地址 (feat-axum_03分支)

我们最终将实现以下接口:

  • GET /todos?page=1&size=10:分页查询
  • GET /todos/:id:详情查询
  • POST /todos:创建任务(带校验)
  • PUT /todos/:id:更新状态
  • DELETE /todos/:id:删除数据

🛠️ 01. 项目初始化

首先创建一个新项目

Base 复制代码
cargo new axum_todo
cd axum_todo

我们需要在Cargo.toml中引入"全家桶"。为了让大家看懂每个库的作用,我加了详细注释:

Toml 复制代码
[dependencies]
# Web框架
axum = "0.8.8"

# 时间处理库,弥补了标准的std::time的功能不足(如缺少时区支持、灵活的时间解析/格式化)
# serde 特性,让结构体中直接使用时间类型可以进行序列化/反序列化
chrono = { version = "0.4.43", features = ["serde"] }

# 序列化标准框架(定义"怎么转"的规范),derive是它的一个辅助特性(提供宏来自动神抽狗序列化/反序列化代码)
serde = { version = "1.0.228", features = ["derive"] }

# 基于serde定义的Serializa/Deserialize特性,来对数据进行JSON转换的"标准实现"
serde_json = "1.0.149"

# sqlx是Rust生态中异步、类型安全的SQL工具库
# runtime-tokio特性:为sqlx提供异步运行时支持
# sqlite: 启用SQLite数据库驱动与适配
# chrono: 时间类型与数据库的自动映射
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono"] }

# Rust是 Rust 生态的事实标准异步运行时(负责调度异步任务、处理 IO 事件、管理线程池)
# full是 tokio 的全量特性集
tokio = { version = "1.49.0", features = ["full"] }

# HTTP中间件集合库,快速增强HTTP服务的功能(如日志、身份验证、限流、CORS 等)
# trace特性:自动记录每个请求的关键信息的特性
tower-http = { version = "0.6.8", features = ["trace"] }
# tracing是 Rust 中结构化日志、事件追踪与分布式链路追踪的核心库,理解为日志生产者
tracing = "0.1.44"
# 接收日志数据、格式化日志、输出的一个日志处理器
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }

🏗️ 02. 架构设计

为了拒绝"面条代码",我们将项目结构化,职责分离:

Text 复制代码
src/
├── error.rs    # 统一错误处理(核心)
├── models.rs   # 数据库实体与DTO
├── handlers.rs # 业务逻辑控制器
└── main.rs     # 服务入口与路由组装

03. 优雅的错误处理

这是生产级代码的关键!

我们不希望在每个handler里处理错误,而是定义一个统一的AppError。通过实现IntoResponse,让错误自动转化为HTTP响应。

rust 复制代码
// src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use tracing;

#[derive(Debug)]
pub enum AppError {
    Sqlx(sqlx::Error),      // 数据库错误
    NotFound,               // 资源未找到错误
    ValidationError(String),// 验证错误
}

// From trait是【类型转换】的标准接口,实现From<A> for B后,
// 可以通过?操作符将A类型自动转换为B类型
// 允许直接使用?将sqlx错误转换为AppError
impl From<sqlx::Error> for AppError {
    fn from(inner: sqlx::Error) -> Self {
        AppError::Sqlx(inner)
    }
}

// Axum要求路由handler的返回值必须实现IntoResponse trait, 这里实现该trait
// 让AppError能自动转换为标准HTTP响应
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, msg) = match self {
            // 处理数据库错误
            AppError::Sqlx(e) => {
                // 记录详细的日志到服务器
                tracing::error!("Database error:{:?}", e);
                // 避免暴露数据库地址、SQL语句、表结构等敏感信息,这里只返回internal server error
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error".to_string())
            },
            // 处理资源未找到
            AppError::NotFound => (StatusCode::NOT_FOUND, "Resource not found".to_string()),
            // 处理验证错误
            AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
        };

        (status, Json!({"error": msg})).into_response()
    }
}

📦 04. 数据模型 (Models)

区分 数据库实体(Entity)传输对象(DTO) 是好习惯。

我们使用 FromRow 宏让 SQLx 自动将数据库查询结果映射到 Struct。

Rust 复制代码
// src/models.rs
use serde::{Serialize, Deserialize};
use chrono;
use sqlx::{FromRow};

// Debug(调试打印)自动生成打印结构体所有字段的逻辑
// Serialize(JSON序列化)自动将结构体字段序列化为 JSON 键值对
// FromRow(数据库行映射)自动将数据库查询结果的列与结构体字段一一映射
#[derive(Debug, Serialize, FromRow)]
pub struct Todo {
    pub id: i64,
    pub title: String,
    pub done: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

// 自动实现Debug
// Deserialize JSON反序列化
#[derive(Debug, Deserialize)]
pub struct CreateTodo {
    pub title: String,
}

// 用于分页参数
#[derive(Debug, Deserialize)]
pub struct Pagination {
    pub page: Option<u32>,
    pub size: Option<u32>,
}

// 更新任务时的请求体 (DTO)
#[derive(Debug, Deserialize)]
pub struct UpdateTodo {
    pub title: Option<String>,
    pub done: Option<bool>,
}

🧠 05. 核心业务逻辑

src/handlers.rs中,我们将database pool放入AppState进行共享,并利用Result<T, AppError>来处理业务,代码极其清爽。

这里首先定义共享状态:

rust 复制代码
use axum::{
    extract::{
        State,
        Query
    }, 
    http::StatusCode, 
    Json
};
use sqlx::SqlitePool;
use crate::{
    error::AppError, 
    models::{
        CreateTodo, 
        Todo,
        Pagination
    },
};

// 共享状态
#[derive(Clone)]
pub struct AppState {
    pub pool: SqlitePool,
}

1. 创建任务 (数据校验 + 插入)

Axum 的提取器让我们可以直接拿 Struct,不用手动解析 JSON。

Rust 复制代码
// state是我们要在main.rs中注入的共享状态
pub async fn create_todo(
    State(state): State<AppState>,
    Json(payload): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), AppError> {
    // title不为空检测
    if payload.title.trim().is_empty() {
        return Err(AppError::ValidationError("标题不能为空".to_string()));
    }

    // 插入数据库,同时返回创建好的任务数据
    let todo = sqlx::query_as::<_, Todo>(
        "INSERT INTO todos (title) VALUES (?) RETURNING id, title, done, created_at"
    )
    .bind(payload.title)
    // 查询并返回唯一一条结果,自动映射为目标类型。
    .fetch_one(&state.pool)
    .await?;

    // 日志记录创建任务
    tracing::info!("创建了一个新的任务:{}", todo.id);

    // 请求返回
    Ok((StatusCode::CREATED, Json(todo)))
}

2.分页查询列表

Rust 复制代码
pub async fn list_todos(
    State(state): State<AppState>,
    Query(pagination): Query<Pagination>
) -> Result<Json<Vec<Todo>>, AppError> {
    let page = pagination.page.unwrap_or(1);
    let size = pagination.size.unwrap_or(10);
    let offset = (page - 1) * size;

    // 根据分页查询数据列表
    let todos = sqlx::query_as::<_, Todo>(
        "SELECT id, title, done, created_at FROM todos ORDER BY created_at DESC LIMIT ? OFFSET ?"
    )
    .bind(size)
    .bind(offset)
    // 查询返回任务数量结果,没有数据返回空数组
    .fetch_all(&state.pool)
    .await?;

    Ok(Json(todos))
}

3.根据id获取数据

GET /todos/:id业务逻辑,通过id获取任务

Rust 复制代码
pub async fn get_todo(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<Json<Todo>, AppError> {
    let todo = sqlx::query_as::<_, Todo>(
        "SELECT id, title, done, created_at FROM todos WHERE id = ?"
    )
    .bind(id)
    // fetch_optional 返回 Option<T>,为空返回None
    .fetch_optional(&state.pool)
    .await?
    // 如果是None, 转换为AppError::NotFound
    .ok_or(AppError::NotFound)?;

    Ok(Json(todo))
}

4.更新任务

PUT /todos/:id业务逻辑,可进行标题和状态的修改,任务id不存在返回404,修改成功返回当前内容

Rust 复制代码
pub async fn update_todo(
    State(state): State<AppState>,
    Path(id): Path<i64>,
    Json(payload): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
    // 首先检查是否存在,不存在直接抛出 404
    let _exists = sqlx::query("SELECT id FROM todos WHERE id = ?")
        .bind(id)
        .fetch_optional(&state.pool)
        .await?
        .ok_or(AppError::NotFound)?;

    // COALESCE(?, field) 表示:如果传入参数不为 NULL,用参数;否则保持原值
    let todo = sqlx::query_as::<_, Todo>(
        r#"
        UPDATE todos 
        SET title = COALESCE(?, title), 
            done = COALESCE(?, done)
        WHERE id = ? 
        RETURNING id, title, done, created_at
        "#
    )
    .bind(payload.title)
    .bind(payload.done)
    .bind(id)
    .fetch_one(&state.pool)
    .await?;

    Ok(Json(todo))
}

5.删除任务

DELETE /todos/:id业务逻辑,根据id删除任务,任务不存在返回404内容,删除成功返回"删除成功"文本

复制代码
pub async fn delete_todo(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<StatusCode, AppError> {
    let result = sqlx::query("DELETE FROM todos WHERE id = ?")
        .bind(id)
        .execute(&state.pool)
        .await?;

     // 无行受影响 →  todo 不存在 → 返回 404
    if result.rows_affected() == 0 {
        return Err(AppError::NotFound);
    }

    Ok(StatusCode::NO_CONTENT)
}

🔌 06. 组装与启动

最后,在src/main.rs中将所有组件串联起来。这里我们使用了tower-httpTrace层来自动记录请求日志。

RUST 复制代码
mod error;
mod handlers;
mod models;

use axum:: { routing::{ get }, Router, serve };
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use sqlx::{sqlite::SqlitePoolOptions };
use std::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>>{

    // 初始化环境变量
    dotenvy::dotenv().ok();

    // 初始化日志
    // 创建一个tracing订阅者注册表
    tracing_subscriber::registry()
        // 添加一个格式化层,方便人类查看和调试
        .with(tracing_subscriber::fmt::layer())
        // 从默认环境变量中创建一个环境过滤器。环境过滤器用于根据环境变量来决定哪些日志记录应该被处理和输出。
        // 这里的指令是将 todo_api 模块的日志级别设置为 debug。意味着 todo_api 模块产生的所有 debug 及以上级别的日志(如 info、warn、error)都会被处理和输出。
        // 如果不add_directive, 默认通常处理和输出error级别的日志
        .with(tracing_subscriber::EnvFilter::from_default_env().add_directive("todo_api=debug".parse()?))
        // 应用前面配置进行初始化收集日志
        .init();

    let db_url = std::env::var("DATABASE_URL").expect("环境变量必须设置DATABASE");

    // 数据库连接池
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect(&db_url)
        .await?;

    // 
    sqlx::query(
        r#"
            CREATE TABLE IF NOT EXISTS todos (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                done BOOLEAN NOT NULL DEFAULT 0,
                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
            );
        "#
    )
    .execute(&pool)
    .await?;

    tracing::info!("数据迁移成功!");

    // 构建应用状态
    let state = handlers::AppState { pool };

    // 构建路由
    let app = Router::new()
        .route("/todos", get(handlers::list_todos).post(handlers::create_todo))
        .route("/todos/{id}", get(handlers::get_todo).put(handlers::update_todo).delete(handlers::delete_todo))
        .with_state(state); // 注入状态
    
    // 启动服务器
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;

    tracing::info!("启动服务器:{}", listener.local_addr()?);
    println!("启动服务: http://{}", listener.local_addr()?);

    serve(listener, app).await?;

    Ok(())
}

📝 总结与测试

至此,我们用极少的代码量构建了一个健壮的 API。

在项目根目录新建.env文件:

env 复制代码
DATABASE_URL=sqlite://todos.db?mode=rwc

测试一下

使用curl验证接口:

Bash 复制代码
# 1. 创建任务
curl -X POST -H "Content-Type: application/json" -d '{"title":"学习Rust"}' http://localhost:3000/todos

# 2. 获取列表
curl http://localhost:3000/todos?page=1&size=5
相关推荐
fantasy5_54 小时前
深入理解 Linux 动静态库:制作、原理与加载机制
linux·运维·restful
反向跟单策略4 小时前
如何正确看待期货反向跟单策略?
大数据·人工智能·学习·数据分析·区块链
QiZhang | UESTC4 小时前
学习日记day65
学习
leaves falling4 小时前
C 语言-文件操作学习
学习
半条-咸鱼5 小时前
C语言基础语法+STM32实践学习笔记 | 指针/寄存器核心应用
c语言·stm32·学习·嵌入式
hzb666665 小时前
xd_day47文件上传-day55xss
javascript·学习·安全·web安全·php
WYH2876 小时前
TTSY-学习笔记1
笔记·学习
鄭郑6 小时前
【Playwright 学习笔记 03】CSS选择器 定位方法
css·笔记·学习·playwright
JeffDingAI6 小时前
【Datawhale学习笔记】参数高效微调
android·笔记·学习