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 exempleorder_service.version: la version selon le Semantic Versioning.edition: la version du langage. L’édition 2024 est disponible depuis Rust 1.85, etcargo newl’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 featurederivedeserde. Sans cette feature, le code produit une erreur indiquant que la macro n’a pas été trouvée.serde_json::to_stringconvertit la struct en chaîne JSON.serde_json::from_strfait l’inverse. Le type de destination est déduit de l’annotationOrder.
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épertoirecrates/.[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);
}
}
pubsur la struct et les champs permet à la crateapid’y accéder.u64::from(line.quantity)convertitu32enu64sans perte de données.#[cfg(test)]s’assure que le module de test n’est compilé que lors de l’exécution decargo 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/fullet que la crate B n’a besoin que detokio/rt. Lors de la compilation ensemble dans un workspace, B bénéficie aussi defull. Le code qui fonctionne dans le workspace peut échouer quand vous compilez B seul. Exécutezcargo 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 defeatures = ["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 sansversion. 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 newdans 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 utilisercargo checkpour avoir des retours rapides. - Ajouter des dépendances avec
cargo add, comprendre comment les exigences de version etCargo.lockdéterminent ensemble les versions réelles, et séparerdev-dependenciesdes dépendances principales. - Regrouper plusieurs crates dans un workspace, déclarer les versions partagées dans
[workspace.dependencies]puis les hériter viaworkspace = 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.