跳转到内容

Rust 数据库:用 sqlx、Diesel 和 SeaORM 操作 SQLite 与 Postgres

程序迟早需要记住东西:用户、订单、最高分、待办事项。程序一停止,Vec 和 HashMap 里的数据就没了。数据库会把数据安全地存在磁盘上,让多个程序共享,还能飞快地回答“把所有未完成的任务按时间倒序列出来”这样的问题。

Rust 有非常好用的数据库 crate。在这一页,你会先选一个 crate,然后写真正的代码:先用纯 Rust 写一个小小的“仓储(repository)”(可以直接在 Playground 里运行),然后用 sqlx 操作 SQLite、连接 Postgres、做迁移,最后简单了解一下 Diesel 和 SeaORM。Playground 里没有数据库 crate,所以这些示例要在你自己的计算机上用 cargo run 运行。每个示例我们都实际编译运行过,展示的输出都是真实的。

  • Web API 的数据层:把项目:构建 Web API 里的待办事项服务器改一改,让数据存进 Postgres,而不是放在内存里。
  • 访问量很大的网站:托管所有 Rust crate 的 crates.io 就是通过 Diesel 把数据存在 Postgres 里的。开源社区平台 Lemmy(英文)也是这样。
  • 桌面工具和命令行工具,用一个本地 SQLite 文件存数据:笔记应用、记账工具、缓存。
  • 后台任务和数据处理任务:读出数据行,处理后再把结果写回去。

也有比较难的地方:Rust 没有像 .NET 的 Entity Framework 或 Rails 的 ActiveRecord 那样唯一的“官方”方案,你需要在几个不错的 crate 之间做选择。第一次编译数据库 crate 要花一两分钟。另外,编译时检查 SQL(一个很棒的功能)需要先做一点配置。

crate 同步还是异步 编译时检查查询? ORM? 适合的场景
rusqlite 同步 不检查 不是,直接写 SQL 只用一个 SQLite 文件的小工具或桌面应用,不需要异步。
sqlx 异步 检查,用 query! 系列宏(可选) 不是,直接写 SQL 你喜欢写 SQL,又想用异步。支持 SQLite、Postgres、MySQL。Web 应用通常的首选。
Diesel 同步(diesel-async 提供异步) 检查,整个查询构建器都有类型检查 ORM 加查询构建器 你希望编译器检查每一条查询,也不介意学它的查询写法。非常成熟。
SeaORM 异步(也有 sea-orm-sync) 检查类型,不检查 SQL(运行时才检查) 是,完整的 ORM 你想要实体、关联关系,以及像 Entity Framework 那样“把这个对象保存起来”。基于 sqlx。

这四个都很成熟,用的人也很多。如果拿不定主意,先从 sqlx 开始:你写的是普通的 SQL,学到的东西换到哪种语言都用得上。如果你是从 C# 转过来的,面向 C# 开发者的 Rust把 Entity Framework、Dapper 和 ADO.NET 一一对应到了这些 crate。

先看结构:用纯 Rust 写一个仓储

Section titled “先看结构:用纯 Rust 写一个仓储”

在用任何数据库之前,先来看一个几乎所有应用都会用到的模式:仓储(repository)。它是程序里唯一知道怎么保存和读取数据的地方。程序的其他部分只调用 add、list_open 这样的方法,并不关心数据是放在 HashMap 里、SQLite 里还是 Postgres 里。

在 Rust 里,用一个 trait 描述仓储能做什么,再用一个结构体来干活。这里的存储是内存里的一个 HashMap:

use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
done: bool,
}
// 应用需要存储层提供的功能。这里完全没说数据“怎么”存。
trait TaskRepository {
fn add(&mut self, title: &str) -> u32;
fn mark_done(&mut self, id: u32) -> Result<(), String>;
fn list_open(&self) -> Vec<Task>;
}
struct InMemoryRepo {
tasks: HashMap<u32, Task>,
next_id: u32,
}
impl TaskRepository for InMemoryRepo {
fn add(&mut self, title: &str) -> u32 {
let id = self.next_id;
self.tasks.insert(id, Task { id, title: title.to_string(), done: false });
self.next_id += 1;
id
}
fn mark_done(&mut self, id: u32) -> Result<(), String> {
match self.tasks.get_mut(&id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("没有 id 为 {id} 的任务")),
}
}
fn list_open(&self) -> Vec<Task> {
let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect();
open.sort_by_key(|t| t.id); // HashMap 没有顺序,所以要排序
open
}
}
// 适用于“任何”仓储:今天放在内存里,明天换成 SQLite 或 Postgres。
fn print_open(repo: &impl TaskRepository) {
for task in repo.list_open() {
println!("[ ] {}: {}", task.id, task.title);
}
}
fn main() {
let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 };
repo.add("Buy milk");
repo.add("Learn sqlx");
repo.add("Walk the dog");
repo.mark_done(1).unwrap();
print_open(&repo);
println!("{:?}", repo.mark_done(99));
}

输出:

[ ] 2: Learn sqlx
[ ] 3: Walk the dog
Err("没有 id 为 99 的任务")
▶ 动手试一试

任务标题保留了英文,这样和后面 sqlx 示例的真实输出能对上。想想换成数据库之后会变成什么样:HashMap 变成一张表,每个 Task 变成一行,next_id 变成自动编号的 id 列,list_open 变成一条带 WHERE done = FALSE ORDER BY id 的查询。这个内存版本以后也很有用:写测试时,它就是一个速度很快的替身。

用 sqlx 操作 SQLite:你的第一个真正的数据库

Section titled “用 sqlx 操作 SQLite:你的第一个真正的数据库”

SQLite 是一个装在一个文件里(甚至只放在内存里)的完整数据库。不需要安装服务器,所以非常适合学习、写测试,很多真实的应用也在用它。创建一个项目,添加 sqlx 和 tokio:

Terminal window
cargo new todo-db
cd todo-db
cargo add sqlx --features runtime-tokio,sqlite
cargo add tokio --features full
[dependencies]
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["full"] }

sqlite feature 会把 SQLite 直接编译进你的程序,不用另外安装任何东西。sqlx 是异步的,所以需要 tokio。现在把 src/main.rs 替换成:

use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// 内存数据库:非常适合学习,程序一结束就消失。
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&pool)
.await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&pool)
.await?;
println!("Inserted task {}", result.last_insert_rowid());
}
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(1_i64)
.execute(&pool)
.await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id")
.fetch_all(&pool)
.await?;
for task in &tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.title);
}
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?")
.bind(42_i64)
.fetch_optional(&pool)
.await?;
println!("Task 42: {open:?}");
Ok(())
}

用 cargo run 运行:

Inserted task 1
Inserted task 2
Inserted task 3
[x] 1: Buy milk
[ ] 2: Learn sqlx
[ ] 3: Walk the dog
Task 42: None

一步一步来看:

  • sqlx::query("...") 里放的是 SQL。每个 ? 都是一个占位符(placeholder),.bind(value) 按顺序把值填进去。
  • .execute(&pool) 执行不返回数据行的语句(CREATE、INSERT、UPDATE、DELETE)。它的结果会告诉你 rows_affected(),对 SQLite 还有 last_insert_rowid()。
  • #[derive(sqlx::FromRow)] 让 sqlx 把每一行转换成一个 Task。列名必须和字段名一致。
  • query_as 加 fetch_all 得到一个 Vec<Task>。必须恰好有一行时用 fetch_one,可能一行都没有时用 fetch_optional。注意,找不到的那一行变成了 None(一个 Option),程序并没有崩溃。
  • 每个调用都返回 Result,所以 ? 会把数据库错误一路传给 main。

如果想在程序结束后保留数据,就改用文件:SqlitePool::connect("sqlite:todos.db?mode=rwc")。mode=rwc 的意思是“读、写,文件不存在就创建”。

编译时检查的查询。 sqlx 还提供了宏,比如 sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done)。编译时,这个宏会连接你的开发数据库(地址来自 DATABASE_URL)来检查 SQL。像 titel 这样的拼写错误,会在任何用户看到之前就让编译失败,报 no such column: titel。这要求编译时有一个建好了表的数据库,所以先用普通的 query 函数,等有了迁移(见下文)再加上这些宏。

Postgres(PostgreSQL)是 Web 应用最常用的数据库。它以服务器的形式运行,所以你需要先让一个服务器跑起来:从 postgresql.org(英文)下载安装,用云数据库,或者用 Docker 启动一个:

