Bỏ qua để đến nội dung

Cơ sở dữ liệu trong Rust: SQLite và Postgres với sqlx, Diesel, SeaORM

Sớm hay muộn, chương trình của bạn cũng cần ghi nhớ thứ gì đó: người dùng, đơn hàng, điểm cao, danh sách việc cần làm. Khi chương trình dừng, một Vec hay một HashMap sẽ mất sạch. Một cơ sở dữ liệu (database) giữ dữ liệu an toàn trên đĩa, cho nhiều chương trình dùng chung, và trả lời rất nhanh những câu hỏi như “cho tôi xem mọi việc chưa xong, mới nhất trước”.

Rust có những crate cơ sở dữ liệu rất tốt. Trên trang này, bạn sẽ chọn một crate rồi viết code thật: một “repository” nhỏ bằng Rust thuần (chạy ngay tại đây trên Playground), sau đó là SQLite với sqlx, Postgres, migration, và một vòng ngắn qua Diesel và SeaORM. Các crate cơ sở dữ liệu không có trên Playground, nên những ví dụ đó dành cho máy của bạn với cargo run. Tất cả đều đã được build và chạy, và kết quả hiển thị là kết quả thật.

  • Tầng dữ liệu của một web API. Lấy server to-do trong Dự án: Web API với Axum và lưu dữ liệu của nó trong Postgres thay vì trong bộ nhớ.
  • Những trang web lớn, đông người dùng. crates.io, trang lưu trữ mọi crate Rust, lưu dữ liệu trong Postgres thông qua Diesel. Lemmy (tiếng Anh), một nền tảng cộng đồng mã nguồn mở, cũng vậy.
  • Ứng dụng desktop và công cụ dòng lệnh dùng một file SQLite cục bộ: ứng dụng ghi chú, công cụ quản lý chi tiêu, một bộ nhớ đệm (cache).
  • Worker chạy nền và các job xử lý dữ liệu: đọc các dòng, xử lý chúng, rồi ghi kết quả trở lại.

Chỗ khó hơn: Rust không có một bộ công cụ “chính thức” duy nhất như Entity Framework trong .NET hay ActiveRecord trong Rails, nên bạn phải chọn giữa vài crate tốt. Lần build đầu tiên của một crate cơ sở dữ liệu mất một hai phút. Và SQL được kiểm tra lúc biên dịch (một tính năng tuyệt vời) cần cài đặt thêm một chút.

Crate Đồng bộ hay bất đồng bộ Kiểm tra truy vấn lúc biên dịch? ORM? Chọn nó khi
rusqlite đồng bộ (sync) không không, SQL thuần Một công cụ nhỏ hoặc ứng dụng desktop với một file SQLite. Không cần async.
sqlx bất đồng bộ (async) có, với các macro query! (tùy chọn) không, SQL thuần Bạn thích viết SQL và muốn async. SQLite, Postgres, MySQL. Lựa chọn đầu tiên thường gặp cho ứng dụng web.
Diesel đồng bộ (diesel-async thêm async) có, toàn bộ query builder được kiểm tra kiểu ORM và query builder Bạn muốn trình biên dịch kiểm tra mọi truy vấn và không ngại học cách viết truy vấn của nó. Rất trưởng thành.
SeaORM bất đồng bộ (có sea-orm-sync) kiểu thì có, SQL thì không (kiểm tra lúc chạy) có, ORM đầy đủ Bạn muốn entity, quan hệ và kiểu “lưu object này” giống Entity Framework. Xây trên sqlx.

Cả bốn đều đã trưởng thành và được dùng rộng rãi. Nếu chưa chắc, hãy bắt đầu với sqlx: bạn viết SQL bình thường, nên những gì bạn học được dùng được ở mọi ngôn ngữ. Bạn đến từ C#? Trang Rust cho lập trình viên C# đối chiếu Entity Framework, Dapper và ADO.NET với các crate này.

Hình dạng trước: một repository bằng Rust thuần

Phần tiêu đề “Hình dạng trước: một repository bằng Rust thuần”

Trước khi đụng đến cơ sở dữ liệu, hãy xem một mẫu mà gần như ứng dụng nào cũng dùng: repository. Đó là một chỗ duy nhất biết cách lưu và tải dữ liệu của bạn. Phần còn lại của chương trình chỉ gọi các phương thức như add và list_open, và không quan tâm dữ liệu nằm trong một HashMap, trong SQLite hay trong Postgres.

Trong Rust, một trait mô tả những gì repository làm được, còn một struct thực hiện công việc. Ở đây nơi lưu trữ là một HashMap trong bộ nhớ:

use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
done: bool,
}
// Những gì ứng dụng cần từ nơi lưu trữ. Không có gì ở đây nói lưu NHƯ THẾ NÀO.
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!("không có việc nào với 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 không có thứ tự, nên phải sắp xếp
open
}
}
// Chạy được với BẤT KỲ repository nào: hôm nay trong bộ nhớ, mai là SQLite hay 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("Mua sữa");
repo.add("Học sqlx");
repo.add("Dắt chó đi dạo");
repo.mark_done(1).unwrap();
print_open(&repo);
println!("{:?}", repo.mark_done(99));
}

Kết quả:

[ ] 2: Học sqlx
[ ] 3: Dắt chó đi dạo
Err("không có việc nào với id 99")
▶ Tự chạy thử

Hãy nhìn xem một cơ sở dữ liệu sẽ thay thế những gì ở đây. HashMap trở thành một bảng (table), mỗi Task trở thành một dòng (row), và next_id trở thành một cột id tự động đánh số. list_open trở thành một truy vấn với WHERE done = FALSE ORDER BY id. Phiên bản trong bộ nhớ cũng có ích về sau: nó là một bản giả (fake) chạy nhanh cho các bài test của bạn.

sqlx với SQLite: cơ sở dữ liệu thật đầu tiên của bạn

Phần tiêu đề “sqlx với SQLite: cơ sở dữ liệu thật đầu tiên của bạn”

SQLite là cả một cơ sở dữ liệu nằm gọn trong một file (hoặc chỉ trong bộ nhớ). Không có server nào phải cài, nên nó rất hợp để học, để viết test, và cho nhiều ứng dụng thật. Tạo một project rồi thêm sqlx và 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"] }

Feature sqlite build SQLite vào thẳng chương trình của bạn, nên bạn không cần cài gì thêm. sqlx là bất đồng bộ (async), vì vậy nó cần tokio. Giờ thay nội dung 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> {
// Cơ sở dữ liệu trong bộ nhớ: rất hợp để học, biến mất khi chương trình kết thúc.
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(())
}

Chạy bằng 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

Các chuỗi trong ví dụ này được giữ nguyên tiếng Anh, vì kết quả ở trên là kết quả thật của đúng đoạn code đó. Từng bước một:

  • sqlx::query("...") chứa câu SQL. Mỗi dấu ? là một placeholder (chỗ giữ chỗ), và .bind(value) lần lượt điền giá trị vào theo thứ tự.
  • .execute(&pool) chạy một câu lệnh không trả về dòng nào (CREATE, INSERT, UPDATE, DELETE). Kết quả cho bạn biết rows_affected(), và với SQLite còn có last_insert_rowid().
  • #[derive(sqlx::FromRow)] cho phép sqlx biến mỗi dòng thành một Task. Tên cột phải khớp với tên field.
  • query_as + fetch_all cho bạn một Vec<Task>. Dùng fetch_one khi chắc chắn phải có đúng một dòng, và fetch_optional khi có thể không có dòng nào. Để ý cách một dòng không tồn tại trở thành None, một Option, chứ không làm chương trình crash.
  • Mọi lời gọi đều trả về một Result, nên ? chuyển lỗi cơ sở dữ liệu lên cho main.

Muốn giữ dữ liệu sau khi chương trình kết thúc, hãy dùng một file: SqlitePool::connect("sqlite:todos.db?mode=rwc"). Phần mode=rwc nghĩa là “đọc, ghi, và tạo file nếu chưa có”.

Truy vấn được kiểm tra lúc biên dịch. sqlx còn có các macro như sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). Lúc build, macro kết nối tới cơ sở dữ liệu phát triển của bạn (lấy từ DATABASE_URL) và kiểm tra câu SQL. Một lỗi gõ như titel sẽ chặn bản build với thông báo no such column: titel, trước khi bất kỳ người dùng nào nhìn thấy. Việc này cần một cơ sở dữ liệu có sẵn các bảng của bạn trong lúc build, nên hãy bắt đầu với các hàm query thường, rồi thêm macro khi bạn đã có migration (xem bên dưới).

Postgres (PostgreSQL) là lựa chọn phổ biến nhất cho ứng dụng web. Nó chạy như một server, nên bạn cần có một server đang chạy: cài từ postgresql.org (tiếng Anh), dùng một cơ sở dữ liệu trên cloud, hoặc khởi động một cái bằng Docker:

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

Code gần như không đổi. Bật feature postgres (thêm một feature TLS như tls-rustls cho cơ sở dữ liệu trên cloud, vì chúng thường yêu cầu mã hóa):

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

