本文 AI 產出,尚未審核

Rust 實務專案與最佳實踐:專案架構設計


簡介

在 Rust 生態系統中,良好的專案架構不只是讓程式碼看起來整潔,更直接影響到團隊協作、測試效率與未來的維護成本。許多新手在完成第一個 cargo new 後,往往只剩下 src/main.rssrc/lib.rs 這兩個檔案,隨著功能增長,程式碼會迅速變得雜亂,導致 編譯時間變長、模組相依混亂,甚至出現「無法找不到 trait」的錯誤。

本篇文章針對 從零開始建立可擴充、易測試且符合 Rust 社群慣例的專案結構 進行說明。無論你是剛踏入 Rust 的初學者,還是已經有一定開發經驗的中階開發者,都能從中獲得實務上的指引,快速將雜亂的程式碼整理成易於管理的模組層級。


核心概念

1. Cargo 工作區(Workspace)

Cargo 工作區允許在同一個 Git repository 中管理多個 crate(套件),透過 Cargo.toml[workspace] 設定,可以共享 依賴版本、編譯快取,並讓跨 crate 的測試與 CI 更加順暢。

# Cargo.toml (根目錄)
[workspace]
members = [
    "app",          # 主執行檔
    "domain",       # 核心業務邏輯
    "infrastructure", # 外部介面 (DB、HTTP、檔案系統)
    "common",       # 共用工具與型別
]

重點:使用工作區可以避免每個子 crate 重複宣告相同的相依,減少 依賴衝突 的機會。


2. 分層架構(Layered Architecture)

在 Rust 中,我們常採用 Domain‑Driven Design(DDD) 的概念,將程式碼分為以下幾層:

層級 目的 常見檔案/目錄
Application 協調 Use‑case,處理輸入/輸出 app/src/main.rsapp/src/handlers/
Domain 純粹的業務邏輯與模型 domain/src/lib.rsdomain/src/model/
Infrastructure 與外部資源的介面 (DB、API、檔案) infrastructure/src/lib.rsinfrastructure/src/repo/
Common 共用工具、錯誤類型、宏 common/src/lib.rs

這樣的分層可以讓 Domain 完全不依賴外部框架(如 actix-websqlx),提升 測試的可插拔性


3. 模組與檔案對應規則

Rust 的模組系統允許 檔案結構直接映射到模組階層。以下是常見的對應方式:

src/
├── lib.rs          // crate 根模組
├── main.rs         // binary crate 入口
├── api/
│   ├── mod.rs      // pub mod api;
│   └── v1.rs       // pub mod v1;
└── utils/
    ├── mod.rs
    └── logger.rs
  • mod.rs 代表目錄本身的模組。
  • 子檔案(如 v1.rs)則是子模組,使用 pub mod v1; 於父模組中公開。

4. 測試與範例(Tests & Examples)

Rust 原生支援單元測試與整合測試。單元測試 放在 src/... 檔案的 #[cfg(test)] 模組內;整合測試 放在 tests/ 目錄;範例程式 放在 examples/

// domain/src/model/user.rs
pub struct User {
    pub id: u64,
    pub name: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn create_user() {
        let user = User { id: 1, name: "Alice".into() };
        assert_eq!(user.id, 1);
        assert_eq!(user.name, "Alice");
    }
}

程式碼範例

以下提供 5 個實用範例,說明如何在分層專案中建立、使用與測試模組。

範例 1:Domain Model 與 Repository Trait

// domain/src/model/user.rs
pub struct User {
    pub id: u64,
    pub name: String,
}

// domain/src/repo/user_repo.rs
use async_trait::async_trait;
use crate::model::user::User;

#[async_trait]
pub trait UserRepository: Send + Sync {
    async fn find_by_id(&self, id: u64) -> Option<User>;
    async fn save(&self, user: &User) -> Result<(), String>;
}

說明UserRepository 只定義抽象介面,不牽涉任何資料庫細節,因此在測試時可以輕易 mock。


範例 2:Infrastructure 實作 PostgreSQL Repository

// infrastructure/src/repo/postgres_user_repo.rs
use async_trait::async_trait;
use sqlx::PgPool;
use domain::repo::user_repo::UserRepository;
use domain::model::user::User;

pub struct PgUserRepo {
    pool: PgPool,
}

impl PgUserRepo {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl UserRepository for PgUserRepo {
    async fn find_by_id(&self, id: u64) -> Option<User> {
        let row = sqlx::query!("SELECT id, name FROM users WHERE id = $1", id as i64)
            .fetch_one(&self.pool)
            .await
            .ok()?;

        Some(User {
            id: row.id as u64,
            name: row.name,
        })
    }

    async fn save(&self, user: &User) -> Result<(), String> {
        sqlx::query!(
            "INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET name = $2",
            user.id as i64,
            user.name
        )
        .execute(&self.pool)
        .await
        .map_err(|e| e.to_string())?;
        Ok(())
    }
}

重點:此實作位於 infrastructure crate,只負責資料存取,不會出現在 domain 中。


範例 3:Application Service(Use‑case)

// app/src/service/user_service.rs
use domain::repo::user_repo::UserRepository;
use domain::model::user::User;
use std::sync::Arc;

pub struct UserService {
    repo: Arc<dyn UserRepository>,
}

impl UserService {
    pub fn new(repo: Arc<dyn UserRepository>) -> Self {
        Self { repo }
    }

