从 API 到应用:用 Rust 和 SQLx 为 Axum 服务添加持久化数据库

引言

在上一篇文章中,我们使用 Axum 框架构建了一个功能完备、结构清晰的 To-Do List API。它拥有优雅的错误处理、数据验证和日志系统,可以说是一个非常健壮的"骨架"。然而,它有一个在真实世界中堪称"致命"的弱点:所有的数据都依赖于服务进程的内存。这意味着,无论是计划内的更新部署,还是计划外的意外宕机,只要服务重启,所有用户的待办事项都会烟消云散。这对于任何期望提供稳定服务的应用来说,都是完全不可接受的。

本文是 robust_todo_api 项目的直接续篇,我们将正面迎击这个核心问题:数据持久化 。我们将彻底告别存储在内存中的 HashMap,转而拥抱强大且开源的 PostgreSQL 关系型数据库。而连接我们 Rust 应用与数据库之间的桥梁,则是 Rust 生态中最受欢迎的异步 SQL 工具库 sqlx。通过这次升级,我们的 API 将蜕变为一个真正意义上的、能够持久存储数据的 Web 应用。

为什么是 SQLx?一场关于安全的哲学共鸣

在选择数据库工具时,我们并非只是简单地寻找一个"驱动程序"。我们寻求的是一个能够融入并增强我们现有技术栈核心价值的伙伴。sqlx 正是这样的伙伴。

它并不仅仅是一个数据库驱动,而是一个现代、完全异步的工具库。其最大的"杀手级"特性无疑是编译时查询检查 。这是一个革命性的概念:通过一个名为 sqlx-cli 的可选工具,sqlx 可以在你编译 Rust 代码的阶段(cargo buildcargo check),就主动连接到你的开发数据库。它会逐一检查你的 SQL 语句,验证以下几点:

  • SQL 语法是否正确? 杜绝了简单的拼写错误。
  • 引用的表名、列名是否存在? 告别因数据库结构变更导致运行时错误的窘境。
  • 查询返回的数据类型和数量,是否与你的 Rust 结构体类型完美匹配? 确保了数据在从数据库到应用的传递过程中类型安全无虞。

这个特性 incredible 地将数据库层面的、通常只有在运行时才能发现的错误,提前到了编译阶段。这与 Rust 语言本身的设计哲学------在编译时尽可能多地发现和消灭错误,从而保证运行时安全、可靠------形成了完美的共鸣。选择 sqlx,就是选择将 Rust 的安全边界延伸至数据库交互的每一个角落。

读完本文,你将不仅仅是学会了如何连接数据库,而是掌握了一整套现代化的后端开发工作流:

  • 基础设施即代码:使用 Docker 快速、可复现地搭建本地开发数据库。
  • Schema 版本控制 :通过 sqlx-cli 进行专业的数据库迁移(Migrations),让你的数据库结构像代码一样被追踪和管理。
  • 高性能连接管理:在 Axum 应用中配置并管理异步数据库连接池,这是构建高并发服务的基础。
  • 类型安全的数据库交互 :使用 sqlx 的编译时宏重构所有 API handler,实现与真实数据库的高效、安全交互。

让我们启程,为我们的应用注入真正的、永不磨灭的"记忆"!


第一步:环境准备 - 基础建设的艺术

