导读 :为了帮助大家从零开始掌握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-http的Trace层来自动记录请求日志。
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