Xây dựng API với Rust và Axum – Phần 2: Kiến trúc của Rust, Axum, và SeaORM


Trong phần này, chúng ta sẽ tìm hiểu sâu về kiến trúc hỗ trợ hệ thống API được xây dựng với Rust, Axum, và SeaORM. Cấu trúc này nhấn mạnh khả năng mở rộng, bảo trì và hiệu suất.

🧱 Tổng quan về Kiến trúc Cấp cao #

Hệ thống được tổ chức thành các mô-đun riêng biệt:

  • application_core : Chứa logic nghiệp vụ cốt lõi và các bộ xử lý lệnh.
  • migrations : Chứa các mô hình thực thể SeaORM và các bản di chuyển cơ sở dữ liệu.
  • src : Bao gồm điểm vào (main.rs) và lớp trình bày (các bộ xử lý HTTP Axum).

Cấu trúc Thư mục #

my-cms/
├── application_core/         # Core business logic and services
│   └── commands/
│       └── post/
│           └── read.rs       # Post read command handler
├── migrations/               # SeaORM entity definitions and migrations
├── src/
│   ├── api/                  # Axum routes and controllers
│   └── bin/
│       └── main.rs           # Application entry point
├── Cargo.toml                # Workspace configuration
└── ...

📦 application_core/commands/post/read.rs #

Mô-đun này bao gồm logic nghiệp vụ để xử lý các thao tác liên quan đến bài viết. #

Response DTO Structure #

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostReadResponse {
    pub id: Uuid,
    pub title: String,
    pub preview_content: Option<String>,
    pub content: String,
    pub slug: String,
    pub thumbnail_paths: Vec<String>,
    pub published: bool,
    pub created_by: String,
    pub created_at: DateTimeWithTimeZone,
    pub last_modified_by: Option<String>,
    pub last_modified_at: Option<DateTimeWithTimeZone>,
    pub category_id: Uuid,
    pub row_version: i32,
    pub tags: Vec<tags::Model>,
    pub tag_names: Vec<String>,
    pub translations: Vec<post_translations::Model>,
}

impl PostReadResponse {
    pub fn new(
        post: Model,
        tags: Vec<tags::Model>,
        translations: Vec<post_translations::Model>,
    ) -> Self {
        let tag_names = tags
            .iter()
            .map(|tag| tag.name.to_owned())
            .collect::<Vec<String>>();

        Self {
            id: post.id,
            title: post.title,
            preview_content: post.preview_content,
            content: post.content,
            slug: post.slug,
            published: post.published,
            created_by: post.created_by,
            created_at: post.created_at,
            last_modified_by: post.last_modified_by,
            last_modified_at: post.last_modified_at,
            category_id: post.category_id,
            row_version: post.row_version,
            thumbnail_paths: post.thumbnail_paths,
            tags,
            tag_names,
            translations,
        }
    }
}

Command Handler Trait #

#[async_trait]
pub trait PostReadHandlerTrait {
    async fn handle_get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError>;

    async fn handle_get_posts_with_filtering(
        &self,
        category_type: Option<CategoryType>,
        published: Option<bool>,
    ) -> Result<Vec<PostReadResponse>, AppError>;

    async fn handle_get_post(
        &self,
        id: Uuid,
    ) -> Result<PostReadResponse, AppError>;
}

#[derive(Debug, Clone)]
pub struct PostReadHandler {
    pub db: Arc<DatabaseConnection>,
}

Handler Implementation #

#[async_trait]
impl PostReadHandlerTrait for PostReadHandler {
    #[instrument]
    async fn handle_get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError> {
        let db_result = Posts::find()
            .find_with_related(Tags)
            .all(self.db.as_ref())
            .await
            .map_err(AppError::from)?;

        let response = db_result
            .into_iter()
            .map(|(post, tags)| {
                PostReadResponse::new(post, tags, vec![])
            })
            .collect();

        Ok(response)
    }

    #[instrument]
    async fn handle_get_posts_with_filtering(
        &self,
        category_type: Option<CategoryType>,
        published: Option<bool>,
    ) -> Result<Vec<PostReadResponse>, AppError> {
        let db_result = Posts::find()
            .join(JoinType::LeftJoin, posts::Relation::Categories.def())
            .apply_if(category_type, |query, v| {
                query.filter(Expr::col(categories::Column::CategoryType).eq(v.as_enum()))
            })
            .apply_if(published, |query, v| {
                query.filter(Expr::col(posts::Column::Published).eq(v))
            })
            .find_with_related(Tags)
            .all(self.db.as_ref())
            .await
            .map_err(AppError::from)?;

        let post_ids: Vec<Uuid> = db_result.iter().map(|(post, _)| post.id).collect();

        let translations = post_translations::Entity::find()
            .filter(post_translations::Column::PostId.is_in(post_ids.clone()))
            .all(self.db.as_ref())
            .await
            .map_err(AppError::from)?;

        let translations_map = translations.into_iter().fold(
            HashMap::new(),
            |mut acc: HashMap<Uuid, Vec<post_translations::Model>>, translation| {
                acc.entry(translation.post_id)
                    .or_default()
                    .push(translation);
                acc
            },
        );

        let response = db_result
            .into_iter()
            .map(|(post, tags)| {
                let post_translations = translations_map.get(&post.id).cloned().unwrap_or_default();
                PostReadResponse::new(post, tags, post_translations)
            })
            .collect();

        Ok(response)
    }