在编写任何触及数据库的代码之前,我们需要一个稳定运行的 PostgreSQL 实例和一套称手的命令行工具。这是一个"磨刀不误砍柴工"的过程,坚实的基础设施将为后续的开发带来极大的便利。

  1. 使用 Docker 启动 PostgreSQL:一键启动你的专属数据库

    在现代软件开发中,Docker 已经成为管理服务依赖的事实标准。它允许我们将应用(如此处的 PostgreSQL)及其所有依赖打包到一个轻量、可移植的"容器"中。这确保了无论是在你的 Mac、Windows 还是同事的 Linux 笔记本上,数据库环境都是完全一致的,从而彻底告别了"在我机器上是好的"这类经典难题。

    如果你尚未安装 Docker,请先根据其官网指引完成安装。之后,打开你的终端,运行以下命令:

    bash 复制代码
    docker run --name robust-postgres -e POSTGRES_PASSWORD=password -e POSTGRES_USER=user -e POSTGRES_DB=todos -p 5432:5432 -d postgres

    让我们逐一解析这个命令的含义:

    • docker run: 这是启动一个新容器的基本命令。
    • --name robust-postgres: 为这个容器赋予一个人类可读的名字。这样,将来我们可以用 docker start robust-postgresdocker stop robust-postgres 来轻松地启停它。
    • -e ...: -e 参数用于设置容器内的环境变量。这里我们设置了三个至关重要的变量,PostgreSQL 镜像在首次启动时会读取它们来完成初始化:
      • POSTGRES_PASSWORD=password: 设置超级用户的密码。
      • POSTGRES_USER=user: 创建一个名为 user 的新用户。
      • POSTGRES_DB=todos: 创建一个名为 todos 的新数据库,并将其所有权赋予 user 用户。
    • -p 5432:5432: 这是端口映射。它将你本机(宿主机)的 5432 端口与容器内部的 5432 端口连接起来。5432 是 PostgreSQL 的标准监听端口。这样设置后,我们本地的 Rust 应用就能通过连接 localhost:5432 来访问容器中的数据库了。
    • -d: 代表 "detach"(分离模式)。这会让容器在后台运行,并将容器的 ID 打印出来,而不会占用你当前的终端会话。
    • postgres: 这是我们要使用的 Docker 镜像的名称。Docker 会首先在本地查找 postgres 镜像,如果找不到,会自动从 Docker Hub(官方镜像仓库)拉取最新版本。

    命令执行成功后,你可以通过 docker ps 命令看到一个名为 robust-postgres 的容器正在运行,这意味着你的专属数据库已经准备就绪。

  2. 安装 sqlx-cli:你的数据库守护神

    sqlx-clisqlx 生态系统的重要组成部分,它是一个独立的命令行工具,主要负责两件事:数据库迁移管理和辅助编译时检查。我们将使用 cargo 来安装它:

    bash 复制代码
    cargo install sqlx-cli

    这个命令会将 sqlx-cli 安装到你的 cargo二进制文件目录(通常是 ~/.cargo/bin),使其成为一个全局可用的命令。

    注意:潜在的编译障碍
    sqlx-cli 在编译时需要链接到 PostgreSQL 的客户端库(通常称为 libpq)。这是一个 C 语言库,提供了与 PostgreSQL 服务器通信的基础功能。如果你的系统上缺少这个库的开发文件(头文件等),cargo install 可能会失败。

    根据你的操作系统,解决方法如下:

    • Ubuntu/Debian : sudo apt-get install libpq-dev
    • CentOS/Fedora/RHEL : sudo yum install postgresql-develsudo dnf install postgresql-devel
    • macOS (使用 Homebrew) : brew install libpq (可能还需要手动将其路径添加到环境变量中,请遵循 brew 的提示)
  3. 创建 .env 文件:安全配置的基石

    将数据库连接字符串、API 密钥等敏感信息硬编码在代码中是一种非常危险的做法。一个更好的实践是遵循"十二要素应用"(The Twelve-Factor App)的原则,将配置存储在环境中。 .env 文件是一种在开发环境中模拟环境变量的便捷方式。

    在你的项目根目录(与 Cargo.toml 同级)下,创建一个新文件,命名为 .env,并写入以下内容:

    dotenv 复制代码
    DATABASE_URL=postgres://user:password@localhost:5432/todos

    这个 DATABASE_URL 是一个标准格式的连接URI,sqlx 和许多其他数据库工具都能识别它。它的结构是:postgres://<用户名>:<密码>@<主机>:<端口>/<数据库名>。这与我们之前在 docker run 命令中设置的环境变量完全对应。

  4. 添加新的依赖:为项目注入新能力

    现在,我们需要告诉 Rust 的包管理器 Cargo,我们的项目需要哪些新的库。打开 Cargo.toml 文件,在 [dependencies] 部分添加 sqlxdotenvy

    toml 复制代码
    [package]
    name = "robust_todo_api"
    version = "0.1.0"
    edition = "2024"
    
    [dependencies]
    axum = "0.7"
    tokio = { version = "1", features = ["full"] }
    serde = { version = "1", features = ["derive"] }
    serde_json = "1"
    uuid = { version = "1", features = ["v4", "serde"] }
    
    # 日志 & 追踪
    tracing = "0.1"
    tracing-subscriber = { version = "0.3", features = ["env-filter"] }
    tower-http = { version = "0.5.0", features = ["trace"] }
    
    # 数据验证
    validator = { version = "0.18", features = ["derive"] }
    
    # 新增依赖
    sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros", "uuid"] }
    dotenvy = "0.15"

    让我们深入理解 sqlx 的这些 features 标志:

    • runtime-tokio-rustls: 这是一个组合特性。
      • runtime-tokio: 明确告诉 sqlx 我们使用的异步运行时是 tokiosqlx 也支持 async-std
      • rustls: 指定使用 rustls 作为 TLS (传输层安全) 的后端,用于加密数据库连接。rustls 是一个纯 Rust 实现的现代 TLS 库,使用它意味着我们的应用编译后不依赖于系统上的 OpenSSL 库,使二进制文件更具可移植性。另一个选项是 native-tls,它会使用操作系统提供的 TLS 实现(如 OpenSSL on Linux)。
    • postgres: 启用针对 PostgreSQL 数据库的特定驱动和协议支持。
    • macros: 这是启用 sqlx "杀手级特性"的关键。它会引入 query!, query_as! 等宏,这些宏是实现编译时查询检查的核心。
    • uuid: 启用 sqlxuuid 类型的原生支持。这使得 sqlx 可以在 Rust 代码中的 uuid::Uuid 类型和 PostgreSQL 的 UUID 类型之间进行无缝、自动的转换,无需我们手动处理。
    • dotenvy: 这是一个轻量级的库,它的作用非常专一:在程序启动时读取 .env 文件,并将其中的键值对加载到当前进程的环境变量中。