Terminal window
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres

代码几乎不用改。打开 postgres feature 即可(如果用云数据库,再加一个 TLS feature,比如 tls-rustls,因为云数据库通常要求加密连接):

Terminal window
cargo add sqlx --features runtime-tokio,postgres,tls-rustls

然后用连接字符串来连接。连接字符串是一个 URL,写明了数据库在哪里、怎么登录:

postgres://USER:PASSWORD@HOST:PORT/DATABASE
postgres://postgres:secret@localhost:5432/postgres

千万不要把密码写进代码里。把 URL 放进环境变量 DATABASE_URL,在程序启动时读取(见环境变量):

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&url)
.await?;
// Postgres 的占位符带编号:$1、$2……
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1")
.bind(false)
.fetch_all(&pool)
.await?;

和 SQLite 相比,有两点不同要记住:Postgres 的占位符是 $1、$2,而不是 ?;自动编号的 id 通常写成 BIGINT GENERATED ALWAYS AS IDENTITY(或 BIGSERIAL),对应 Rust 的 i64。如果密码里有 @ 或 / 这样的字符,要在 URL 里对它们做百分号编码(@ 写成 %40)。sqlx-cli 等工具也会从项目文件夹里的 .env 文件读取 DATABASE_URL。记得把 .env 加进 .gitignore,免得它被传到 GitHub 上。

表结构会随着时间变化:这里加一列,那里加一张表。迁移(migration)就是一组带编号的 SQL 文件,每个文件描述一次变更。每个数据库(你的、同事的、生产环境的)都按同样的顺序执行同样的文件,数据库自己会记住哪些已经执行过。先安装一次命令行工具:

Terminal window
# 支持所有数据库
cargo install sqlx-cli
# 或者只装你需要的,编译更快
cargo install sqlx-cli --no-default-features --features sqlite,rustls

然后在项目文件夹里执行:

Terminal window
echo "DATABASE_URL=sqlite:todos.db" > .env
sqlx database create
sqlx migrate add create_tasks
Creating migrations/20261018035858_create_tasks.sql

(你看到的编号会不一样,它是当前的日期和时间。)把变更写进这个文件:

CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
);

然后应用它:

Terminal window
sqlx migrate run
Applied 20261018035858/migrate create tasks (1.449995ms)

再运行一次,什么也不会发生,因为这个迁移已经应用过了。sqlx migrate info 会列出哪些已经应用、哪些还在等待。已经在某个地方执行过的迁移,千万不要再改;要改就新加一个迁移。

你也可以让程序在启动时自动应用迁移。migrate! 宏会把 ./migrations 里的文件直接打包进你的二进制文件:

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!().run(&pool).await?; // 只执行这个数据库还没执行过的迁移

Diesel 走的是另一条路:你不写 SQL 字符串,而是用 Rust 方法来构建查询,编译器会对照表结构的描述来检查它们。比如 done 是一个 bool 列,所以 tasks::done.eq(true) 能通过编译,而 tasks::done.eq("yes") 就是编译错误。Diesel 是同步的,对工具和很多服务器来说完全够用。

[dependencies]
diesel = { version = "2.3", features = ["sqlite"] }
# 把 SQLite 编译进你的程序。只有在你没装 SQLite 时才需要:
libsqlite3-sys = { version = "0.38", features = ["bundled"] }
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
// 正常情况下,`diesel migration run` 会帮你把这段代码生成到 src/schema.rs 里。
diesel::table! {
tasks (id) {
id -> Integer,
title -> Text,
done -> Bool,
}
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = tasks)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
struct Task {
id: i32,
title: String,
done: bool,
}
#[derive(Insertable)]
#[diesel(table_name = tasks)]
struct NewTask<'a> {
title: &'a str,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut conn = SqliteConnection::establish(":memory:")?;
// 这一步通常由迁移来做。
diesel::sql_query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }];
diesel::insert_into(tasks::table)
.values(&new_tasks[..])
.execute(&mut conn)?;
diesel::update(tasks::table.find(1))
.set(tasks::done.eq(true))
.execute(&mut conn)?;
let all: Vec<Task> = tasks::table
.order(tasks::id)
.select(Task::as_select())
.load(&mut conn)?;
for task in &all {
println!("{} {} done={}", task.id, task.title, task.done);
}
let open: i64 = tasks::table
.filter(tasks::done.eq(false))
.count()
.get_result(&mut conn)?;
println!("{open} task(s) still open");
Ok(())
}
1 Buy milk done=true
2 Learn Diesel done=false
1 task(s) still open

