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 target mappa – 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 build vagy cargo test paranccsal 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 target mappa révén.
Megjegyzés

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);
}
Tipp

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"] }
Jó tudni

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
Tipp

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.

Figyelem

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.