第二步:数据库迁移 - 用代码管理你的表结构

数据库迁移是一种以编程方式、可版本控制地管理数据库 schema(结构)演变的过程。 我们绝对不应该手动连接到生产数据库去 CREATEALTER 表。这种做法是不可追踪、不可复现且极易出错的。相反,我们应该通过迁移文件来精确地定义每一次数据库结构的变更。

  1. 创建迁移文件

    请确保你的终端位于项目根目录,并且 .env 文件已正确配置。然后,运行以下命令:

    bash 复制代码
    sqlx migrate add create_todos_table

    这个命令指示 sqlx-cli 做几件事情:

    • 它会读取 .env 文件中的 DATABASE_URL 来确定我们正在使用 PostgreSQL。
    • 它会在项目根目录下创建一个名为 migrations 的新文件夹。
    • 在这个文件夹里,它会生成一个以当前UTC时间戳和我们提供的描述 create_todos_table 命名的 .sql 文件。

    在较新版本的 sqlx-cli (v0.7 左右) 中,为了简化操作,默认只创建一个合并的 SQL 文件。文件中会用注释 -- Add up migration script here-- Add down migration script here 来区分"向上"和"向下"的脚本。

    • Up Migration (up.sql): 定义应用此迁移时需要执行的 SQL 命令。例如,创建一张新表、添加一个新列。
    • Down Migration (down.sql) : 定义撤销 此迁移时需要执行的 SQL 命令。例如,删除 up 中创建的表、移除添加的列。编写 down 脚本是良好实践,它使得我们可以在开发过程中轻松地回滚错误的变更。
  2. 编写迁移 SQL

    现在,打开刚才生成的 SQL 文件。我们将分别在 updown 的部分写入相应的 SQL 语句。

    -- Add up migration script here 下方,写入 CREATE TABLE 语句:

    sql 复制代码
    -- migrations/{timestamp}_create_todos_table.sql
    -- Add up migration script here
    CREATE TABLE todos (
        id UUID PRIMARY KEY NOT NULL,
        text TEXT NOT NULL,
        completed BOOLEAN NOT NULL DEFAULT FALSE
    );

    这里需要特别注意,我们将 id 的类型设置为 UUID。这是一种通用唯一标识符,非常适合用作分布式系统中的主键。它与我们 Rust 代码中使用的 uuid::Uuid 类型完美对应。

    接着,在 -- Add down migration script here 下方,写入 DROP TABLE 语句,这是 CREATE TABLE 的逆操作:

    sql 复制代码
    -- Add down migration script here
    DROP TABLE todos;
  3. 执行迁移

    保存好迁移文件后,回到终端,运行:

    bash 复制代码
    sqlx migrate run