在真实项目里,你会先安装 diesel_cli(cargo install diesel_cli --no-default-features --features sqlite),然后依次执行 diesel setup、diesel migration generate create_tasks 和 diesel migration run。CLI 会替你把 table! 代码写进 src/schema.rs,所以它永远和数据库保持一致。Getting Started 指南(英文)从头到尾讲解了整个流程。

SeaORM 是一个异步 ORM:你把每张表描述成一个实体(entity,一个带属性的结构体),然后操作对象,而不是写 SQL。用起来很像 Entity Framework。2.0 版本甚至可以根据实体直接创建表。

[dependencies]
sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }
tokio = { version = "1", features = ["full"] }
mod task {
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "task")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub done: bool,
}
impl ActiveModelBehavior for ActiveModel {}
}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]
async fn main() -> Result<(), DbErr> {
let db = &Database::connect("sqlite::memory:").await?;
// 为这个 crate 里的每个实体建表。"seaorm_demo" 是 Cargo.toml 里的
// 包名,其中的 - 换成了 _。
db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] {
task::ActiveModel {
title: Set(title.to_owned()),
done: Set(false),
..Default::default()
}
.insert(db)
.await?;
}
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model();
milk.done = Set(true);
milk.update(db).await?;
let open = task::Entity::find()
.filter(task::Column::Done.eq(false))
.all(db)
.await?;
for t in &open {
println!("open: {} {}", t.id, t.title);
}
Ok(())
}
open: 2 Learn SeaORM

Model 是你读出来的一行数据。ActiveModel 是你正在修改的一行:Set(...) 标记要保存的字段,..Default::default() 把 id 留给数据库来填。项目大一些的时候,sea-orm-cli 可以根据现有数据库生成实体,还能管理迁移。详见 SeaORM 文档。

这是这一页最重要的一条规则:千万不要用 format! 把用户输入直接拼进 SQL 字符串。原因用纯 Rust 就能说明白(不需要数据库):

// 数据库收到的内容,用两种不同的方式构建。
fn pasted_query(name: &str) -> String {
format!("SELECT * FROM users WHERE name = '{name}'")
}
fn bound_query(name: &str) -> (&'static str, Vec<String>) {
("SELECT * FROM users WHERE name = ?", vec![name.to_string()])
}
fn main() {
for input in ["ana", "x' OR '1'='1"] {
println!("input: {input}");
println!(" pasted: {}", pasted_query(input));
let (sql, params) = bound_query(input);
println!(" bound: {sql} params: {params:?}");
}
}

输出:

input: ana
pasted: SELECT * FROM users WHERE name = 'ana'
bound: SELECT * FROM users WHERE name = ? params: ["ana"]
input: x' OR '1'='1
pasted: SELECT * FROM users WHERE name = 'x' OR '1'='1'
bound: SELECT * FROM users WHERE name = ? params: ["x' OR '1'='1"]
▶ 动手试一试

这里的输出保留了英文,因为 pasted: 和 bound: 后面的空格是为了让两行 SQL 对齐。直接拼接时(pasted),输入里的单引号提前结束了字符串,于是 OR '1'='1' 变成了 SQL 的一部分。这个条件永远为真,所以查询会返回所有用户。攻击者用同样的手法,可以读取别人的数据,甚至删掉整张表。这就叫 SQL 注入(SQL injection),是 Web 上最常见的安全漏洞之一。

用绑定参数时(bound),SQL 文本永远不变。输入是单独发送的,数据库只把它当成一个值。下面是用 sqlx 写的两个版本,在一张真实的 SQLite 表上运行,表里有 ana 和 root 两个用户:

let user_input = "nobody' OR '1'='1"; // 假设这是从登录表单传来的
// 不要这样做:把输入拼进 SQL 文本。
let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");
let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;
println!("format!: {rows:?}");
// 应该这样做:固定的 SQL 文本加 ? 占位符,输入单独发送。
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?")
.bind(user_input)
.fetch_all(&pool)
.await?;
println!("bind: {rows:?}");
format!: [("ana",), ("root",)]
bind: []

