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.
Bạn có thể xây dựng gì
Phần tiêu đề “Bạn có thể xây dựng gì”- 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.
Dùng crate nào?
Phần tiêu đề “Dùng crate nào?”| 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ạoErr("không có việc nào với id 99")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:
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"] }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 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: NoneCá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ếtrows_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ộtTask. Tên cột phải khớp với tên field.query_as+fetch_allcho bạn mộtVec<Task>. Dùngfetch_onekhi chắc chắn phải có đúng một dòng, vàfetch_optionalkhi có thể không có dòng nào. Để ý cách một dòng không tồn tại trở thànhNone, mộtOption, 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 chomain.
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).
Kết nối tới Postgres
Phần tiêu đề “Kết nối tới Postgres”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:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresCode 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):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsSau đó 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/DATABASEpostgres://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.
Migration với sqlx-cli
Phần tiêu đề “Migration với sqlx-cli”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:
# tất cả các loại cơ sở dữ liệucargo install sqlx-cli
# hoặc chỉ những gì bạn cần, để build nhanh hơncargo install sqlx-cli --no-default-features --features sqlite,rustlsRồi, trong thư mục project:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating 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ó:
sqlx migrate runApplied 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ấyDiesel trong vài phút
Phần tiêu đề “Diesel trong vài phút”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=true2 Learn Diesel done=false1 task(s) still openTrong 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 trong vài phút
Phần tiêu đề “SeaORM trong vài phút”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 SeaORMModel 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).
SQL injection: luôn dùng bind parameter
Phần tiêu đề “SQL injection: luôn dùng bind parameter”Đâ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"]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ô.
Connection pool
Phần tiêu đề “Connection pool”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 visitsVà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_connectionsnê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.
Luyện tập
Phần tiêu đề “Luyện tập”1. Tìm kiếm trong repository
Phần tiêu đề “1. Tìm kiếm trong repository”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 milk3: Milk the cow0 kết quả cho "cat"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: truemark 99 done: false[ ] 2: Learn sqlx[ ] 3: Walk the dogVẫ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.
Tóm tắt
Phần tiêu đề “Tóm tắt”- 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
Phần tiêu đề “Kiểm tra nhanh”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
Đọc thêm
Phần tiêu đề “Đọc thêm”- sqlx trên docs.rs (tiếng Anh): tài liệu tham khảo API, với
query,query_as, các macroquery!, 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ữ.