sqlx-cli 会连接到 DATABASE_URL 指定的数据库,并检查一张名为 _sqlx_migrations 的特殊表(如果不存在,它会自动创建)。这张表记录了所有已经成功运行过的迁移文件。然后,它会按时间顺序,执行所有在 migrations 文件夹中但尚未在 _sqlx_migrations 表里记录的迁移文件。

成功执行后,你的 PostgreSQL 数据库的 todos 数据库中就已经有了一张我们定义的 todos 表。数据库的结构现在是明确的、受版本控制的了。


第三步:改造应用核心 - 拥抱异步数据库操作

基础设施准备就绪,现在是时候进入最激动人心的部分了:我们将用与真实数据库的交互,来替换掉所有基于内存 HashMap 的操作逻辑。

  1. 创建数据库连接池并更新应用状态

    对于一个 Web 服务来说,为每个进来的 HTTP 请求都新建一个数据库连接是一种巨大的性能浪费。建立数据库连接是一个相对耗时的操作,涉及到网络握手和认证过程。 连接池 (Connection Pool) 是一种标准解决方案。应用启动时,它会预先创建并维护一定数量的数据库连接。当需要执行查询时,应用会从池中"借用"一个连接,用完后再"归还"给池子,而不是关闭它。这极大地减少了连接建立和销毁的开销,显著提升了应用性能。

    我们需要修改 src/main.rs,让 Axum 应用在启动时创建 sqlx 提供的 PgPool(PostgreSQL 连接池),并将其作为共享状态注入到我们的 Router 中,以便所有 handler 都能访问它。

    rust 复制代码
    // src/main.rs
    
    use axum::{routing::{get, post, put, delete}, Router};
    use std::net::SocketAddr;
    use tower_http::trace::TraceLayer;
    use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
    
    // 引入 sqlx 和 dotenvy
    use sqlx::postgres::PgPoolOptions;
    use sqlx::PgPool;
    use dotenvy::dotenv;
    use std::env;
    
    mod models;
    mod handlers;
    mod errors;
    
    // 定义一个新的应用状态结构体,用于持有数据库连接池
    // derive(Clone) 是 Axum 状态共享的要求
    #[derive(Clone)]
    pub struct AppState {
        pool: PgPool,
    }
    
    #[tokio::main]
    async fn main() {
        // 在程序启动时从 .env 文件加载环境变量
        dotenv().ok();
    
        // 初始化日志系统
        tracing_subscriber::registry()
            .with(tracing_subscriber::EnvFilter::new(
                "robust_todo_api=debug,tower_http=debug,sqlx=debug" // 增加 sqlx 的 debug 日志
            ))
            .with(tracing_subscriber::fmt::layer())
            .init();
    
        // 从环境变量中获取数据库 URL,如果不存在则 panic
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
        
        // 创建数据库连接池
        let pool = PgPoolOptions::new()
            .max_connections(5) // 设置池中的最大连接数
            .connect(&db_url)
            .await
            .expect("Failed to create pool.");
        
        // 创建 AppState 实例
        let app_state = AppState { pool };
    
        // 定义路由,并将 AppState 注入
        let app = Router::new()
            .route("/todos", get(handlers::get_all_todos).post(handlers::create_todo))
            .route(
                "/todos/:id",
                get(handlers::get_todo_by_id)
                    .put(handlers::update_todo)
                    .delete(handlers::delete_todo),
            )
            .with_state(app_state) // 使用 .with_state() 将状态注入到路由中
            .layer(TraceLayer::new_for_http());
    
        // 启动服务
        let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
        tracing::debug!(">> 服务正在监听 http://{}", addr);
        let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
        axum::serve(listener, app).await.unwrap();
    }

    核心改动深度解析:

    • dotenv().ok();: 我们在 main 函数的开头就调用它,确保在后续代码(如读取 DATABASE_URL)执行之前,所有环境变量都已加载完毕。
    • AppState 结构体:我们定义了一个新的 AppState 结构体来专门持有所有需要共享的应用状态。目前它只包含 pool: PgPool,但未来可以轻松扩展,加入配置、缓存客户端等。#[derive(Clone)] 是必需的,因为 Axum 会为每个处理请求的 worker 线程克隆一份状态。PgPool 内部使用了 Arc(原子引用计数指针),所以克隆它本身是非常廉价的,只是复制一个指针。
    • PgPoolOptions: sqlx 提供了链式 API 来配置连接池。.max_connections(5) 是一个重要的性能调优参数,它限制了应用能同时打开的数据库连接数量,防止耗尽数据库资源。
    • .with_state(app_state): 这是 Axum 注入共享状态的关键方法。它会将 app_state 的一个副本分发给它所应用到的所有路由处理器(handler)。
    • sqlx=debug: 在日志过滤器中加入 sqlx=debug,可以让 sqlx 打印出详细的运行时信息,包括它执行的每一条 SQL 语句、连接获取和释放等,这在开发和调试阶段非常有用。
  2. 让数据模型与 SQLx 兼容

    我们需要告诉 sqlx 如何将从数据库查询到的一行数据,映射到我们的 Todo 结构体实例上。sqlx 通过一个名为 FromRow 的 trait 来实现这一点。最简单的方式就是使用派生宏。

    打开 src/models.rs,为 Todo 结构体派生 sqlx::FromRow

    rust 复制代码
    // src/models.rs
    
    use serde::{Deserialize, Serialize};
    use uuid::Uuid;
    use validator::Validate;
    
    // 为 Todo 结构体派生 sqlx::FromRow
    // 这个宏会自动生成代码,使得 sqlx 能够根据列名将数据库行记录
    // 映射到这个结构体的同名字段上。
    #[derive(Debug, Serialize, Clone, sqlx::FromRow)]
    pub struct Todo {
        pub id: Uuid,
        pub text: String,
        pub completed: bool,
    }
    
    // CreateTodo 结构体保持不变,因为它只用于从 HTTP 请求体中反序列化 JSON
    #[derive(Deserialize, Validate)]
    pub struct CreateTodo {
        #[validate(length(min = 1, message = "Todo text cannot be empty"))]
        pub text: String,
    }
    
    // UpdateTodo 结构体也保持不变
    #[derive(Deserialize, Validate)]
    pub struct UpdateTodo {
        #[validate(length(min = 1, message = "Todo text cannot be empty"))]
        pub text: Option<String>,
        pub completed: Option<bool>,
    }

    这个小小的 #[derive(sqlx::FromRow)] 背后蕴含了 sqlx 强大的宏能力。在编译时,它会检查 Todo 结构体的字段名(id, text, completed),并生成将数据库查询结果中同名列的值赋给这些字段的代码。

  3. 重写所有 Handlers:与 SQLx 的共舞

    这是本次重构的核心。我们将打开 src/handlers.rs,用 sqlx 的异步查询替换掉所有的 HashMap 操作。

    首先,更新文件开头的 use 语句,并准备好从 Axum 的 State 提取器中获取我们的 AppState

    rust 复制代码
    // src/handlers.rs
    
    use axum::{
        extract::{Path, State},
        http::StatusCode,
        Json,
    };
    use uuid::Uuid;
    use validator::Validate;
    
    use crate::models::{CreateTodo, Todo, UpdateTodo};
    use crate::errors::AppError;
    use crate::AppState; // 引入新的 AppState
    
    // ----- Handlers -----
    
    // GET /todos
    pub async fn get_all_todos(
        State(state): State<AppState>,
    ) -> Result<Json<Vec<Todo>>, AppError> {
        let todos = sqlx::query_as!(Todo, "SELECT id, text, completed FROM todos ORDER BY id")
            .fetch_all(&state.pool)
            .await
            .map_err(|e| {
                tracing::error!("Failed to fetch todos: {:?}", e);
                AppError::InternalServerError
            })?;
        
        Ok(Json(todos))
    }
    
    // POST /todos
    pub async fn create_todo(
        State(state): State<AppState>,
        Json(input): Json<CreateTodo>,
    ) -> Result<(StatusCode, Json<Todo>), AppError> {
        input.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
    
        let todo = sqlx::query_as!(
            Todo,
            "INSERT INTO todos (id, text) VALUES ($1, $2) RETURNING id, text, completed",
            Uuid::new_v4(),
            input.text
        )
        .fetch_one(&state.pool)
        .await
        .map_err(|e| {
            tracing::error!("Failed to create todo: {:?}", e);
            AppError::InternalServerError
        })?;
    
        Ok((StatusCode::CREATED, Json(todo)))
    }
    
    // GET /todos/:id
    pub async fn get_todo_by_id(
        State(state): State<AppState>,
        Path(id): Path<Uuid>,
    ) -> Result<Json<Todo>, AppError> {
        let todo = sqlx::query_as!(Todo, "SELECT * FROM todos WHERE id = $1", id)
            .fetch_optional(&state.pool)
            .await
            .map_err(|e| {
                tracing::error!("Failed to fetch todo by id: {:?}", e);
                AppError::InternalServerError
            })?
            .ok_or_else(|| AppError::NotFound(format!("Todo with ID {} not found", id)))?;
    
        Ok(Json(todo))
    }
    
    // PUT /todos/:id
    pub async fn update_todo(
        State(state): State<AppState>,
        Path(id): Path<Uuid>,
        Json(input): Json<UpdateTodo>,
    ) -> Result<Json<Todo>, AppError> {
        input.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
    
        let todo = sqlx::query_as!(Todo, "SELECT * FROM todos WHERE id = $1", id)
            .fetch_optional(&state.pool)
            .await
            .map_err(|_| AppError::InternalServerError)?
            .ok_or_else(|| AppError::NotFound(format!("Todo with ID {} not found", id)))?;
    
        let text = input.text.unwrap_or(todo.text);
        let completed = input.completed.unwrap_or(todo.completed);
    
        let updated_todo = sqlx::query_as!(
            Todo,
            "UPDATE todos SET text = $1, completed = $2 WHERE id = $3 RETURNING *",
            text,
            completed,
            id
        )
        .fetch_one(&state.pool)
        .await
        .map_err(|_| AppError::InternalServerError)?;
    
        Ok(Json(updated_todo))
    }
    
    // DELETE /todos/:id
    pub async fn delete_todo(
        State(state): State<AppState>,
        Path(id): Path<Uuid>,
    ) -> Result<StatusCode, AppError> {
        let result = sqlx::query!("DELETE FROM todos WHERE id = $1", id)
            .execute(&state.pool)
            .await
            .map_err(|_| AppError::InternalServerError)?;
        
        if result.rows_affected() == 0 {
            Err(AppError::NotFound(format!("Todo with ID {} not found", id)))
        } else {
            Ok(StatusCode::NO_CONTENT)
        }
    }

    深度解析 sqlx 的查询模式:

    • 提取状态 : State(state): State<AppState> Axum 的提取器现在为我们提供了 AppState 的实例,我们可以通过 state.pool 访问连接池。
    • 编译时安全宏 :
      • sqlx::query_as!(Todo, ...): 这是我们最常用的宏。它接收一个目标类型(Todo)和 SQL 查询字符串。在编译时,它会连接数据库,验证 SQL 语法,并确保 SELECT 子句返回的列与 Todo 结构体的字段在名称和类型上都兼容。
      • sqlx::query!(...): delete_todo 中使用的宏。它不映射到指定的结构体,而是返回一个匿名的、字段已正确类型的结构体。它同样会进行编译时检查。
    • 参数绑定 : $1, $2 这种语法是参数化查询的占位符。sqlx 会安全地将我们后续提供的值(如 id, input.text)绑定到这些占位符上。这是一种防止 SQL 注入攻击的根本方法。它确保了用户输入永远被当作数据处理,而不会被错误地解析为 SQL 代码的一部分。
    • 执行器方法 (Fetcher Methods) :
      • .fetch_all(&state.pool): 执行查询并异步地将所有返回的行收集到一个 Vec<Todo> 中。适合返回列表的场景。
      • .fetch_one(&state.pool): 执行查询并期望返回恰好一行 。如果数据库返回 0 行或多于 1 行,它将返回一个错误。非常适合 INSERT ... RETURNING 或根据唯一键查询的场景。
      • .fetch_optional(&state.pool): 执行查询并期望返回零行或一行 。它的返回值是 Result<Option<Todo>, Error>,完美匹配我们"根据 ID 查找单个资源"的场景,因为资源可能存在,也可能不存在。
      • .execute(&state.pool): 用于执行不返回数据行的 SQL 命令(如 DELETE 或没有 RETURNING 子句的 UPDATE)。它返回一个 QueryResult,其中包含了 rows_affected() 等元信息。
    • RETURNING * : 这是 PostgreSQL 的一个极其有用的特性。 它允许 INSERTUPDATEDELETE 语句直接返回被操作行的内容。这为我们省去了一次额外的 SELECT 查询。例如,在 create_todo 中,我们 INSERT 一条新记录后,可以直接通过 RETURNING * 获得数据库生成的完整 Todo 对象(包括默认值等),效率极高。

