Rust 實務專案與最佳實踐:專案架構設計
簡介
在 Rust 生態系統中,良好的專案架構不只是讓程式碼看起來整潔,更直接影響到團隊協作、測試效率與未來的維護成本。許多新手在完成第一個 cargo new 後,往往只剩下 src/main.rs 或 src/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.rs、app/src/handlers/ |
| Domain | 純粹的業務邏輯與模型 | domain/src/lib.rs、domain/src/model/ |
| Infrastructure | 與外部資源的介面 (DB、API、檔案) | infrastructure/src/lib.rs、infrastructure/src/repo/ |
| Common | 共用工具、錯誤類型、宏 | common/src/lib.rs |
這樣的分層可以讓 Domain 完全不依賴外部框架(如 actix-web、sqlx),提升 測試的可插拔性。
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(())
}
}
重點:此實作位於
infrastructurecrate,只負責資料存取,不會出現在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 無法獨立測試,耦合度過高 | 只在 Application 或 Infrastructure 層使用框架,Domain 只保留純 Rust 型別與 trait |
在 main.rs 中寫太多業務邏輯 |
程式入口變得難以閱讀,測試困難 | 把業務流程抽成 service,main.rs 只負責組裝與啟動 |
| 過度拆分模組,導致檔案過小 | 專案結構變得雜亂,mod.rs 充斥大量 pub mod |
依照 功能領域(如 user, order)劃分子目錄,避免每個檔案只剩幾行程式 |
忽略 #[cfg(test)] 的測試隔離 |
測試程式碼在 release build 中被編譯,增加二進位體積 | 確保所有測試僅在 #[cfg(test)] 區塊內,或放在 tests/ 目錄 |
直接在 Cargo.toml 中硬編碼版本 |
依賴升級時需手動調整,容易產生衝突 | 使用 workspace 的 cargo update 與 cargo tree 監控相依樹,必要時使用 patch 或 replace 進行臨時調整 |
最佳實踐總結:
- 工作區 + 分層:讓每個 crate 只負責單一職責。
- Trait 抽象:Domain 只依賴 trait,Infrastructure 提供具體實作。
- 依賴注入:使用
Arc<dyn Trait>或impl Trait讓測試可注入 mock。 - 測試分層:單元測試聚焦於 Domain,整合測試驗證 Infrastructure 與 Application。
- CI/CD:在 CI pipeline 中加入
cargo fmt -- --check、cargo clippy -- -D warnings、cargo test --all,確保程式碼品質與相依一致性。
實際應用場景
| 場景 | 為何需要此架構 | 具體實作示例 |
|---|---|---|
| 微服務 API | 多服務共享同一套 Domain,且各自有不同的資料來源(PostgreSQL、Redis) | 把 domain 放在工作區共用,service-a、service-b 各自有自己的 infrastructure |
| CLI 工具 + Library | 同一套業務邏輯需要同時支援命令列介面與程式庫呼叫 | app crate 為 CLI,lib crate(domain)提供公共 API |
| 嵌入式系統 | 需要最小化二進位體積,同時保持測試可行 | 只編譯 domain + infrastructure(使用 no_std),在 app 中條件編譯 std 相關功能 |
| 資料處理管線 | 多階段處理(讀取 → 轉換 → 輸出),每階段可獨立測試 | 每個階段抽成獨立 crate,透過 trait 串接,使用 cargo run --bin pipeline 觸發整體流程 |
總結
建立 可擴充、易測試且符合 Rust 社群慣例的專案架構,關鍵在於:
- 使用 Cargo 工作區 統一管理多 crate,減少相依衝突。
- 採用分層(Domain‑Application‑Infrastructure),讓業務邏輯與外部框架徹底分離。
- 以 Trait 為界面,配合依賴注入,使測試可以輕鬆 mock。
- 遵守模組與檔案對應規則,保持程式碼可讀性與可維護性。
- 持續加入測試、格式化與 lint,在 CI 中自動檢查,避免「技術債」累積。
只要遵循上述原則,即使專案規模從 單一二進位 成長到 多服務微服務群,也能保持 編譯速度、開發效率與程式碼品質 的穩定。祝你在 Rust 的實務專案中,寫出乾淨、可靠且具備長期維護性的程式碼! 🚀