Cargo : créer des projets, gérer les dépendances et organiser un workspace

1. Tổng quan

Cargo est le build tool et package manager officiel de Rust. Il crée des projets, charge les dépendances, compile, exécute les tests et gère plusieurs crates dans un seul repository.

Cet article aborde trois tâches que vous accomplissez au quotidien avec Cargo. Premièrement, créer des projets binaires et des libraries. Deuxièmement, ajouter et configurer des dépendances. Troisièmement, organiser un workspace composé de plusieurs crates partageant une configuration commune. Après cette lecture, vous pourrez construire un projet Rust multi-modules sans dupliquer les versions des dépendances partout.

2. Créer un nouveau projet

2.1. Binary et library

La commande cargo new crée une nouvelle crate. Par défaut, c’est une binary crate, c’est-à-dire un programme qui a une fonction main :

cargo new order-service
cd order-service

La structure de répertoire générée :

order-service/
├── Cargo.toml
├── .gitignore
└── src/
    └── main.rs

Si vous voulez écrire une library que d’autres crates peuvent utiliser, ajoutez l’option --lib :

cargo new pricing --lib

Cargo crée alors src/lib.rs à la place de src/main.rs. Une crate peut avoir les deux fichiers. Dans ce cas, main.rs appelle la logique dans lib.rs via le nom de la crate.

2.2. Fichier Cargo.toml

Cargo.toml est le manifest de la crate :

[package]
name = "order-service"
version = "0.1.0"
edition = "2024"

[dependencies]
  • name : le nom de la crate. Les tirets - dans le nom deviennent des underscores _ quand vous l’utilisez dans le code, par exemple order_service.
  • version : la version selon le Semantic Versioning.
  • edition : la version du langage. L’édition 2024 est disponible depuis Rust 1.85, et cargo new l’utilise par défaut sur la toolchain actuelle.
  • [dependencies] : la liste des crates externes, actuellement vide.

2.3. Commandes de build essentielles

cargo check        # vérifie les erreurs de compilation, ne génère pas d'exécutable
cargo build        # compile la version debug dans target/debug
cargo run          # compile puis exécute
cargo build --release   # compile optimisée dans target/release

Pendant que vous écrivez du code, vous devriez utiliser cargo check. Cette commande est beaucoup plus rapide que cargo build car elle ignore la génération de code machine. N’utilisez --release que pour mesurer les performances ou compiler pour le déploiement. La version debug peut être des dizaines de fois plus lente que la version release.

3. Ajouter des dépendances

3.1. Utiliser cargo add

Supposons que vous ayez besoin de sérialiser une Order en JSON. Deux crates populaires pour cela sont serde et serde_json :

cargo add serde --features derive
cargo add serde_json

Cargo modifie automatiquement Cargo.toml :

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Vous pouvez éditer ce fichier manuellement. cargo add vous aide simplement à éviter les erreurs de saisie et sélectionne automatiquement la version la plus récente compatible.

3.2. Qu’est-ce qu’une exigence de version ?

La chaîne "1.0" ne signifie pas la version exacte 1.0.0. Cargo l’interprète comme ^1.0, c’est-à-dire toute version >= 1.0.0 et < 2.0.0. Voici d’autres façons d’écrire :

[dependencies]
regex = "1.10"          # >= 1.10.0, < 2.0.0
chrono = "=0.4.38"      # exactement 0.4.38
rand = "~0.8.5"         # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9"    # plage personnalisée

Avec les versions 0.x, Cargo considère le deuxième chiffre comme la partie « majeure ». Ainsi "0.4" ne permet que 0.4.* et ne peut pas être mis à niveau vers 0.5.

Les versions réellement utilisées se trouvent dans Cargo.lock. Cargo écrit ce fichier lors de la première compilation. Les compilations suivantes utiliseront exactement ces versions jusqu’à ce que vous exécutiez :

cargo update              # met à jour toutes les dépendances dans la plage autorisée
cargo update -p serde     # met à jour uniquement serde

3.3. Utiliser une dépendance dans le code

Modifiez src/main.rs :

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Order {
    id: u64,
    customer: String,
    total_cents: u64,
}