    #[instrument]
    async fn handle_get_post(&self, id: Uuid) -> Result<PostReadResponse, AppError> {
        let post = Posts::find_by_id(id)
            .one(self.db.as_ref())
            .await?
            .ok_or(AppError::NotFound)?;

        let tags = post.find_related(Tags).all(self.db.as_ref()).await?;

        let translations = post.find_related(post_translations::Entity)
            .all(self.db.as_ref())
            .await?;

        Ok(PostReadResponse::new(post, tags, translations))
    }
}

⚠️ Tuyên bố miễn trừ trách nhiệm: Về thư mục migrations #

🧩 Thư mục migrations chứa các mô hình thực thể và tập lệnh di chuyển được tạo bởi SeaORM. Điều quan trọng là:
  • Tạo lược đồ cơ sở dữ liệu và các thực thể của riêng bạn bằng các công cụ CLI của SeaORM, phù hợp với nhu cầu dự án của bạn.
  • Sử dụng thư mục này như một tài liệu tham khảo, không phải là một mẫu trực tiếp.
  • Căn chỉnh định nghĩa thực thể với các bảng cơ sở dữ liệu cụ thể của bạn bằng cách chạy:
sea-orm-cli generate entity -u postgres://<your-database-url> -o migrations
  • Sau đó, tùy chỉnh các thực thể được tạo để phù hợp với logic miền của ứng dụng của bạn.

🚨 Đừng dựa dẫm một cách mù quáng vào các mô hình được tạo sẵn — lược đồ của bạn có thể khác biệt đáng kể. Chỉ sử dụng điều này như một hướng dẫn cấu trúc.

🧭 Triết lý thiết kế Clean Architecture & CQRS #

Dự án này được kiến trúc sử dụng các nguyên tắc từ Clean ArchitectureCommand Query Responsibility Segregation (CQRS) . Những mô hình này thúc đẩy việc phân tách các mối quan tâm và tổ chức mã theo trách nhiệm.

✨ Lợi ích của phương pháp này #

  • Phân tách mối quan tâm: Phần trình bày, logic ứng dụng và cơ sở hạ tầng được phân tách rõ ràng.
  • Khả năng mở rộng: Thêm tính năng hoặc module mới có thể được thực hiện độc lập mà không ảnh hưởng đến các phần khác.
  • Khả năng kiểm thử: Logic nghiệp vụ được cô lập trong tầng application_core , giúp dễ dàng viết các bài kiểm thử đơn vị.
  • Khả năng bảo trì: Mỗi module có một mục đích rõ ràng, và cấu trúc thư mục củng cố tính kỷ luật.

Sự phân tách rõ ràng này làm cho hệ thống dễ hiểu và phát triển hơn khi yêu cầu thay đổi.

🏋️Cải tiến: Giới thiệu tầng Dịch vụ Truy cập Dữ liệu (DAS) #

Hiện tại, trong kiến trúc, DbConnection đang được tiêm trực tiếp vào các handler để thực hiện các tác vụ cơ sở dữ liệu. Mặc dù cách tiếp cận này hoạt động, nhưng nó không phải là thực hành tốt nhất vì vi phạm Nguyên tắc Trách nhiệm Đơn lẻ (SRP) và làm cho các handler bị gắn chặt với tầng cơ sở dữ liệu. Để cải thiện điều này và làm cho kiến trúc trở nên mô-đun và dễ bảo trì hơn, chúng ta có thể giới thiệu Dịch vụ Truy cập Dữ liệu (DAS) .

Tại sao giới thiệu DAS? #

  • Phân tách trách nhiệm : Các handler nên tập trung vào logic nghiệp vụ, không phải kết nối cơ sở dữ liệu.
  • Khả năng mở rộng : Dễ dàng quản lý và kiểm tra các tương tác cơ sở dữ liệu riêng lẻ.
  • Khả năng bảo trì : Tạo ra sự trừu tượng rõ ràng giữa tầng cơ sở dữ liệu và logic ứng dụng.

