Amikor egy projekt túlnő egy binárison vagy egy library crate-en, hamar szembejön a kérdés: hogyan bontsuk szét a kódot logikus egységekre anélkül, hogy szétforgácsolódna a build rendszer? Erre a válasz a Cargo workspace, ami már régóta a Cargo egyik legmegbízhatóbb, legkevésbé "varázslatos" funkciója.
Mi az a Cargo workspace és mikor van rá szükség
A workspace lényegében egy csoportosítás: több crate-et fog össze egy közös Cargo.lock fájl és egy közös target/ könyvtár alá. Ez nem egyenlő azzal, hogy egyetlen crate-be tennéd az egészet — a tagok (member crate-ek) továbbra is önálló crate-ek, saját Cargo.toml-lal, saját verziószámmal, saját src/ mappával.
Akkor van rá szükség, ha:
- van egy
corelogikád, amit egyszerre használ egy CLI, egy webszerver és egy tesztkészlet, - külön akarod választani a domain logikát a szolgáltatási rétegtől (pl.
domain,api,clicrate-ek), - több bináris is épül közös könyvtárkódból,
- csökkenteni akarod az újrafordítási időt azzal, hogy kisebb, jól izolált crate-ekre bontod a kódot (a Cargo incrementális build cache-e crate granularitáson dolgozik).
Ha csak egy binárisod és egy lib.rs-ed van, ne rohanj workspace-et csinálni belőle. A workspace akkor éri meg, ha legalább 2-3 valóban különálló crate-ed van, amik függnek egymástól vagy közös infrastruktúrát osztanak meg.
Workspace létrehozása több crate-tel: könyvtárstruktúra és Cargo.toml beállítások
A workspace gyökerében egy "virtuális" Cargo.toml áll, ami maga nem crate, csak a taglistát és a közös beállításokat tartalmazza. Egy tipikus struktúra így néz ki:
my-project/
├── Cargo.toml
├── Cargo.lock
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
├── api/
│ ├── Cargo.toml
│ └── src/main.rs
└── cli/
├── Cargo.toml
└── src/main.rs
A gyökér Cargo.toml tartalma:
[workspace]
members = ["core", "api", "cli"]
resolver = "2"
A resolver = "2" beállítás fontos: ez a feature-unification-t crate granularitáson kezeli, nem az egész workspace-en, ami elkerüli a felesleges feature-összefésülést különböző célplatformok (pl. build-dependency vs. normal dependency) között. 2021 edition esetén ez már alapból ez a viselkedés, de explicit módon kiírva is érdemes hagyni, hogy dokumentálja a szándékot.
A core crate Cargo.toml-ja egy sima library crate:
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
Az api és a cli pedig a core-ra hivatkozik útvonal alapú (path) dependency-vel:
[package]
name = "cli"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
clap = { version = "4", features = ["derive"] }
Egy egyszerű core crate lib.rs-e, amit a többi tag felhasznál:
// core/src/lib.rs
pub struct Invoice {
pub id: u32,
pub amount_cents: i64,
}
impl Invoice {
pub fn new(id: u32, amount_cents: i64) -> Self {
Self { id, amount_cents }
}
pub fn amount_formatted(&self) -> String {
format!("{:.2}", self.amount_cents as f64 / 100.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_amount_correctly() {
let invoice = Invoice::new(1, 12345);
assert_eq!(invoice.amount_formatted(), "123.45");
}
}
A workspace tagjai bármikor glob mintával is felsorolhatók, például members = ["crates/*"], ami hasznos, ha sok apró crate-et tartasz egy crates/ mappában.
Közös függőségek kezelése és verziók szinkronizálása a workspace-ben
Egy gyakori fájdalom, hogy minden tag crate saját maga állítja be a serde, a tokio vagy más közös függőség verzióját, és idővel elcsúsznak egymástól. Erre a Rust 1.64 óta létező workspace.dependencies mechanizmus a megoldás: a verziókat egy helyen, a gyökér Cargo.toml-ban definiálod, a tag crate-ek pedig csak hivatkoznak rá.
Gyökér Cargo.toml:
[workspace]
members = ["core", "api", "cli"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
thiserror = "1"
clap = { version = "4", features = ["derive"] }
A tag crate-ekben ezután elég workspace = true jelzővel hivatkozni:
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
Ez nem csak azt garantálja, hogy minden crate ugyanazt a verziót használja, hanem azt is, hogy egyetlen helyen kell frissíteni egy verziószámot — nem kell öt Cargo.toml-t átjárni, ha upgrade-elsz egy crate-et.
A workspace.dependencies csak a verzió és a feature-lista egyszerűsítésére jó — a tényleges Cargo.lock továbbra is egy közös fájl az egész workspace-hez, tehát a build végül is egy egységes függőségfát old fel.
Ha egy tag crate-nek extra feature-re van szüksége egy közös dependency-ből, azt is felül lehet írni:
[dependencies]
serde = { workspace = true, features = ["rc"] }
Build és teszt futtatás egyszerre több tagcrate-en
A workspace egyik legnagyobb előnye, hogy a build és a teszt parancsokat a gyökérből futtatva a Cargo automatikusan minden tagra vonatkoztatja őket:
cargo build
cargo test
Ez build-eli és teszteli mind a core, mind az api, mind a cli crate-et egyetlen paranccsal. Ha csak egy adott tagra akarsz koncentrálni, a -p (package) kapcsolóval szűkíthetsz:
cargo build -p core
cargo test -p cli
Ha csak azokat a crate-eket akarod tesztelni, amik megváltoztak (pl. CI-ban), a --workspace kapcsoló explicit módon jelzi, hogy az egész workspace-t vedd figyelembe (ez amúgy is az alapértelmezett, ha a gyökérből futtatod, de scriptekben érdemes kiírni, hogy egyértelmű legyen):
cargo test --workspace
A cargo check --workspace gyors visszajelzést ad típushibákról anélkül, hogy tényleges bináris kódot generálna — ez különösen hasznos nagyobb workspace-eknél, ahol a teljes build sok percet is igénybe vehet.
CI pipeline-okban célszerű a cargo build --workspace --all-targets és cargo test --workspace kombót használni, hogy a példák (examples/), a benchmarkok és a tesztek is lefordítási ellenőrzés alá essenek, ne csak a fő bináris.
Gyakori hibák és buktatók workspace-ek használatakor
Akármilyen jól dokumentált is a workspace mechanizmus, van néhány buktató, amibe rendszeresen belefutnak a fejlesztők:
1. A gyökér Cargo.toml egyszerre [package] és [workspace]. Ez technikailag megengedett (ún. "root package", ami maga is tag), de kezdőknek gyakran összezavarja a mentális modellt. Ha bizonytalan vagy, tartsd a gyökér Cargo.toml-t tisztán [workspace]-nek, és minden crate legyen külön mappában.
2. Eltérő edition a tag crate-ek között. A Cargo megengedi, hogy egy workspace-en belül különböző crate-ek különböző edition-t használjanak, de ez felesleges komplexitást hoz be. Ha nincs erős okod rá, tartsd egységesen edition = "2021"-en mindet.
3. Körkörös path dependency. Ha az api függ a core-tól, és valamiért a core is elkezd hivatkozni az api-ra (akár csak dev-dependency-ként egy teszthez), a Cargo hibát fog jelezni. Ezt a leggyakrabban egy harmadik, közös "shared-test-utils" crate bevezetésével lehet elkerülni.
4. Cargo.lock verziókontroll alá helyezése. Library crate-eknél megszokott, hogy a Cargo.lock-ot nem commitolják, de egy workspace esetén, ha van benne legalább egy bináris tag (pl. cli vagy api), erősen ajánlott commitolni a Cargo.lock-ot, hogy reprodukálható legyen a build minden fejlesztőnél és CI-ban is.
5. Túl sok apró crate. A crate-ekre bontás csökkentheti az inkrementális build időt, de minden új crate új Cargo.toml overhead-et, új public API felületet és több pub láthatóság-menedzsmentet jelent. Ha egy modul mindig együtt változik egy másikkal, valószínűleg nem kell külön crate-be tenned — elég egy mod a meglévő crate-en belül.
// api/src/main.rs — egyszerű, workspace-tagokra épülő bináris
use core::Invoice;
fn main() {
let invoice = Invoice::new(42, 999_00);
println!("Invoice #{}: {} Ft", invoice.id, invoice.amount_formatted());
}
Ha path dependency-vel hivatkozol egy tag crate-re, de elfelejtesz felvenni a [workspace] members listájába, a Cargo külön "beágyazott" workspace-nek fogja kezelni, és külön Cargo.lock-ot generál neki — ez zavaró duplikált függőségfeloldáshoz vezethet. Mindig ellenőrizd, hogy minden crate szerepel-e a members listában.
Összefoglalás
A Cargo workspace nem bonyolít, hanem éppen ellenkezőleg: rendet tesz, amint a projekted túlnő egy crate méretén. A workspace.dependencies mechanizmus megszünteti a verzióeltérések fejfájását, a -p és --workspace kapcsolók pedig pontos kontrollt adnak build és teszt szinten. Ha betartod az alapszabályokat — egységes edition, tiszta members lista, commitolt Cargo.lock binárisok esetén —, a workspace-ed hosszú távon is karbantartható marad, még akkor is, ha tíz vagy húsz tag crate-re nő a projekt.