Sau đó kết nối bằng một connection string (chuỗi kết nối), một URL cho biết cơ sở dữ liệu ở đâu và đăng nhập thế nào:

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

Đừng bao giờ viết mật khẩu vào code. Hãy đặt URL vào biến môi trường DATABASE_URL và đọc nó lúc khởi động (xem Biến môi trường):

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 đánh số placeholder: $1, $2, ...
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1")
.bind(false)
.fetch_all(&pool)
.await?;

Hai điểm khác với SQLite cần nhớ: placeholder của Postgres là $1, $2 thay vì ?, và một cột id tự động đánh số thường là BIGINT GENERATED ALWAYS AS IDENTITY (hoặc BIGSERIAL), vừa với kiểu i64. Nếu mật khẩu có những ký tự như @ hay /, hãy percent-encode chúng trong URL (@ thành %40). Các công cụ như sqlx-cli cũng đọc DATABASE_URL từ file .env trong thư mục project. Hãy thêm .env vào .gitignore để nó không bao giờ lọt lên GitHub.

Các bảng của bạn sẽ thay đổi theo thời gian: chỗ này thêm một cột, chỗ kia thêm một bảng. Migration là những file SQL được đánh số, mô tả từng thay đổi. Mọi cơ sở dữ liệu (của bạn, của đồng nghiệp, của production) chạy cùng những file đó theo cùng thứ tự, và cơ sở dữ liệu nhớ những file nào nó đã chạy rồi. Cài công cụ dòng lệnh một lần:

Terminal window
# tất cả các loại cơ sở dữ liệu
cargo install sqlx-cli
# hoặc chỉ những gì bạn cần, để build nhanh hơn
cargo install sqlx-cli --no-default-features --features sqlite,rustls

Rồi, trong thư mục project:

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

(Con số của bạn sẽ khác: đó là ngày giờ tạo file.) Viết thay đổi vào file đó:

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

Và áp dụng nó:

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

Chạy lại lần nữa thì không có gì xảy ra, vì migration đó đã được áp dụng. sqlx migrate info liệt kê những gì đã áp dụng và những gì còn chờ. Đừng bao giờ sửa một migration đã chạy ở đâu đó; hãy thêm một migration mới.

Bạn cũng có thể để chương trình tự áp dụng migration khi khởi động. Macro migrate! nhúng các file trong ./migrations vào file thực thi của bạn:

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!().run(&pool).await?; // chỉ chạy những migration mà cơ sở dữ liệu này chưa thấy

Diesel đi một con đường khác: thay vì viết chuỗi SQL, bạn dựng truy vấn bằng các phương thức Rust, và trình biên dịch kiểm tra chúng dựa trên mô tả các bảng của bạn. Ví dụ, done là một cột bool, nên tasks::done.eq(true) biên dịch được, còn tasks::done.eq("yes") là lỗi biên dịch. Diesel là đồng bộ, điều này ổn cho các công cụ và cho nhiều server.

[dependencies]
diesel = { version = "2.3", features = ["sqlite"] }
# Build SQLite vào chương trình. Chỉ cần nếu máy bạn chưa cài SQLite:
libsqlite3-sys = { version = "0.38", features = ["bundled"] }
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
// Bình thường `diesel migration run` sinh đoạn này vào src/schema.rs cho bạn.
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:")?;
// Bình thường một migration sẽ làm việc này.
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

Trong một project thật, bạn cài diesel_cli (cargo install diesel_cli --no-default-features --features sqlite), rồi chạy diesel setup, diesel migration generate create_tasks và diesel migration run. CLI tự viết src/schema.rs với đoạn code table! cho bạn, nên nó luôn khớp với cơ sở dữ liệu. Hướng dẫn Getting Started (tiếng Anh) đi qua tất cả các bước đó.

SeaORM là một ORM bất đồng bộ: bạn mô tả mỗi bảng thành một entity (một struct có các attribute), và làm việc với object thay vì SQL. Cảm giác rất gần với Entity Framework. Phiên bản 2.0 còn có thể tạo bảng từ các entity của bạn.

