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 core logiká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, cli crate-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).
Tipp

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");
    }
}
Megjegyzés

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.

Jó tudni

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.

Tipp

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());
}
Figyelem

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.