Axum + SQLx 实战:把内存服务变成真正的持久化后端
上一篇我们用 Axum 写了一个简单的 HTTP 服务,但数据还放在内存里。只要服务重启,所有数据都会消失。真实后端不能这样,所以这一篇我们把数据落进数据库,用 Axum + SQLx 搭一个可持久化的 Rust 后端骨架。
为什么选 SQLx?
SQLx 是 Rust 生态里非常流行的异步 SQL 工具库,支持 PostgreSQL、MySQL、SQLite 等数据库。 它最大的特点是:可以写原生 SQL,又能获得编译期检查。
比如你写:
sqlx::query_as!(
User,
"SELECT id, title, completed FROM todos WHERE id = $1",
id
)
.fetch_one(&pool)
.await?;
SQLx 会在编译阶段检查这条 SQL 是否合法、返回列是否匹配 User 结构体。如果表结构改了,或者字段名写错了,很多错误会在编译期暴露,而不是等到线上请求失败。
第一步:准备数据库
本地开发最方便的方式是用 Docker 启动一个 PostgreSQL:
docker run --name pg -e POSTGRES_PASSWORD=123456 -p 5432:5432 -d postgres
然后创建数据库:
createdb -h localhost -U postgres todo_db
在项目根目录创建 .env 文件:
DATABASE_URL=postgres://postgres:123456@localhost:5432/todo_db
第二步:初始化项目和迁移
创建项目:
cargo new todo_service
cd todo_service
cargo add axum tokio --features tokio/full
cargo add sqlx --features runtime-tokio-rustls,postgres,macros,migrate,chrono
cargo add serde serde_json dotenvy thiserror anyhow
安装 SQLx CLI:
cargo install sqlx-cli --no-default-features --features postgres
创建迁移文件:
sqlx migrate add create_todos_table
在生成的迁移文件里写表结构:
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
运行迁移:
sqlx migrate run
这样数据库表结构就被版本化管理起来了,后续改表结构也只需要新增迁移文件。
第三步:连接池与应用状态
真实服务不会每次请求都新建连接,而是使用连接池。SQLx 的 PgPool 已经内置了连接池能力。
use axum::Router;
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: Arc<PgPool>,
}
async fn init_pool(database_url: &str) -> PgPool {
PgPool::connect(database_url)
.await
.expect("failed to connect to database")
}
启动时读取环境变量,创建连接池,再注入到 Axum 路由中:
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = Arc::new(init_pool(&database_url).await);
let state = AppState { db: pool };
let app = Router::new()
.route("/todos", axum::routing::get(list_todos))
.route("/todos", axum::routing::post(create_todo))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
第四步:定义数据模型
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Todo {
pub id: i32,
pub title: String,
pub completed: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateTodo {
pub title: String,
}
FromRow 可以让 SQLx 自动把查询结果映射到结构体。
第五步:写两个接口
查询所有待办:
use axum::{extract::State, Json};
async fn list_todos(
State(state): State<AppState>,
) -> Result<Json<Vec<Todo>>, AppError> {
let todos = sqlx::query_as!(
Todo,
"SELECT id, title, completed FROM todos ORDER BY id DESC"
)
.fetch_all(&*state.db)
.await?;
Ok(Json(todos))
}
创建待办:
async fn create_todo(
State(state): State<AppState>,
Json(payload): Json<CreateTodo>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as!(
Todo,
"INSERT INTO todos (title) VALUES ($1) RETURNING id, title, completed",
payload.title
)
.fetch_one(&*state.db)
.await?;
Ok(Json(todo))
}
这里用了 RETURNING,插入后直接返回新记录,避免再查一次。
第六步:统一错误处理
不要到处 unwrap()。可以定义一个简单的应用错误类型:
use axum::{http::StatusCode, response::IntoResponse};
#[derive(Debug)]
enum AppError {
Db(sqlx::Error),
BadRequest(String),
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Db(e)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
match self {
AppError::Db(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()
}
AppError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, msg).into_response()
}
}
}
}
这样 handler 返回 Result<T, AppError> 后,Axum 会自动把错误转成合适的 HTTP 响应。
接下来可以继续扩展
到这里,一个最小可用的持久化服务已经跑通了。后续可以继续加:
- 更新和删除接口
- 分页查询
- 用户表与关联查询
- JWT 登录认证
- Tracing 日志
- CORS、限流、压缩等 Tower 中间件
- 用 SeaORM 替代手写 SQL
Rust 后端开发最舒服的地方就在这里:数据库连接、错误类型、请求参数、返回结构都被类型系统串在一起。刚开始会觉得约束多,但越往后越能感受到它的稳定感。尤其是当你把服务部署到服务器上,重启几次后数据依然完好,那种“终于像个正经后端”的感觉会很实在。
- 点赞
- 收藏
- 关注作者
评论(0)