Các bước để cải thiện kiến trúc #

  1. Tạo Dịch vụ Truy cập Dữ liệu (DAS)
    • DAS sẽ bao bọc tất cả các thao tác cơ sở dữ liệu.
    • Nó sẽ quản lý vòng đời của DbConnection , cung cấp các phương thức mà các handler có thể sử dụng để tương tác với cơ sở dữ liệu mà không cần quản lý trực tiếp kết nối.
  2. Tái cấu trúc các Handler để sử dụng DAS
    • Thay vì tiêm trực tiếp DbConnection vào các handler, hãy tiêm DAS.
    • DAS sau đó sẽ cung cấp các phương thức cho các thao tác cơ sở dữ liệu.

Ví dụ: Tạo Dịch vụ Truy cập Dữ liệu (DAS) #

  1. Create DAS Interface
pub trait DataAccessService {
    fn get_post_by_id(&self, id: Uuid) -> impl Future<Output = Result<PostReadResponse, AppError>>;
    fn get_all_posts(&self) -> impl Future<Output = Result<Vec<PostReadResponse>, AppError>>;
    // Add other database-related methods here
}
  1. Hiện thực DAS
#[derive(Debug)]
pub struct PostDataAccessService {
    pub db: Arc<DatabaseConnection>,
}

impl DataAccessService for PostDataAccessService {
    async fn get_post_by_id(&self, id: Uuid) -> Result<PostReadResponse, AppError> {
        let post = Posts::find_by_id(id)
            .find_with_related(Tags)
            .one(self.db.as_ref())
            .await
            .map_err(|e| e.into())?;

        // Mapping db result to response format
        post.map(|p_and_tags| {
            PostReadResponse::new(
                p_and_tags.0.to_owned(),
                p_and_tags.1.to_owned(),
                vec![],
            )
        })
        .ok_or(AppError::NotFound)
    }

    async fn get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError> {
        let db_result = Posts::find()
            .find_with_related(Tags)
            .all(self.db.as_ref())
            .await
            .map_err(|e| e.into())?;

        Ok(db_result
            .iter()
            .map(|p_and_tags| {
                PostReadResponse::new(p_and_tags.0.to_owned(), p_and_tags.1.to_owned(), vec![])
            })
            .collect())
    }
}
  1. Chỉnh sửa Handlers để sử dụng DAS
#[derive(Debug)]
pub struct PostReadHandler {
    pub data_access_service: Arc<dyn DataAccessService>,
}

impl PostReadHandlerTrait for PostReadHandler {
    #[instrument]
    async fn handle_get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError> {
        self.data_access_service.get_all_posts().await
    }

    #[instrument]
    async fn handle_get_post(&self, id: Uuid) -> Result<PostReadResponse, AppError> {
        self.data_access_service.get_post_by_id(id).await
    }
}

Lợi ích của việc đưa tầng DAS vào hệ thống #

  • Logic Cơ Sở Dữ Liệu Tập Trung : Tất cả các tác vụ liên quan đến cơ sở dữ liệu hiện được đóng gói trong DAS, cung cấp một nơi duy nhất để chỉnh sửa cách thực thi các truy vấn cơ sở dữ liệu.
  • Khả Năng Kiểm Thử : Bạn có thể giả lập (mock) lớp DAS trong các bài kiểm thử, giúp dễ dàng cô lập và kiểm tra logic nghiệp vụ trong các trình xử lý.
  • Tách Biệt : Các trình xử lý không còn bị ràng buộc chặt chẽ với cơ sở dữ liệu, giúp chúng linh hoạt hơn và dễ bảo trì hơn.

=> Cải tiến này nâng cao kiến trúc bằng cách tuân theo Nguyên Tắc Trách Nhiệm Đơn (SRP) , thúc đẩy khả năng kiểm thử tốt hơn và cho phép bảo trì dễ dàng hơn cho ứng dụng.

📚 Tài Liệu Tham Khảo và Đọc Thêm #

Nếu bạn mới làm quen với Kiến Trúc Sạch hoặc CQRS , đây là một số tài liệu tham khảo hữu ích dành cho các nhà phát triển Rust:

Những tài liệu này đi sâu hơn vào các thực tiễn tốt nhất về kiến trúc và cách triển khai chúng hiệu quả trong Rust.

➡️ Xem kho mã tại đây: https://github.com/doitsu2014/my-cms

⬅️ Quay lại Xây dựng API với Rust và Axum – Phần 1: Khởi tạo & Thiết lập dự án

➡️ Tiếp tục đến Phần 3: Truy Vết & Khả Năng Quan Sát → — Làm cho API của bạn minh bạch và dễ dàng gỡ lỗi trong môi trường sản xuất.

Bởi [email protected]

Xuất bản vào 4/11/2025