拼接的查询把两个用户都泄露了,绑定参数的查询则正确地发现:没有人叫这个奇怪的名字。注意坏版本里的 AssertSqlSafe(...) 包装:sqlx 0.9 默认就会保护你。sqlx::query 和 query_as 只接受固定的 &'static str,所以不加这个包装就传入 format! 生成的字符串,会编译失败:

error[E0277]: dynamic SQL strings should be audited for possible injections
= help: the trait `SqlSafeStr` is not implemented for `&std::string::String`
= note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.

这条错误的意思是:动态拼出来的 SQL 字符串应该先经过审查,确认没有注入风险;更推荐的做法是用字面量 SQL 加绑定参数,或者用 QueryBuilder 往查询里加入动态数据。

那什么时候才会用到 AssertSqlSafe?只有在你确实必须在运行时构建 SQL 的时候,比如让用户选择按哪一列排序。先拿这个值和你自己写好的一份允许名单做对比,再用它包住字符串,告诉 sqlx 你已经审查过了。至于值,永远用 .bind()。Diesel 和 SeaORM 会替你绑定值,前提是你用它们的查询构建器,而不是原始 SQL 字符串。

打开一个数据库连接很慢:要走一趟网络、登录,还要做些初始化。连接池(pool)会一次性打开几个连接,然后把它们借出去。你的代码为一次查询借用一个连接,用完马上还回去。这就是为什么上面每个示例传的都是 &pool,而不是单个连接。

use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new()
.filename("visits.db")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4) // 同时最多 4 个连接
.connect_with(options)
.await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)")
.execute(&pool)
.await?;
// 20 个任务共享一个连接池。克隆连接池的开销很小:它只是一个句柄。
let mut handles = Vec::new();
for n in 0..20 {
let pool = pool.clone();
handles.push(tokio::spawn(async move {
sqlx::query("INSERT INTO visits (page) VALUES (?)")
.bind(format!("/page/{}", n % 3))
.execute(&pool)
.await
}));
}
for handle in handles {
handle.await.expect("task panicked")?; // 等待 20 次插入全部完成
}

这 20 个任务同时运行,但任何时刻最多只有 4 个在和数据库通信,其他任务会稍等一会儿,直到有空闲的连接。之后统计行数(用一个全新的 visits.db),得到:

/page/0: 7 visits
/page/1: 7 visits
/page/2: 6 visits

几条经验法则:

  • 在程序启动时创建一个连接池,然后共享它。在 axum 应用里,把它放进应用状态(见 Web API 项目)。
  • 不要每个请求都新建一个连接池,那样连接池的好处就全没了。
  • max_connections 要远低于数据库允许的上限。Postgres 服务器默认允许 100 个连接,而你的应用每运行一个实例,就有一个自己的连接池。
  • 设置 acquire_timeout。这样当所有连接都在忙时,请求会带着清楚的错误失败,而不是永远等下去。

沿用内存仓储的思路,加一个 search 方法:返回标题里包含某个单词的任务(不区分大小写),按 id 排序。用 “milk” 测试一下。

提示:title.to_lowercase().contains(&word.to_lowercase()),这里用 Vec<Task> 存储就够了。

查看答案
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
}
trait TaskRepository {
fn add(&mut self, title: &str) -> u32;
fn search(&self, word: &str) -> Vec<Task>;
}
struct VecRepo {
tasks: Vec<Task>,
}
impl TaskRepository for VecRepo {
fn add(&mut self, title: &str) -> u32 {
let id = self.tasks.len() as u32 + 1;
self.tasks.push(Task { id, title: title.to_string() });
id
}
fn search(&self, word: &str) -> Vec<Task> {
let word = word.to_lowercase();
let mut found: Vec<Task> = self
.tasks
.iter()
.filter(|t| t.title.to_lowercase().contains(&word))
.cloned()
.collect();
found.sort_by_key(|t| t.id);
found
}
}
fn main() {
let mut repo = VecRepo { tasks: Vec::new() };
repo.add("Buy milk");
repo.add("Walk the dog");
repo.add("Milk the cow");
for task in repo.search("milk") {
println!("{}: {}", task.id, task.title);
}
println!("搜索“cat”:找到 {} 个结果", repo.search("cat").len());
}

