Amikor egy Rust projekt elkezd nőni, előbb-utóbb eljön a pillanat, amikor egyetlen Cargo.toml és egyetlen src mappa már nem elég. Lehet, hogy van egy közös logikád, amit egy CLI-ből és egy web szerverből is használnál, vagy csak egyszerűen szeretnéd külön crate-be szervezni a doménmodellt és az infrastruktúrát. Itt jön képbe a Cargo workspace.
Mi az a Cargo workspace és mikor érdemes használni
A workspace lényegében több crate közös "esernyője": egy gyökér Cargo.toml, ami nem feltétlenül tartalmaz saját kódot, hanem összefogja a tagokat (member crate-eket). A legfontosabb előnyök:
- Egy közös
Cargo.lock– minden tag ugyanazt a dependency-verziót használja, nincs verzióütközés a fordítás során. - Egy közös
targetmappa – a build artifactok megosztásra kerülnek, így ha két crate ugyanazt a függőséget használja, azt nem kell kétszer lefordítani. - Egyszerűbb build és teszt orchestration – egyetlen
cargo buildvagycargo testparanccsal az összes tagot le tudod fordítani/tesztelni.
Érdemes workspace-t bevezetni, ha:
- a projekted több, jól elkülöníthető felelősségi körre bontható (pl.
core,cli,api), - több binárist is szeretnél gyártani ugyanabból a közös logikából,
- vagy csak egyszerűen szeretnéd gyorsítani a build időt a megosztott
targetmappa révén.
Egy workspace nem azonos a monorepóval, de gyakran együtt jár vele. A workspace Cargo szintű fogalom, a monorepo pedig repository szervezési kérdés – de a kettő remekül kiegészíti egymást.
Workspace létrehozása több crate-tel
Kezdjük a gyakorlattal. Tegyük fel, hogy egy projektben van egy közös core logikánk, egy cli binárisunk, és egy api szolgáltatásunk. A mappastruktúra így nézhet ki:
my-project/
├── Cargo.toml
├── core/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
├── cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
└── api/
├── Cargo.toml
└── src/
└── main.rs
A gyökér Cargo.toml fájl nem tartalmaz [package] szekciót (hacsak nem akarod, hogy maga a gyökér is egy crate legyen, de ez ritkán éri meg), hanem csak a [workspace] szekciót:
[workspace]
members = [
"core",
"cli",
"api",
]
resolver = "2"
A resolver = "2" beállítás a feature unification újabb, pontosabb módját aktiválja – edition 2021 esetén ez amúgy is alapértelmezett, de workspace-eknél érdemes explicit módon kiírni, mert vegyes edition-ös tagoknál ez nem mindig egyértelmű.
A core crate lehet egy egyszerű library:
// core/src/lib.rs
pub struct Config {
pub max_retries: u32,
pub timeout_ms: u64,
}
impl Config {
pub fn from_env() -> Option<Self> {
let max_retries = std::env::var("MAX_RETRIES").ok()?.parse().ok()?;
let timeout_ms = std::env::var("TIMEOUT_MS").ok()?.parse().ok()?;
Some(Config { max_retries, timeout_ms })
}
pub fn from_env_or_default() -> Self {
let Some(config) = Self::from_env() else {
return Config { max_retries: 3, timeout_ms: 5000 };
};
config
}
}
Itt egy let-else mintát is bevetettem – kifejezetten hasznos ilyen "vagy sikerül, vagy visszaesünk egy default értékre" logikánál, sokkal olvashatóbb, mint egy beágyazott match.
A cli crate ezután egyszerűen importálja a core-t:
# cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
// cli/src/main.rs
use core::Config;
fn main() {
let config = Config::from_env_or_default();
println!("Max retries: {}, timeout: {}ms", config.max_retries, config.timeout_ms);
}
Ha a core nevet választod egy crate-nek, ütközhet a standard library core crate-jével bizonyos import-kontextusokban. Gyakorlatban ritkán okoz gondot, de ha zavaró, nevezd inkább my_core-nak vagy hasonlónak.
Közös függőségek kezelése a workspace Cargo.toml-ban
Amikor több tag ugyanazt a külső crate-et használja, kellemetlen tud lenni, hogy minden Cargo.toml-ban külön-külön kell karbantartani a verziószámokat. Erre való a workspace szintű dependency-inheritance, amit a [workspace.dependencies] szekcióban definiálhatsz:
# gyökér Cargo.toml
[workspace]
members = ["core", "cli", "api"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
anyhow = "1"
thiserror = "1"
Ezután az egyes tag Cargo.toml-jaiban egyszerűen csak jelezni kell, hogy a verziót a workspace-től örökli:
# core/Cargo.toml
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
anyhow = { workspace = true }
Ez két nagy előnnyel jár: egy helyen frissítesz verziót, és garantáltan nem lesz verzióeltérés a tagok között. Ha valamelyik tagnak extra feature-re van szüksége egy közös függőségből, azt is meg tudod adni:
[dependencies]
serde = { workspace = true, features = ["rc"] }
A workspace.dependencies funkció viszonylag friss (Rust 1.64 óta stabil), de mostanra már a legtöbb éles projektben alapértelmezett gyakorlatnak számít. Ha még nem használod, mindenképp érdemes bevezetni – jelentősen csökkenti a verziókarbantartási terhet.
Build és teszt futtatás egyszerre több taghoz
A workspace egyik legnagyobb kényelmi funkciója, hogy a gyökérből kiadott parancsok automatikusan az összes tagra vonatkoznak:
cargo build
cargo test
cargo check
Ha csak egy adott tagot akarsz buildelni vagy tesztelni, használd a -p (--package) kapcsolót:
cargo build -p cli
cargo test -p core
Ha több, de nem az összes tagot érinti a művelet, több -p kapcsolót is megadhatsz:
cargo test -p core -p api
A cargo run esetén, ha a workspace-ben több binárist tartalmazó crate is van, a Cargo megkérdezi (vagy hibát dob), hogy melyiket szeretnéd futtatni – itt is jól jön a -p kapcsoló:
cargo run -p cli
A cargo test --workspace explicit módon jelzi a szándékodat, hogy tényleg az összes tagot akarod tesztelni – hasznos lehet CI szkriptekben, hogy egyértelmű legyen, mi történik, még akkor is, ha új tagot adsz a workspace-hez.
CI/CD pipeline-okban gyakori minta, hogy egy lépésben lefordítod és leteszteled az egészet:
cargo build --workspace --all-targets
cargo test --workspace --all-features
Az --all-targets kapcsoló biztosítja, hogy a példák, benchmark-ok és integrációs tesztek is lefordulnak, nem csak a fő library/binary target.
Gyakori hibák és buktatók workspace-ek kezelésekor
Néhány dolog, amivel biztosan találkozni fogsz, ha elkezdesz workspace-ekkel dolgozni:
1. Feature unification meglepetések. Mivel a workspace tagjai megosztják a Cargo.lock-ot, ha az egyik tag bekapcsol egy feature-t egy közös dependency-n, az az egész workspace-re hat a build során (legalábbis a resolver = "2" bevezetése előtt ez volt jellemző probléma – de mindenképp figyelj oda, mert vegyes feature-igényeknél még mindig okozhat meglepetést).
2. path dependency verzióütközés. Ha egy tag path-sel hivatkozik egy másikra, de közben megadsz neki egy version-t is (pl. publikáláshoz), a Cargo elvárja, hogy a helyi verzió megegyezzen a megadottal. Ez gyakori hibaforrás, amikor valaki elfelejti frissíteni a verziószámot mindkét helyen.
[dependencies]
core = { path = "../core", version = "0.2" }
Ha a core/Cargo.toml-ban a verzió 0.1.0 marad, a fordítás hibával áll le.
3. Túl "laposra" tervezett workspace. Ha mindent egyetlen nagy workspace-be gyömöszölsz, könnyen elveszik az áttekinthetőség. Érdemes tudatosan eldönteni, mi legyen külön crate: ha két modul csak ritkán vagy sosem függ egymástól, talán jobb, ha külön repositoryban élnek.
4. Publikálás sorrendje. Ha a workspace tagjait a crates.io-ra is publikálni akarod, figyelned kell a függőségi sorrendre – előbb a core-t kell publikálni, utána a rá épülő crate-eket, különben a cargo publish hibát dob, mert nem talál publikált verziót a path dependency-hez.
A cargo publish nem támogatja automatikusan a teljes workspace publikálását egy paranccsal – minden tagot külön-külön, a helyes sorrendben kell kiadnod. Ez könnyen elfelejthető nagyobb workspace-eknél.
5. IDE és rust-analyzer teljesítmény. Nagyon nagy workspace-eknél (sok tucat tag) a rust-analyzer indexelése lassulhat. Ha ezt tapasztalod, érdemes megfontolni a workspace feldarabolását több kisebb repositoryra, vagy legalább a rust-analyzer konfigurációjában szűkíteni az elemzett tagok körét.
Összefoglalás
A Cargo workspace-ek nem bonyolultak, de a hasznuk annál nagyobb, amint a projekted túlnő egyetlen crate-en. A közös Cargo.lock, a megosztott build cache és a [workspace.dependencies] révén elérhető verziókonzisztencia mind hozzájárulnak ahhoz, hogy a projekted karbantartható maradjon, ahogy nő. Kezdd kicsiben – válaszd szét a domén logikát a felhasználói felülettől –, és onnantól a workspace szinte magától bővül a projekt igényei szerint. A gyakori buktatók (feature unification, path/version ütközés, publikálási sorrend) mind kezelhetők, ha tudatosan tervezed meg a struktúrát az elejétől fogva.