fn main() {
    let order = Order {
        id: 42,
        customer: "An".to_string(),
        total_cents: 150_000,
    };

    let json = serde_json::to_string(&order).expect("serialize Order");
    println!("{json}");

    let parsed: Order = serde_json::from_str(&json).expect("deserialize Order");
    println!("{parsed:?}");
}
  • #[derive(Serialize, Deserialize)] n’est disponible que si vous activez la feature derive de serde. Sans cette feature, le code produit une erreur indiquant que la macro n’a pas été trouvée.
  • serde_json::to_string convertit la struct en chaîne JSON.
  • serde_json::from_str fait l’inverse. Le type de destination est déduit de l’annotation Order.

Exécutez cargo run :

{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }

3.4. Autres types de dépendances

En plus de [dependencies], Cargo a deux autres groupes couramment utilisés :

[dev-dependencies]
pretty_assertions = "1.4"   # utilisé uniquement lors de l'exécution des tests, exemples, benchmarks

[build-dependencies]
cc = "1.0"                  # utilisé uniquement pour build.rs

Les crates dans [dev-dependencies] ne sont pas inclus dans le binaire final et n’affectent pas les utilisateurs de votre library. Placez les bibliothèques uniquement destinées aux tests ici.

Vous pouvez également pointer une dépendance vers un répertoire local ou Git :

[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }

4. Organiser un workspace

À mesure que votre projet grandit, vous voudrez le diviser en plusieurs crates. Par exemple : une crate domain contenant la logique métier, une crate api contenant le programme exécutable. Un workspace permet à ces crates de partager Cargo.lock, le répertoire target/ et les versions des dépendances.

4.1. Créer le workspace racine

mkdir shop && cd shop

Créez shop/Cargo.toml :

[workspace]
resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
  • Ce fichier n’a pas de [package], donc c’est un virtual manifest. Il sert uniquement à regrouper les crates enfants.
  • resolver = "3" doit être déclaré explicitement dans un virtual manifest. Sans [package], Cargo ne peut pas déduire le resolver de l’edition et vous avertira si vous omettez cette ligne.
  • members = ["crates/*"] regroupe toutes les crates qui se trouvent dans le répertoire crates/.
  • [workspace.package] et [workspace.dependencies] sont des valeurs partagées que les crates enfants héritent.

Créez deux crates enfants :

cargo new crates/domain --lib
cargo new crates/api

La structure obtenue :

shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
    ├── domain/
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── api/
        ├── Cargo.toml
        └── src/main.rs

4.2. Hériter la configuration du workspace

crates/domain/Cargo.toml :

[package]
name = "domain"
version.workspace = true
edition.workspace = true

[dependencies]
serde.workspace = true

crates/api/Cargo.toml :

[package]
name = "api"
version.workspace = true
edition.workspace = true

[dependencies]
domain = { path = "../domain" }
serde_json.workspace = true

serde.workspace = true signifie que cette crate récupère la version et les features de [workspace.dependencies]. Quand vous avez besoin de mettre à jour serde, vous ne modifiez qu’un seul endroit dans le fichier racine.

La crate enfant peut toujours activer des features supplémentaires :

[dependencies]
serde = { workspace = true, features = ["rc"] }

Les features ici sont ajoutées aux features déclarées dans le workspace, elles ne les remplacent pas.

4.3. Code dans la crate domain

crates/domain/src/lib.rs :

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderLine {
    pub product: String,
    pub unit_price_cents: u64,
    pub quantity: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub lines: Vec<OrderLine>,
}