[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?;
// Tạo bảng cho mọi entity trong crate này. "seaorm_demo" là
// tên package trong Cargo.toml, với - đổi thành _.
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 là một dòng bạn đọc ra. ActiveModel là một dòng bạn đang thay đổi: Set(...) đánh dấu những field cần lưu, còn ..Default::default() để id cho cơ sở dữ liệu tự điền. Với các project lớn hơn, sea-orm-cli sinh entity từ một cơ sở dữ liệu có sẵn và quản lý migration. Xem tài liệu SeaORM (tiếng Anh).

Đây là quy tắc quan trọng nhất trên trang này. Đừng bao giờ tạo câu SQL bằng cách dán dữ liệu người dùng nhập vào chuỗi bằng format!. Đây là lý do, bằng Rust thuần (không cần cơ sở dữ liệu):

// Những gì cơ sở dữ liệu nhận được, tạo theo hai cách khác nhau.
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:?}");
}
}

Kết quả:

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"]
▶ Tự chạy thử

Khi dán trực tiếp (pasted), dấu nháy trong dữ liệu nhập kết thúc chuỗi quá sớm, và OR '1'='1' trở thành một phần của câu SQL. Điều kiện đó luôn đúng, nên truy vấn trả về mọi người dùng. Kẻ tấn công có thể dùng cùng mẹo này để đọc dữ liệu của người khác hoặc xóa bảng. Đây gọi là SQL injection, một trong những lỗi bảo mật phổ biến nhất trên web.

Với bind parameter (bound), câu SQL không bao giờ thay đổi. Dữ liệu nhập đi riêng, và cơ sở dữ liệu chỉ coi nó là một giá trị. Đây là cả hai phiên bản với sqlx, chạy trên một bảng SQLite thật có hai người dùng ana và root:

let user_input = "nobody' OR '1'='1"; // hãy tưởng tượng giá trị này đến từ một form đăng nhập
// ĐỪNG: dán dữ liệu nhập vào câu 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:?}");
// NÊN: câu SQL cố định với placeholder ?, còn dữ liệu nhập gửi riêng.
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: []

Truy vấn dán trực tiếp làm lộ cả hai người dùng. Truy vấn dùng bind thì tìm đúng là không có ai mang cái tên kỳ lạ đó. Và hãy để ý lớp bọc AssertSqlSafe(...) trong phiên bản tệ: sqlx 0.9 bảo vệ bạn theo mặc định. sqlx::query và query_as chỉ nhận một &'static str cố định, nên truyền một chuỗi format! mà không có lớp bọc đó sẽ không biên dịch được:

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.

Trình biên dịch báo rằng chuỗi SQL động cần được kiểm tra xem có thể bị injection không, và khuyên dùng chuỗi SQL cố định với bind parameter. Vậy khi nào mới dùng AssertSqlSafe? Chỉ khi bạn thật sự phải tạo SQL lúc chạy, ví dụ một cột sắp xếp do người dùng chọn. Hãy kiểm tra giá trị đó với danh sách tên được phép do chính bạn cố định trước, rồi mới bọc chuỗi lại để báo cho sqlx biết bạn đã kiểm tra nó. Với giá trị, luôn dùng .bind(). Diesel và SeaORM tự bind giá trị cho bạn, miễn là bạn dùng query builder của chúng chứ không dùng chuỗi SQL thô.

Mở một kết nối cơ sở dữ liệu thì chậm: có một lượt đi qua mạng, một lần đăng nhập và một ít thiết lập. Một pool (vùng chứa kết nối) mở sẵn vài kết nối một lần rồi cho mượn. Code của bạn mượn một kết nối cho một truy vấn và trả lại ngay. Đó là lý do mọi ví dụ ở trên đều truyền &pool, chứ không phải một kết nối đơn lẻ.

use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new()
.filename("visits.db")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4) // không bao giờ quá 4 kết nối cùng lúc
.connect_with(options)
.await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)")
.execute(&pool)
.await?;
// 20 task dùng chung một pool. Clone một pool rất rẻ: nó chỉ là một handle.
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")?; // chờ cả 20 lệnh insert xong
}

20 task chạy cùng lúc, nhưng tại mỗi thời điểm chỉ có tối đa 4 task nói chuyện với cơ sở dữ liệu. Những task còn lại chờ một chút cho đến khi có kết nối rảnh. Đếm số dòng sau đó (với một file visits.db mới) cho ra:

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

Vài quy tắc kinh nghiệm:

  • Tạo một pool khi chương trình khởi động và dùng chung nó. Trong một ứng dụng axum, hãy đặt nó vào app state (xem dự án web API).
  • Đừng tạo pool mới cho mỗi request. Làm vậy là vứt bỏ toàn bộ lợi ích của pool.
  • max_connections nên thấp hơn nhiều so với mức cơ sở dữ liệu cho phép. Một server Postgres mặc định cho phép 100 kết nối, và mỗi bản sao ứng dụng của bạn đều có pool riêng.
  • Đặt acquire_timeout, để khi mọi kết nối đều bận, request sẽ thất bại với một lỗi rõ ràng thay vì chờ mãi.