第四步:最终测试 - 验证持久化的力量

所有代码已经改造完毕。现在,是时候通过实践来检验我们的劳动成果了。

  1. 启动应用

    在终端中运行 cargo run。由于我们在 main.rs 的日志配置中添加了 sqlx=debug,现在你的终端会变得非常"热闹"。你会看到 sqlx 打印出大量有用的调试信息,包括它如何从连接池中获取连接、执行的每一条具体的 SQL 语句以及执行耗时。这对于理解应用底层行为和性能调试非常有帮助。

  2. 执行一系列 curl 命令

    我们将模拟客户端与 API 的交互,来完整地测试一次数据的生命周期。

    • 创建一个新的 Todo:

      打开另一个终端窗口,执行以下 curl 命令。-X POST 指定请求方法,-H "Content-Type: application/json" 告诉服务器我们发送的是 JSON 数据,-d '...' 是请求体内容。

      bash 复制代码
      curl -X POST -H "Content-Type: application/json" -d '{"text": "学习 SQLx"}' http://127.0.0.1:3000/todos

      如果一切顺利,你应该会收到一个包含新创建的 Todo 对象的 JSON 响应,其中 id 是一个新生成的 UUID。

    • 获取所有 Todos (验证创建成功):

      现在,让我们获取列表,看看我们刚创建的项目是否在其中。

      bash 复制代码
      curl http://127.0.0.1:3000/todos

      返回的 JSON 数组中应该包含了 "学习 SQLx" 这一项。

    • 重启服务 (关键步骤):

      这是验证持久化的核心步骤。回到运行 cargo run 的终端,按下 Ctrl+C 来优雅地停止服务。此时,内存中的所有状态都已丢失。然后,再次运行 cargo run 重启应用。

    • 再次获取所有 Todos (验证持久化):

      服务重启后,再次执行获取所有 Todo 的 curl 命令:

      bash 复制代码
      curl http://127.0.0.1:3000/todos

      见证奇迹的时刻! 尽管服务已经彻底重启,但 "学习 SQLx" 这个待办事项依然被返回了。它安静地躺在我们的 PostgreSQL 数据库中,等待着被查询。这有力地证明了,我们的数据被成功地持久化了。


