Cargo: Projekte erstellen, Dependencies verwalten und Workspaces organisieren
1. Übersicht
Cargo ist das offizielle Build-Tool und Package Manager von Rust. Es erstellt Projekte, lädt Dependencies herunter, kompiliert, führt Tests aus und verwaltet mehrere Crates in einem Repository.
Dieser Artikel behandelt drei Aufgaben, die du täglich mit Cargo erledigst. Erstens das Erstellen von Binary- und Library-Projekten. Zweitens das Hinzufügen und Konfigurieren von Dependencies. Drittens das Organisieren eines Workspaces mit mehreren Crates, die eine gemeinsame Konfiguration verwenden. Nach dem Lesen kannst du ein Rust-Projekt mit mehreren Modulen aufbauen, ohne Dependency-Versionen überall zu duplizieren.
2. Neues Projekt erstellen
2.1. Binary und Library
Der Befehl cargo new erstellt einen neuen Crate. Standardmäßig ist dies ein Binary-Crate, d. h. ein Programm mit einer main-Funktion:
cargo new order-service
cd order-service
Die resultierende Verzeichnisstruktur:
order-service/
├── Cargo.toml
├── .gitignore
└── src/
└── main.rs
Wenn du eine Bibliothek schreiben möchtest, die andere Crates verwenden können, fügst du das Flag --lib hinzu:
cargo new pricing --lib
Dann erstellt Cargo src/lib.rs statt src/main.rs. Ein Crate kann beide Dateien haben. Dann ruft main.rs die Logik in lib.rs über den Crate-Namen auf.
2.2. Die Datei Cargo.toml
Cargo.toml ist das Manifest des Crate:
[package]
name = "order-service"
version = "0.1.0"
edition = "2024"
[dependencies]
name: Name des Crate. Bindestriche-im Namen werden zu_, wenn du sie im Code verwendest, zum Beispielorder_service.version: Versionsnummer nach Semantic Versioning.edition: Sprachversion. Edition 2024 ist seit Rust 1.85 verfügbar undcargo newverwendet sie standardmäßig für das aktuelle Toolchain.[dependencies]: Liste der externen Crates, derzeit leer.
2.3. Grundlegende Build-Befehle
cargo check # Prüfe auf Kompilierungsfehler, ohne ausführbare Dateien zu erstellen
cargo build # Erstelle die Debug-Version in target/debug
cargo run # Erstelle und führe aus
cargo build --release # Erstelle die optimierte Version in target/release
Beim Schreiben von Code solltest du cargo check verwenden. Dieser Befehl ist viel schneller als cargo build, da die Codegenerierung übersprungen wird. Verwende --release nur beim Messen der Leistung oder beim Build für die Bereitstellung. Die Debug-Version kann um das Vielfache langsamer sein als die Release-Version.
3. Dependencies hinzufügen
3.1. Mit cargo add arbeiten
Angenommen, du musst eine Order in JSON serialisieren. Zwei beliebte Crates dafür sind serde und serde_json:
cargo add serde --features derive
cargo add serde_json
Cargo modifiziert Cargo.toml automatisch:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Du kannst diese Datei manuell bearbeiten. cargo add hilft dir nur, Tippfehler zu vermeiden und die neueste kompatible Version auszuwählen.
3.2. Was Versions-Anforderungen bedeuten
Der String "1.0" bedeutet nicht genau Version 1.0.0. Cargo interpretiert dies als ^1.0, d. h. jede Version >= 1.0.0 und < 2.0.0. Einige andere Schreibweisen:
[dependencies]
regex = "1.10" # >= 1.10.0, < 2.0.0
chrono = "=0.4.38" # genau 0.4.38
rand = "~0.8.5" # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9" # benutzerdefinierter Bereich
Bei Version 0.x behandelt Cargo die zweite Zahl als „Major”. Also erlaubt "0.4" nur 0.4.* und wird nicht auf 0.5 hochgestuft.
Die tatsächlich verwendete Version befindet sich in Cargo.lock. Cargo schreibt diese Datei beim ersten Build. Nachfolgende Builds verwenden diese Versionen, bis du folgendes ausführst:
cargo update # Aktualisiere alle Dependencies im erlaubten Bereich
cargo update -p serde # Aktualisiere nur serde
3.3. Dependencies im Code verwenden
Ändere 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)]ist nur verfügbar, wenn dasderive-Feature vonserdeaktiviert ist. Ohne dieses Feature meldet der Code einen Fehler, dass das Makro nicht gefunden wurde.serde_json::to_stringkonvertiert das Struct in einen JSON-String.serde_json::from_strmacht das Gegenteil. Der Zieltyp wird von der AnnotationOrderabgeleitet.
Führe cargo run aus:
{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }
3.4. Andere Arten von Dependencies
Neben [dependencies] hat Cargo zwei weitere häufig verwendete Gruppen:
[dev-dependencies]
pretty_assertions = "1.4" # nur beim Ausführen von Tests, Beispielen und Benchmarks
[build-dependencies]
cc = "1.0" # nur für build.rs
Crates in [dev-dependencies] werden nicht in die endgültige Binary aufgenommen und beeinflussen nicht die Benutzer deiner Bibliothek. Platziere Bibliotheken, die nur dem Testen dienen, hier.
Du kannst auch eine Dependency auf ein lokales Verzeichnis oder ein Git-Repository verweisen:
[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }
4. Workspaces organisieren
Wenn dein Projekt wächst, möchtest du es in mehrere Crates aufteilen. Beispiel: ein domain-Crate enthält die Geschäftslogik, ein api-Crate ist das laufende Programm. Ein Workspace ermöglicht es diesen Crates, Cargo.lock, das Verzeichnis target/ und Dependency-Versionen gemeinsam zu nutzen.
4.1. Root-Workspace erstellen
mkdir shop && cd shop
Erstelle 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"
- Diese Datei hat keine
[package], daher ist sie ein virtuelles Manifest. Es wird nur zum Zusammenfassen der Sub-Crates verwendet. resolver = "3"muss im virtuellen Manifest explizit deklariert werden. Ohne[package]kann Cargo den Resolver nicht aus der Edition ableiten und warnt, wenn du diese Zeile weglässt.members = ["crates/*"]sammelt alle Crates im Verzeichniscrates/.[workspace.package]und[workspace.dependencies]sind gemeinsame Werte, die Sub-Crates erben.
Erstelle zwei Sub-Crates:
cargo new crates/domain --lib
cargo new crates/api
Die resultierende Struktur:
shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
├── domain/
│ ├── Cargo.toml
│ └── src/lib.rs
└── api/
├── Cargo.toml
└── src/main.rs
4.2. Konfiguration vom Workspace erben
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 bedeutet, dass dieser Crate die Version und Features aus [workspace.dependencies] übernimmt. Wenn du serde aktualisieren musst, bearbeitest du nur eine Stelle in der Root-Datei.
Sub-Crates können immer noch zusätzliche Features aktivieren:
[dependencies]
serde = { workspace = true, features = ["rc"] }
Features hier werden zu den im Workspace deklarierten Features hinzugefügt, ersetzen sie nicht.
4.3. Code im 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);
}
}
pubauf Structs und Feldern ermöglicht es demapi-Crate, auf sie zuzugreifen.u64::from(line.quantity)konvertiertu32zuu64ohne Datenverlust.#[cfg(test)]stellt sicher, dass das Test-Modul nur kompiliert wird, wenncargo testausgeführt wird.
Tests für nur den domain-Crate ausführen:
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
Das Ausführen von cargo test im Root-Verzeichnis führt Tests aller Crates im Workspace aus.
4.4. domain von api aus verwenden
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"));
}
Aus dem Root-Verzeichnis:
cargo run -p api
Total: 60000
{
"id": 7,
"customer": "Dung",
"lines": [
{
"product": "Tea",
"unit_price_cents": 20000,
"quantity": 3
}
]
}
Der api-Crate deklariert serde nicht, kann aber trotzdem Order serialisieren. Der Grund ist, dass das Serialize-Trait bereits im domain-Crate implementiert ist. api braucht nur serde_json, um die Konvertierungsfunktion aufzurufen.
4.5. Nützliche Befehle im Workspace
cargo build --workspace # baue alle Crates
cargo check -p api # prüfe nur den api-Crate und seine Dependencies
cargo tree -p api # zeige den Dependency-Baum
cargo tree -d # liste Crates auf, die mehrere Versionen haben
cargo tree -d ist sehr hilfreich, wenn die Binary unerwartet groß ist. Das Ergebnis zeigt normalerweise, dass zwei Versionen desselben Crate gezogen werden.
5. Häufige Fehler
Hinweis: Features in Cargo werden vereinheitlicht (Feature Unification). Angenommen, Crate A aktiviert
tokio/fullund Crate B braucht nurtokio/rt. Beim gemeinsamen Build im Workspace erhält B auch allefull-Features. Code, der im Workspace funktioniert, kann fehlschlagen, wenn du B separat buildest. Führecargo check -p <crate-name>für jeden Crate in CI aus.
- Feature vergessen. Fehler wie „cannot find derive macro
Serialize” sind fast immer auf das Fehlen vonfeatures = ["derive"]zurückzuführen. Cargo.locknicht committen. Die aktuelle Cargo-Anleitung empfiehlt, diese Datei sowohl für Binaries als auch für Bibliotheken zu committen. Builds in CI können damit zwischen Durchläufen reproduziert werden.- Path-Dependency beim Publishing.
domain = { path = "../domain" }kann nicht auf crates.io veröffentlicht werden, wennversionfehlt. Um zu veröffentlichen, deklariere beide:{ path = "../domain", version = "0.1.0" }. - Verschachtelte Workspaces. Ein Crate gehört nur zu einem Workspace. Wenn du
cargo newin einem Unterverzeichnis eines anderen Workspaces ausführst, meldet Cargo einen Fehler oder fügt den Crate automatisch zumembershinzu. Überprüfe die Root-Datei nach der Erstellung. - Workspace übermäßig nutzen. Bei einem kleinen Programm musst du nur Module in einem Crate teilen (
mod order;). Das Aufteilen von Crates erhöht die Anzahl der Manifeste und die Konfigurationszeit. Verwende einen Workspace, wenn die Teile wirklich klare Grenzen haben, separate Builds oder Tests benötigen oder wiederverwendet werden müssen.
6. Fazit
Dieser Artikel hat drei Kernaufgaben mit Cargo behandelt:
- Erstelle Binary- oder Library-Projekte mit
cargo newund verwendecargo checkfür schnelles Feedback. - Füge Dependencies mit
cargo addhinzu, verstehe wie Version Requirements undCargo.lockdie tatsächliche Version bestimmen, und trennedev-dependenciesvon Haupt-Dependencies. - Fasse mehrere Crates in einem Workspace zusammen, deklariere gemeinsame Versionen in
[workspace.dependencies]und erbe sie mitworkspace = true.
Mit zunehmendem Projekt solltest du ein einfaches Prinzip beibehalten: Alle Dependency-Versionen befinden sich in der Root-Datei, und jeder Crate deklariert nur das, was er tatsächlich verwendet. Dies erleichtert Upgrades und die Kontrolle der Binary-Größe erheblich.