    pub async fn get_user_name(&self, id: u64) -> Result<String, String> {
        let user = self.repo.find_by_id(id).await.ok_or("User not found")?;
        Ok(user.name)
    }

    pub async fn register_user(&self, name: String) -> Result<User, String> {
        // 簡化的 ID 產生方式
        let id = rand::random::<u64>();
        let user = User { id, name };
        self.repo.save(&user).await?;
        Ok(user)
    }
}

說明UserService 只依賴 UserRepository 抽象介面,可在測試中注入 mock


範例 4:在 main.rs 中組裝依賴

// app/src/main.rs
use std::sync::Arc;
use infrastructure::repo::postgres_user_repo::PgUserRepo;
use app::service::user_service::UserService;
use sqlx::postgres::PgPoolOptions;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 建立資料庫連線池
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://user:password@localhost/mydb")
        .await?;

    // 注入 concrete repository
    let repo = Arc::new(PgUserRepo::new(pool));

    // 建立服務層
    let service = UserService::new(repo);

    // 示範呼叫
    let user = service.register_user("Bob".into()).await?;
    println!("Created user {} with id {}", user.name, user.id);

    Ok(())
}

要點:使用 Arc<dyn UserRepository> 讓依賴注入變得簡單,同時保持 thread‑safe


範例 5:單元測試(Mock Repository)

// app/src/service/user_service_test.rs
use super::UserService;
use async_trait::async_trait;
use domain::model::user::User;
use domain::repo::user_repo::UserRepository;
use std::sync::Arc;

// 建立簡易的 mock
struct MockRepo;

#[async_trait]
impl UserRepository for MockRepo {
    async fn find_by_id(&self, id: u64) -> Option<User> {
        Some(User { id, name: "MockUser".into() })
    }

    async fn save(&self, _user: &User) -> Result<(), String> {
        Ok(())
    }
}

#[tokio::test]
async fn test_get_user_name() {
    let repo = Arc::new(MockRepo);
    let service = UserService::new(repo);

    let name = service.get_user_name(42).await.unwrap();
    assert_eq!(name, "MockUser");
}

說明:透過 mock,我們不需要真的連接資料庫,即可驗證業務邏輯。


常見陷阱與最佳實踐

陷阱 可能的後果 解決方案 / 最佳實踐
將外部框架(如 actix-web)直接寫入 Domain Domain 無法獨立測試,耦合度過高 只在 ApplicationInfrastructure 層使用框架,Domain 只保留純 Rust 型別與 trait
main.rs 中寫太多業務邏輯 程式入口變得難以閱讀,測試困難 把業務流程抽成 servicemain.rs 只負責組裝與啟動
過度拆分模組,導致檔案過小 專案結構變得雜亂,mod.rs 充斥大量 pub mod 依照 功能領域(如 user, order)劃分子目錄,避免每個檔案只剩幾行程式
忽略 #[cfg(test)] 的測試隔離 測試程式碼在 release build 中被編譯,增加二進位體積 確保所有測試僅在 #[cfg(test)] 區塊內,或放在 tests/ 目錄
直接在 Cargo.toml 中硬編碼版本 依賴升級時需手動調整,容易產生衝突 使用 workspacecargo updatecargo tree 監控相依樹,必要時使用 patchreplace 進行臨時調整

最佳實踐總結

  1. 工作區 + 分層:讓每個 crate 只負責單一職責。
  2. Trait 抽象:Domain 只依賴 trait,Infrastructure 提供具體實作。
  3. 依賴注入:使用 Arc<dyn Trait>impl Trait 讓測試可注入 mock。
  4. 測試分層:單元測試聚焦於 Domain,整合測試驗證 Infrastructure 與 Application。
  5. CI/CD:在 CI pipeline 中加入 cargo fmt -- --checkcargo clippy -- -D warningscargo test --all,確保程式碼品質與相依一致性。

實際應用場景

場景 為何需要此架構 具體實作示例
微服務 API 多服務共享同一套 Domain,且各自有不同的資料來源(PostgreSQL、Redis) domain 放在工作區共用,service-aservice-b 各自有自己的 infrastructure
CLI 工具 + Library 同一套業務邏輯需要同時支援命令列介面與程式庫呼叫 app crate 為 CLI,lib crate(domain)提供公共 API
嵌入式系統 需要最小化二進位體積,同時保持測試可行 只編譯 domain + infrastructure(使用 no_std),在 app 中條件編譯 std 相關功能
資料處理管線 多階段處理(讀取 → 轉換 → 輸出),每階段可獨立測試 每個階段抽成獨立 crate,透過 trait 串接,使用 cargo run --bin pipeline 觸發整體流程

總結

建立 可擴充、易測試且符合 Rust 社群慣例的專案架構,關鍵在於:

  1. 使用 Cargo 工作區 統一管理多 crate,減少相依衝突。
  2. 採用分層(Domain‑Application‑Infrastructure),讓業務邏輯與外部框架徹底分離。
  3. 以 Trait 為界面,配合依賴注入,使測試可以輕鬆 mock。
  4. 遵守模組與檔案對應規則,保持程式碼可讀性與可維護性。
  5. 持續加入測試、格式化與 lint,在 CI 中自動檢查,避免「技術債」累積。

只要遵循上述原則,即使專案規模從 單一二進位 成長到 多服務微服務群,也能保持 編譯速度、開發效率與程式碼品質 的穩定。祝你在 Rust 的實務專案中,寫出乾淨、可靠且具備長期維護性的程式碼! 🚀