Rust 数据库:用 sqlx、Diesel 和 SeaORM 操作 SQLite 与 Postgres
程序迟早需要记住东西:用户、订单、最高分、待办事项。程序一停止,Vec 和 HashMap 里的数据就没了。数据库会把数据安全地存在磁盘上,让多个程序共享,还能飞快地回答“把所有未完成的任务按时间倒序列出来”这样的问题。
Rust 有非常好用的数据库 crate。在这一页,你会先选一个 crate,然后写真正的代码:先用纯 Rust 写一个小小的“仓储(repository)”(可以直接在 Playground 里运行),然后用 sqlx 操作 SQLite、连接 Postgres、做迁移,最后简单了解一下 Diesel 和 SeaORM。Playground 里没有数据库 crate,所以这些示例要在你自己的计算机上用 cargo run 运行。每个示例我们都实际编译运行过,展示的输出都是真实的。
能用它做什么
Section titled “能用它做什么”- Web API 的数据层:把项目:构建 Web API 里的待办事项服务器改一改,让数据存进 Postgres,而不是放在内存里。
- 访问量很大的网站:托管所有 Rust crate 的 crates.io 就是通过 Diesel 把数据存在 Postgres 里的。开源社区平台 Lemmy(英文)也是这样。
- 桌面工具和命令行工具,用一个本地 SQLite 文件存数据:笔记应用、记账工具、缓存。
- 后台任务和数据处理任务:读出数据行,处理后再把结果写回去。
也有比较难的地方:Rust 没有像 .NET 的 Entity Framework 或 Rails 的 ActiveRecord 那样唯一的“官方”方案,你需要在几个不错的 crate 之间做选择。第一次编译数据库 crate 要花一两分钟。另外,编译时检查 SQL(一个很棒的功能)需要先做一点配置。
选哪个 crate?
Section titled “选哪个 crate?”| 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 dogErr("没有 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:
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo 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 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 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
Section titled “连接 Postgres”Postgres(PostgreSQL)是 Web 应用最常用的数据库。它以服务器的形式运行,所以你需要先让一个服务器跑起来:从 postgresql.org(英文)下载安装,用云数据库,或者用 Docker 启动一个:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres代码几乎不用改。打开 postgres feature 即可(如果用云数据库,再加一个 TLS feature,比如 tls-rustls,因为云数据库通常要求加密连接):
cargo add sqlx --features runtime-tokio,postgres,tls-rustls然后用连接字符串来连接。连接字符串是一个 URL,写明了数据库在哪里、怎么登录:
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://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 上。
用 sqlx-cli 做迁移
Section titled “用 sqlx-cli 做迁移”表结构会随着时间变化:这里加一列,那里加一张表。迁移(migration)就是一组带编号的 SQL 文件,每个文件描述一次变更。每个数据库(你的、同事的、生产环境的)都按同样的顺序执行同样的文件,数据库自己会记住哪些已经执行过。先安装一次命令行工具:
# 支持所有数据库cargo install sqlx-cli
# 或者只装你需要的,编译更快cargo install sqlx-cli --no-default-features --features sqlite,rustls然后在项目文件夹里执行:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(你看到的编号会不一样,它是当前的日期和时间。)把变更写进这个文件:
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);然后应用它:
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)再运行一次,什么也不会发生,因为这个迁移已经应用过了。sqlx migrate info 会列出哪些已经应用、哪些还在等待。已经在某个地方执行过的迁移,千万不要再改;要改就新加一个迁移。
你也可以让程序在启动时自动应用迁移。migrate! 宏会把 ./migrations 里的文件直接打包进你的二进制文件:
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // 只执行这个数据库还没执行过的迁移一分钟了解 Diesel
Section titled “一分钟了解 Diesel”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=true2 Learn Diesel done=false1 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
Section titled “一分钟了解 SeaORM”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 SeaORMModel 是你读出来的一行数据。ActiveModel 是你正在修改的一行:Set(...) 标记要保存的字段,..Default::default() 把 id 留给数据库来填。项目大一些的时候,sea-orm-cli 可以根据现有数据库生成实体,还能管理迁移。详见 SeaORM 文档。
SQL 注入:永远使用绑定参数
Section titled “SQL 注入:永远使用绑定参数”这是这一页最重要的一条规则:千万不要用 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。这样当所有连接都在忙时,请求会带着清楚的错误失败,而不是永远等下去。
1. 在仓储里搜索
Section titled “1. 在仓储里搜索”沿用内存仓储的思路,加一个 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 milk3: 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: truemark 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 注入的原理,以及参数化查询如何阻止它,适用于任何语言。