- "Điều gì sẽ xảy ra nếu bạn có thể lưu trữ dữ liệu bền vững một cách an toàn trong Rust, dễ dàng viết các truy vấn phức tạp và không cần viết dù chỉ một dòng SQL?"
- Rust-query là thư viện được phát triển để hiện thực hóa điều đó
Rust và cơ sở dữ liệu
- Các thư viện cơ sở dữ liệu hiện có của Rust либо thiếu đảm bảo ở thời điểm biên dịch, hoặc khó dùng và không trực quan như SQL
- Cơ sở dữ liệu đóng vai trò quan trọng trong việc xây dựng phần mềm tránh xung đột và hỗ trợ giao dịch nguyên tử
- SQL là giao thức tiêu chuẩn để tương tác với cơ sở dữ liệu, nhưng phù hợp hơn để máy tính tạo ra; con người tự viết trực tiếp thì kém hiệu quả
Giới thiệu Rust-query
- rust-query là thư viện truy vấn cơ sở dữ liệu được tích hợp sâu với hệ thống kiểu của Rust
- Được thiết kế để cho phép thực hiện các thao tác cơ sở dữ liệu trong Rust theo cách tự nhiên như native
Tính năng chính và quyết định thiết kế
- Bí danh bảng tường minh: cung cấp đối tượng giả đại diện cho bảng sau khi join (
let user = User::join(rows);)
- An toàn với Null: các giá trị tùy chọn trong truy vấn được xử lý bằng kiểu
Option của Rust
- Hàm tổng hợp trực quan: hỗ trợ tổng hợp trực quan theo từng hàng mà không cần
GROUP BY
- Duyệt khóa ngoại an toàn kiểu: dễ dàng thực hiện join ngầm dựa trên khóa ngoại (
track.album().artist().name())
- Tra cứu duy nhất an toàn kiểu: truy xuất hàng có ràng buộc duy nhất cụ thể (trả về
Option<Rating>)
- Schema đa phiên bản: có thể kiểm tra mọi khác biệt giữa các phiên bản schema theo cách khai báo
- Migration an toàn kiểu: có thể xử lý hàng bằng mã Rust tùy ý
- Xử lý xung đột duy nhất an toàn kiểu: trả về kiểu lỗi cụ thể khi xảy ra xung đột ràng buộc duy nhất
- Tham chiếu hàng gắn với vòng đời giao dịch: tham chiếu hàng chỉ hợp lệ khi hàng còn tồn tại
- ID hàng được đóng gói theo kiểu: số hàng không bị lộ ra ngoài API
Truy vấn và chèn dữ liệu
Định nghĩa schema
#[schema]
enum Schema {
User {
name: String,
},
Story {
author: User,
title: String,
content: String,
},
#[unique(user, story)]
Rating {
user: User,
story: Story,
stars: i64,
},
}
use v0::*;
- Định nghĩa schema bằng cú pháp
enum của Rust
- Ràng buộc khóa ngoại được tạo bằng cách chỉ định tên bảng khác làm kiểu của cột
- Thêm ràng buộc duy nhất bằng thuộc tính
#[unique]
- Macro
#[schema] phân tích định nghĩa và tạo module v0
Chèn dữ liệu
fn insert_data(txn: &mut TransactionMut<Schema>) {
let alice = txn.insert(User { name: "alice" });
let bob = txn.insert(User { name: "bob" });
let dream = txn.insert(Story {
author: alice,
title: "My crazy dream",
content: "A dinosaur and a bird...",
});
let rating = txn.try_insert(Rating {
user: bob,
story: dream,
stars: 5,
}).expect("no rating for this user and story exists yet");
}
- Thao tác chèn trả về tham chiếu tới hàng vừa được chèn
- Khi chèn vào bảng có ràng buộc duy nhất, cần dùng
try_insert
try_insert trả về kiểu lỗi cụ thể khi có xung đột
Truy vấn dữ liệu
fn query_data(txn: &Transaction<Schema>) {
let results = txn.query(|rows| {
let story = Story::join(rows);
let avg_rating = aggregate(|rows| {
let rating = Rating::join(rows);
rows.filter_on(rating.story(), &story);
rows.avg(rating.stars().as_float())
});
rows.into_vec((story.title(), avg_rating))
});
for (title, avg_rating) in results {
println!("story '{title}' has avg rating {avg_rating:?}");
}
}
rows biểu thị tập hàng hiện tại trong truy vấn
- Dùng
aggregate để thực hiện phép tổng hợp
- Kết quả có thể được thu thập thành vector của tuple hoặc struct
Tiến hóa schema và migration
- Khi tạo phiên bản schema mới, dùng thuộc tính
#[version]
Thêm phiên bản schema mới
#[schema]
#[version(0..=1)]
enum Schema {
User {
name: String,
#[version(1..)]
email: String,
},
// ... phần schema còn lại ...
}
use v1::*;
Migration dữ liệu
- Migration được kiểm tra kiểu đối với cả schema cũ và mới
- Có thể xử lý dữ liệu hàng bằng mã Rust tùy ý (dùng
map_dummy)
let m = m.migrate(v1::update::Schema {
user: Box::new(|old_user| {
Alter::new(v1::update::UserMigration {
email: old_user
.name()
.map_dummy(|name| format!("{name}@example.com")),
})
}),
});
Kết luận
- rust-query đưa ra một cách tiếp cận mới để tương tác với cơ sở dữ liệu quan hệ trong Rust:
- kiểm tra ở thời điểm biên dịch
- truy vấn có thể kết hợp với Rust
- hỗ trợ tiến hóa schema thông qua kiểm tra kiểu
- Hiện tại chỉ sử dụng SQLite làm backend duy nhất và phù hợp để phát triển các ứng dụng thử nghiệm
- Hoan nghênh phản hồi qua GitHub Issues
2 bình luận
| Phù hợp để máy tính tạo ra và kém hiệu quả nếu con người tự viết trực tiếp.
Ở vị thế từng trải qua các dự án "thế hệ tiếp theo" chỉ có ở Hàn Quốc, nơi phải đưa vào hơn 100 lập trình viên.
Rất thú vị.
Thực ra phần lớn các lập trình viên được đưa vào đều là những người được xem là chuyên gia SQL.
Ý kiến trên Hacker News
Mối lo với schema do ứng dụng định nghĩa là nó được xác thực bởi sai hệ thống. Cơ sở dữ liệu mới là nơi có thẩm quyền đối với schema, và mọi lớp ứng dụng khác đều đưa ra giả định dựa trên đó. SQLx của Rust tạo struct dựa trên kiểu của cơ sở dữ liệu và xác thực ở thời điểm biên dịch, nhưng không đảm bảo cùng kiểu với cơ sở dữ liệu production. Nếu bạn thiết kế truy vấn trên Postgres v15 cục bộ nhưng production chạy Postgres v12, có thể phát sinh lỗi lúc chạy. Schema do ứng dụng định nghĩa tạo ra cảm giác an toàn sai lệch và làm tăng công việc cho kỹ sư.
SQL không hoàn hảo nhưng có một số ưu điểm. Hầu hết mọi người đều biết SQL cơ bản, và tài liệu của các cơ sở dữ liệu như PostgreSQL được viết bằng SQL. Các công cụ bên ngoài cũng dùng SQL, và khi thay đổi truy vấn thì không cần một bước biên dịch tốn kém. SQLx tránh các vấn đề của hệ thống kiểu làm tăng thời gian biên dịch bằng cách kiểm tra kiểu của tham số và để chính cơ sở dữ liệu xác thực truy vấn. Với các cơ sở dữ liệu mới, ngôn ngữ truy vấn tốt hơn có thể sẽ thắng, nhưng với các cơ sở dữ liệu SQL hiện có thì SQLx là lựa chọn tốt hơn.
Có ý kiến phản đối quan điểm rằng SQL nên do máy tính viết. SQL là một ngôn ngữ cấp cao, còn ở mức cao hơn cả Python hay Rust. SQL được thiết kế để dễ đọc và dễ dùng, rồi được chuyển đổi qua nhiều bước trong lúc biên dịch. SQL nằm ở điểm nghẽn của phát triển web, nơi diễn ra các biến đổi trạng thái. Vì SQL là ngôn ngữ cấp cao nên việc tối ưu hóa rất khó. SQL là một khoản nợ kỹ thuật, nhưng dùng SQL vẫn hiệu quả hơn gấp 10 lần so với việc phát triển một API phù hợp hơn.
Có ý kiến vui mừng khi thấy sự khám phá về
typesafe-db-accesstrong Rust. Các thư viện hiện có không đưa ra bảo đảm ở thời điểm biên dịch và lại dài dòng hoặc gượng gạo như SQL.dieselcó cung cấp bảo đảm ở thời điểm biên dịch. Trong tranh luận ORM và non-ORM, người này thích trình dựng truy vấn an toàn kiểu, vàdieselthuộc nhóm đó.Rust-querycó vẻ sẽ nghiêng về phía ORM đầy đủ hơn.Có ý kiến cho rằng cách tiếp cận gắn schema với kiểu dữ liệu là thú vị. Việc ví dụ không có enum
Schemakhiến nó kém trực quan. Nếu được định nghĩa bên trong macro thì sẽ rõ ràng hơn.Việc API của thư viện không để lộ số hàng thực tế gây khó hiểu. Trong máy chủ web, cần truyền row ID của dữ liệu để frontend có thể tham chiếu và chỉnh sửa dữ liệu đó bằng các request khác.
Có ý kiến phần nào đồng ý rằng SQL nên do máy tính viết, nhưng SQL không phải ngôn ngữ thuận tiện nhất để trình tạo mã viết ra. Việc tối ưu hóa kế hoạch đơn giản có thể thay đổi hoàn toàn bố cục của truy vấn. Đề xuất SQL pipe của Google có cải thiện đôi chút, nhưng vẫn mang những vấn đề của một ngôn ngữ truy vấn mới.
Có ý kiến đã dùng SeaQuery, nhưng tài liệu không đủ để tạo các truy vấn nâng cao. Truy vấn kiểu mạnh có thể làm chậm quá trình phát triển, nên người này đang cân nhắc quay lại dùng prepared statement và binding giá trị theo cách truyền thống.
Việc migration bằng thao tác ở cấp từng hàng riêng lẻ có thể rất chậm. Ví dụ, với một bảng có 1 tỷ hàng, một câu lệnh
UPDATEthông thường có thể mất tới một giờ. Cập nhật theo từng hàng sẽ còn mất nhiều thời gian hơn.