Lấy ý tưởng repository trong bộ nhớ và thêm phương thức search: nó trả về những việc có tiêu đề chứa một từ, không phân biệt chữ hoa chữ thường, sắp xếp theo id. Thử với “milk”. Gợi ý: title.to_lowercase().contains(&word.to_lowercase()), và ở đây một Vec<Task> là đủ để lưu trữ.

Xem lời giải

Các tiêu đề được giữ tiếng Anh vì bài này đổi chữ hoa thành chữ thường để so sánh.

#[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!("{} kết quả cho \"cat\"", repo.search("cat").len());
}

Kết quả:

1: Buy milk
3: Milk the cow
0 kết quả cho "cat"
▶ Tự chạy thử

Nơi lưu trữ đã đổi từ HashMap sang Vec, nhưng code gọi qua trait không cần biết điều đó. Trong SQL, phép tìm kiếm tương tự là WHERE title LIKE ? với .bind(format!("%{word}%")). Lưu ý rằng các dấu % nằm trong giá trị được bind, không nằm trong câu SQL.

2. Một repository thật với sqlx (trên máy của bạn)

Phần tiêu đề “2. Một repository thật với sqlx (trên máy của bạn)”

Viết một struct SqliteTaskRepo chứa một SqlitePool và có ba phương thức async: add(title) trả về id mới, mark_done(id) trả về true nếu có một dòng thay đổi, và list_open() trả về những việc chưa xong. Tạo bảng trong main, thêm ba việc, rồi đánh dấu xong cho việc 1 và việc 99. Gợi ý: rows_affected() trên kết quả của execute cho biết một lệnh UPDATE đã thay đổi bao nhiêu dòng.

Xem lời giải
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

Vẫn là hình dạng của phiên bản trong bộ nhớ, chỉ khác là async và trả về Result, vì một cơ sở dữ liệu thật có thể gặp lỗi (đĩa đầy, mất mạng). Bước tiếp theo: thay Vec trong dự án web API của bạn bằng repository này.

  • rusqlite cho SQLite đồng bộ đơn giản, sqlx cho SQL thuần bất đồng bộ (lựa chọn đầu tiên thường gặp), Diesel cho query builder được kiểm tra lúc biên dịch, SeaORM cho một ORM bất đồng bộ giống Entity Framework.
  • Một repository giấu việc dữ liệu nằm ở đâu sau vài phương thức. Bắt đầu với một HashMap, sau này thay bằng cơ sở dữ liệu.
  • Trong sqlx: query + .bind() + .execute() để thay đổi dữ liệu, query_as + fetch_all / fetch_one / fetch_optional để đọc các dòng vào một struct có #[derive(FromRow)].
  • Giữ connection string trong DATABASE_URL, không phải trong code. SQLite dùng placeholder ?, Postgres dùng $1, $2.
  • Thay đổi bảng bằng migration (sqlx migrate add / run), đừng bao giờ sửa tay.
  • Luôn bind dữ liệu người dùng nhập. Đừng bao giờ dán nó vào SQL bằng format!. Tạo một pool lúc khởi động và dùng chung.

Kiểm tra nhanh

1. Bạn muốn viết SQL thuần, dùng async, và có thể chọn kiểm tra truy vấn lúc biên dịch. Crate nào hợp nhất?

2. Nên dùng phương thức nào của sqlx khi một truy vấn có thể trả về không hoặc một dòng?

3. Vì sao format!("... WHERE name = '{name}'") nguy hiểm?

4. Connection pool làm gì?

Điểm: 0 / 4

  • sqlx trên docs.rs (tiếng Anh): tài liệu tham khảo API, với query, query_as, các macro query!, pool, và mọi cơ sở dữ liệu được hỗ trợ.
  • sqlx-cli trên docs.rs (tiếng Anh): cách cài CLI, tạo cơ sở dữ liệu, chạy và hoàn tác migration, và dùng chế độ offline cho các macro query!.
  • Getting Started with Diesel (tiếng Anh): hướng dẫn chính thức từng bước, từ diesel setup đến thêm, sửa và xóa dòng.
  • Tài liệu SeaORM (tiếng Anh): entity, quan hệ, migration và công cụ sinh code sea-orm-cli.
  • OWASP SQL Injection Prevention Cheat Sheet (tiếng Anh): SQL injection hoạt động thế nào và truy vấn có tham số ngăn nó ra sao, cho mọi ngôn ngữ.