输出:

1: Buy milk
3: Milk the cow
搜索“cat”:找到 0 个结果
▶ 动手试一试

任务标题保留英文,是因为这个练习要演示大小写不敏感的搜索(“milk”和“Milk”),而中文没有大小写之分。存储从 HashMap 换成了 Vec,但调用 trait 的代码完全不需要知道。换成 SQL,同样的搜索就是 WHERE title LIKE ? 加上 .bind(format!("%{word}%"))。注意,% 要放进绑定的值里,而不是写进 SQL 文本。

2. 用 sqlx 写一个真正的仓储(在你的计算机上运行)

Section titled “2. 用 sqlx 写一个真正的仓储(在你的计算机上运行)”

写一个结构体 SqliteTaskRepo,里面放一个 SqlitePool,并实现三个 async 方法:add(title) 返回新任务的 id,mark_done(id) 在有行被修改时返回 true,list_open() 返回所有未完成的任务。在 main 里建表,添加三个任务,然后把任务 1 和任务 99 标记为完成。

提示:execute 返回的结果上有 rows_affected(),它会告诉你一条 UPDATE 修改了多少行。

查看答案
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
struct SqliteTaskRepo {
pool: SqlitePool,
}
impl SqliteTaskRepo {
async fn add(&self, title: &str) -> Result<i64, sqlx::Error> {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&self.pool)
.await?;
Ok(result.last_insert_rowid())
}
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id")
.fetch_all(&self.pool)
.await
}
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)")
.execute(&pool)
.await?;
let repo = SqliteTaskRepo { pool };
repo.add("Buy milk").await?;
repo.add("Learn sqlx").await?;
repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?);
println!("mark 99 done: {}", repo.mark_done(99).await?);
for task in repo.list_open().await? {
println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title);
}
Ok(())
}
mark 1 done: true
mark 99 done: false
[ ] 2: Learn sqlx
[ ] 3: Walk the dog

结构和内存版本一模一样,只是方法变成了 async,并且返回 Result,因为真正的数据库是可能出错的(磁盘满了、网络断了)。下一步:把你的 Web API 项目里的 Vec 换成这个仓储。

  • 简单的同步 SQLite 用 rusqlite;想用异步、直接写 SQL 用 sqlx(通常的首选);想要编译时检查的查询构建器用 Diesel;想要像 Entity Framework 那样的异步 ORM 用 SeaORM。
  • 仓储把“数据存在哪里”藏在几个方法后面。先用 HashMap,以后再换成数据库。
  • 在 sqlx 里:修改数据用 query + .bind() + .execute();读取数据用 query_as + fetch_all / fetch_one / fetch_optional,把行读进带 #[derive(FromRow)] 的结构体。
  • 连接字符串放在 DATABASE_URL 里,不要写进代码。SQLite 用 ? 占位符,Postgres 用 $1、$2。
  • 修改表结构要用迁移(sqlx migrate add / run),不要手动改。
  • 用户输入一定要绑定,永远不要用 format! 拼进 SQL。在启动时创建一个连接池,然后共享它。

小测验

1. 你想直接写 SQL、使用异步,并且可以选择让查询在编译时检查。哪个 crate 最合适?

2. 查询可能返回 0 行或 1 行时,应该用 sqlx 的哪个方法?

3. 为什么 format!("... WHERE name = '{name}'") 很危险?

4. 连接池的作用是什么?

得分: 0 / 4

  • sqlx — docs.rs(英文):API 参考,包括 query、query_as 和 query! 宏、连接池,以及所有支持的数据库。
  • sqlx-cli — docs.rs(英文):如何安装这个 CLI、创建数据库、执行和回滚迁移,以及为 query! 宏使用离线模式。
  • Getting Started with Diesel(英文):官方的分步指南,从 diesel setup 一直讲到插入、更新和删除数据行。
  • SeaORM 文档:实体、关联关系、迁移,以及 sea-orm-cli 代码生成器。
  • OWASP SQL Injection Prevention Cheat Sheet(英文):SQL 注入的原理,以及参数化查询如何阻止它,适用于任何语言。