结论:一个新的起点

恭喜你!通过本教程的引导,我们完成了一次意义重大的升级。我们的 robust_todo_api 项目已经从一个基于内存的原型 API,蜕变为一个使用真实数据库进行数据持久化的、更接近生产级别的 Web 应用。

我们收获的不仅仅是代码的改变,更重要的是,我们引入并实践了一整套专业、可靠的数据库开发工作流:

  • 隔离与可复现的环境 : 使用 Docker,我们为开发环境创建了一个一致且与主机系统隔离的数据库实例。
  • 版本化的数据库 Schema : 使用 sqlx-cli 和迁移文件,我们让数据库的结构演变变得像 Git 提交一样清晰、可追溯和可自动化。
  • 安全与高性能的交互 : 使用 sqlx 的异步 API、连接池以及革命性的编译时检查,我们在享受 Tokio 带来的高并发性能的同时,获得了前所未有的数据库操作安全性,将大量潜在的运行时错误消灭在了萌芽阶段。

我们的 robust_todo_api 现在已经名副其实。从这里出发,你已经为构建更复杂、更健壮的系统打下了坚实的基础。下一步的探索方向可以是:

  • 用户认证与授权: 集成 JWT (JSON Web Tokens) 或其他认证机制,实现多用户隔离。
  • 更完善的配置管理: 引入专门的配置库,管理不同环境(开发、测试、生产)的配置。
  • 应用容器化 : 为我们的 Rust 应用编写 Dockerfile,将其也打包成一个 Docker 镜像,为将来的云原生部署铺平道路。