impl Order {
    pub fn total_cents(&self) -> u64 {
        self.lines
            .iter()
            .map(|line| line.unit_price_cents * u64::from(line.quantity))
            .sum()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line(product: &str, price: u64, qty: u32) -> OrderLine {
        OrderLine {
            product: product.to_string(),
            unit_price_cents: price,
            quantity: qty,
        }
    }

    #[test]
    fn total_sums_all_lines() {
        let order = Order {
            id: 1,
            customer: "Binh".to_string(),
            lines: vec![line("Coffee", 35_000, 2), line("Cake", 25_000, 1)],
        };

        assert_eq!(order.total_cents(), 95_000);
    }

    #[test]
    fn empty_order_has_zero_total() {
        let order = Order { id: 2, customer: "Chi".to_string(), lines: vec![] };

        assert_eq!(order.total_cents(), 0);
    }
}
  • pub sur la struct et les champs permet à la crate api d’y accéder.
  • u64::from(line.quantity) convertit u32 en u64 sans perte de données.
  • #[cfg(test)] s’assure que le module de test n’est compilé que lors de l’exécution de cargo test.

Exécutez les tests uniquement pour la crate domain :

cargo test -p domain
running 2 tests
test tests::empty_order_has_zero_total ... ok
test tests::total_sums_all_lines ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Exécuter cargo test dans le répertoire racine exécute les tests de toutes les crates du workspace.

4.4. Utiliser domain depuis api

crates/api/src/main.rs :

use domain::{Order, OrderLine};

fn main() {
    let order = Order {
        id: 7,
        customer: "Dung".to_string(),
        lines: vec![OrderLine {
            product: "Tea".to_string(),
            unit_price_cents: 20_000,
            quantity: 3,
        }],
    };

    println!("Total: {}", order.total_cents());
    println!("{}", serde_json::to_string_pretty(&order).expect("serialize"));
}

Depuis le répertoire racine :

cargo run -p api
Total: 60000
{
  "id": 7,
  "customer": "Dung",
  "lines": [
    {
      "product": "Tea",
      "unit_price_cents": 20000,
      "quantity": 3
    }
  ]
}

La crate api ne déclare pas serde, mais peut toujours sérialiser Order. C’est parce que le trait Serialize est déjà implémenté dans la crate domain. api n’a besoin que de serde_json pour appeler la fonction de conversion.

4.5. Commandes utiles dans le workspace

cargo build --workspace     # compile toutes les crates
cargo check -p api          # vérifie uniquement la crate api et ses dépendances
cargo tree -p api           # affiche l'arbre des dépendances
cargo tree -d               # liste les crates qui ont plusieurs versions

cargo tree -d est très utile quand votre binaire devient anormalement volumineux. Le résultat montre souvent qu’il y a deux versions de la même crate qui sont incluses.

5. Erreurs courantes

Remarque : Les features dans Cargo sont unifiées. Supposons que la crate A active tokio/full et que la crate B n’a besoin que de tokio/rt. Lors de la compilation ensemble dans un workspace, B bénéficie aussi de full. Le code qui fonctionne dans le workspace peut échouer quand vous compilez B seul. Exécutez cargo check -p <nom-crate> pour chaque crate dans votre CI.

  • Oublier d’activer une feature. Les erreurs du type « cannot find derive macro Serialize » sont presque toujours dues à l’absence de features = ["derive"].
  • Ne pas committer Cargo.lock. La recommandation actuelle de Cargo est de committer ce fichier pour les binaires comme pour les libraries. Les builds dans le CI sont ainsi reproductibles d’une exécution à l’autre.
  • Path dependency lors de la publication. domain = { path = "../domain" } ne peut pas être publié sur crates.io sans version. Pour publier, déclarez les deux : { path = "../domain", version = "0.1.0" }.
  • Workspaces imbriqués. Une crate n’appartient qu’à un seul workspace. Si vous faites cargo new dans un sous-répertoire d’un autre workspace, Cargo signale une erreur ou ajoute automatiquement la crate à members. Vérifiez le fichier racine après la création.
  • Abuser du workspace. Avec un petit programme, vous n’avez besoin que de diviser en modules dans une seule crate (mod order;). Séparer en crates augmente le nombre de manifests et le temps de configuration. Utilisez un workspace quand les parties ont vraiment des limites claires, quand elles ont besoin d’être compilées ou testées séparément, ou quand elles doivent être réutilisées.

6. Conclusion

Cet article a couvert trois tâches essentielles avec Cargo :

  • Créer des projets binaires ou libraries avec cargo new, et utiliser cargo check pour avoir des retours rapides.
  • Ajouter des dépendances avec cargo add, comprendre comment les exigences de version et Cargo.lock déterminent ensemble les versions réelles, et séparer dev-dependencies des dépendances principales.
  • Regrouper plusieurs crates dans un workspace, déclarer les versions partagées dans [workspace.dependencies] puis les hériter via workspace = true.

À mesure que votre projet grandit, respectez un principe simple : toutes les versions des dépendances se trouvent dans le fichier racine, chaque crate ne déclare que ce qu’elle utilise vraiment. Cette approche rend les mises à jour et le contrôle de la taille du binaire beaucoup plus faciles.

rustcargodependencyworkspacebuild-tool