Website phiên bản mới từ ruột

Hôm nay mình vừa triển khai phiên bản mới của website này. Nhìn giao diện thì không khác gì nhưng bên trong là đã làm mới hoàn toàn. Mới từ ngôn ngữ lập trình đến database.

Website này được mình viết lại bằng ngôn ngữ Rust và dùng database EdgeDB. Phiên bản cũ được viết bằng Python, dựa trên framework Flask, và database thì dùng PostgeSQL, được viết cũng khá lâu rồi (khoảng 10 năm), lại không được chăm sóc, cập nhật (trừ một đợt cập nhật lớn về frontend năm kia) nên bộ code rất cũ. Nhân việc mình đang có hứng luyện tay nghề về Rust nên mình quyết định dùng website này làm "bài tập". Về mặt frontend thì website này áp dụng cả hai kĩ thuật:

  • Server side rendering: Dùng cho các trang bên ngoài, mà khách truy cập sẽ nhìn thấy. Với các trang này thì mình dùng MiniJinja để render dữ liệu ra HTML.

  • Single page application: Dùng cho trang admin, nơi mình, quản lý nội dung, viết bài. Trang này thì mình dùng VueJS để tạo giao diện, viết một bộ API RESTful để cung cấp dữ liệu cho frontend. Để serialize dữ liệu ra JSON thì mình dùng serde_json.

Admin

Bên Rust chưa có một framework web nào lớn như Django bên Python nên việc làm web hơi vất vả. Rust chỉ mới có một vài framework ở cấp độ "micro-framework", thậm chí "micro" hơn cả Flask, trong đó mình chọn framework Axum vì nghe nói nó được cập nhật thường xuyên. Về database, EdgeDB là một hệ database khá mới, mình đã theo dõi từ khi nó mới chỉ ở "v1 alpha", và vẫn chờ đợi một cơ hội để thử. Ngay từ lần công bố đầu tiên, EdgeDB đã khiến mình hứng thú vì:

  • EdgeDB có sẵn tính năng "database migration". Với các hệ quản trị SQL truyền thống thì phải cần thêm một thư viện để làm việc này. Ví dụ trên Python, nếu dùng SQLAlchemy làm thư viện ORM, thì phải dùng thêm Alembic cho database migration.

  • EdgeDB có ngôn ngữ mô tả dữ liệu mới, gần với "Object-Oriented Programming". Với các hệ CSDL SQL truyền thống thì người ta phải dùng thêm một thư viện ORM nào đó (ví dụ SQLAlchemy) để làm cho dữ liệu lấy lên từ database trông "OOP" hơn, dễ nhào nặn hơn. Các câu truy vấn của EdgeDB trong sáng, dễ hiểu hơn một mớ JOIN của hệ SQL truyền thống.

Ví dụ hình dưới là câu truy vấn để lấy danh sách bài viết, cùng với các danh mục mà bài viết đó thuộc về:

edgedb query

Ở đây quan hệ giữa BlogPostBlogCategory là quan hệ many-to-many. Với hệ CSDL truyền thống thì phải thiết kế thêm một bảng trung gian để diễn tả mối quan hệ này, và câu truy vấn sẽ phức tạp với nhiều lần JOIN, chưa kể phải thêm DISTINCT mà nếu quên thì kết quả trả ra sẽ kì cục, trong khi với EdgeDB thì ta chỉ viết một câu ngắn như trên.

Nếu cú pháp truy vấn trông OOP rồi, thì dữ liệu lấy được có OOP không? Thật thú vị là có. Chẳng hạn trong Rust, nếu ta định nghĩa một struct như sau:

use uuid::Uuid;
use edgedb_protocol::model::Datetime as EDatetime;

#[derive(Debug, edgedb_derive::Queryable)]
struct BlogPost {
    id: Uuid,
    title: String,
    published_at: Option<EDatetime>,
    created_at: EDatetime,
    categories: Vec<BlogCategory>,
}

thì có thể "đúc" dữ liệu đọc lên từ EdgeDB vào struct đó một cách ngắn gọn như sau:

let posts: Vec<BlogPost> = client.query("SELECT BlogPost { id, title, published_at, created_at, categories { id, title } }", &()).await?;

tức là chỉ cần ép kiểu, trait Queryable sẽ tự động chuyển đổi dữ liệu. Điều này giúp ta không cần đến một thư viện ORM nào.

Tuy nhiên, dù thú vị là thế, nhưng vì là một phần mềm mới nên việc ứng dụng EdgeDB vẫn còn rất khó khăn, do tài liệu còn thiếu, và thư viện phía client chưa hoàn thiện. Chẳng hạn khi mình cần một câu query "Lấy ra những BlogPost không thuộc BlogCategory nào", thì tài liệu không đề cập. Mình phải mò mẫm để ra được câu query ngắn ngọn như sau:

SELECT BlogPost FILTER NOT EXISTS .categories;

Filter with empty link Khó khăn khác là về mặt thư viện client. Mặc dù EdgeDB cho phép truyền tham số vào câu query theo tên biến, ví dụ:

UPDATE BlogPost FILTER .id = <uuid>$id SET {
    body := <optional str>$body,
	is_published := <optional bool>$is_published
}

nhưng thư viện client dành cho Rust chưa hỗ trợ việc này, mà chỉ cho phép truyền tham số theo thứ tự, ví dụ:

UPDATE BlogPost FILTER .id = <uuid>$0 SET {
    body := <optional str>$1,
	is_published := <optional bool>$2
}

Tuy nhiên, từ việc chú ý một chi tiết rất nhỏ ở cuối trang tài liệu,

impl QueryArgs for Value

mình đã mày mò ra được cách để truyền được tham số theo tên, mặc dù cách làm khá phức tạp, dễ sai. Cách làm thì mình sẽ viết một bài riêng.

Đó là vất vả khi dùng một database mới, còn khi dùng một ngôn ngữ mới thì sao, đặc biệt là một ngôn ngữ system programming như Rust?

Khó khăn nhất về Rust trong dự án này là việc kết hợp nhiều thư viện với nhau mà chúng lại chưa liên thông với nhau, khiến việc chuyển đổi dữ liệu từ thư viện này sang thư viện kia phải thủ công. Ví dụ dữ liệu BlogPost mô tả ở trên có field datetime lấy được từ EdgeDB ra thì không trực tiếp serialize thành JSON để trả về trong API được, hay cũng không thể render trực tiếp ra HTML bằng MiniJinja được. Trong bộ code này có nhiều kiểu dữ liệu Value đến từ nhiều thư viện khác nhau:

use edgedb_protocol::value::Value;
use minijinja::value::Value;
use serde_json::Value;

Trong bài viết khác, mình sẽ chia sẻ kinh nghiệm chuyển đổi dữ liệu để trao đổi với các thư viện này.

Trải qua nhiều khó khăn như vậy, thứ mình nhận được khi "khai phá mảnh đất mới" là gì:

  • Trải nghiệm sự thú vị, tiện lợi mà những phần mềm mới, nảy sinh từ những đầu óc sáng tạo, mang lại.
  • Thành thạo hơn về ngôn ngữ lập trình Rust. Qua dự án này mình đã hiểu rõ hơn với kĩ thuật error handling, tự động chuyển đổi dữ liệu qua việc implement các "trait" (một khái niệm trong Rust).