Rust 后端开发的道路充满挑战与机遇,而你,已经迈出了坚实而漂亮的一步。继续探索,继续构建吧!

相关推荐
小冷coding3 小时前
【MySQL】MySQL 插入一条数据的完整流程(InnoDB 引擎)
数据库·mysql
鲨莎分不晴4 小时前
Redis 基本指令与命令详解
数据库·redis·缓存
专注echarts研发20年4 小时前
工业级 Qt 业务窗体标杆实现・ResearchForm 类深度解析
数据库·qt·系统架构
周杰伦的稻香6 小时前
MySQL中常见的慢查询与优化
android·数据库·mysql
冉冰学姐6 小时前
SSM学生社团管理系统jcjyw(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·ssm 框架·学生社团管理系统·多角色管理
nvd117 小时前
深入分析:Pytest异步测试中的数据库会话事件循环问题
数据库·pytest
appearappear7 小时前
如何安全批量更新数据库某个字段
数据库
·云扬·8 小时前
MySQL 常见存储引擎详解及面试高频考点
数据库·mysql·面试
羊小猪~~8 小时前
【QT】--文件操作
前端·数据库·c++·后端·qt·qt6.3
栈与堆8 小时前
LeetCode 21 - 合并两个有序链表
java·数据结构·python·算法·leetcode·链表·rust