Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 13: Module, Pfade und das Cargo-Ökosystem – Software-Architektur und professionelles Cargo-Management

In größeren Softwareprojekten reicht ein einfaches Verständnis von “öffentlich” und “privat” oft nicht aus. Wenn Sie eine Bibliothek entwickeln, die von Hunderten anderen Entwicklern genutzt wird, möchten Sie bestimmte Implementierungsdetails vielleicht innerhalb Ihres eigenen Projekts teilen, diese aber keinesfalls in der öffentlichen API der Bibliothek freigeben. Zudem erfordern komplexe Systemarchitekturen eine feingranulare Steuerung des Build-Prozesses, die Verwaltung mehrerer Repositories in einem gemeinsamen Workspace und die optionale Kompilierung je nach Zielplattform oder Feature-Wunsch.

In diesem fortgeschrittenen Abschnitt betrachten wir das Modul- und Cargo-System aus der Perspektive des Software-Architekten.


1. Lernziele – Das wirst du heute lernen

  • Feingranulare Sichtbarkeiten: Sie steuern die Sichtbarkeit von APIs präzise mit pub(crate), pub(super) und pub(in pfad).
  • Crates vs. Packages vs. Workspaces: Sie kennen die genauen Unterschiede und organisieren große Multi-Projekt-Strukturen.
  • Erweiterte Cargo.toml-Konfiguration: Sie binden Abhängigkeiten über Git-Pfade oder lokale Verzeichnisse ein und nutzen dev-dependencies.
  • Build-Skripte (build.rs): Sie generieren Code oder verknüpfen C-Bibliotheken vor dem eigentlichen Build-Vorgang.
  • Bedingte Kompilierung: Sie steuern plattformspezifischen Code mittels #[cfg(...)].
  • Feature-Flags: Sie machen optionale Features konfigurierbar, um Kompilierzeit und Binärgröße zu optimieren.

2. Feingranulare Sichtbarkeiten: Präzise API-Kontrolle

Die einfache binäre Unterscheidung zwischen privat (standardmäßig) und öffentlich (pub) stößt in großen Projekten schnell an Grenzen. Rust bietet daher erweiterte Sichtbarkeits-Modifikatoren an, mit denen Sie den Zugriff auf Klassen, Funktionen und Strukturen exakt einschränken können:

  • pub(crate): Das Element ist innerhalb des gesamten aktuellen Crates (des eigenen Projekts) sichtbar, wird aber nicht nach außen (für andere Crates, die diese Bibliothek einbinden) exportiert.
  • pub(super): Das Element ist nur im direkt übergeordneten Modul (dem Elternmodul) sichtbar.
  • pub(in pfad): Das Element ist nur innerhalb des angegebenen Modulpfads sichtbar. Der Pfad muss ein Vorfahr des aktuellen Moduls sein.

Ein Architekturbeispiel:

// Ein Crate für eine Datenbank-Engine
pub mod datenbank {
    pub struct Verbindung {
        // Diese URL darf nur innerhalb dieses Crates verwendet werden.
        // Externe Nutzer der Bibliothek dürfen sie nicht sehen oder ändern!
        pub(crate) verbindungs_url: String,
    }

    mod intern {
        // Diese Hilfsfunktion ist nur für das Modul "datenbank" sichtbar
        pub(super) fn ping_pruefen() {
            println!("Datenbank antwortet.");
        }
        
        // Diese Funktion ist nur im Modul "datenbank::intern" und seinen Kindern sichtbar
        pub(self) fn geheimes_logging() {
            println!("Schreibe geheimes Log.");
        }
    }

    pub fn verbindung_aufbauen(url: &str) -> Verbindung {
        intern::ping_pruefen(); // Erlaubt wegen pub(super)
        Verbindung {
            verbindungs_url: url.to_string(),
        }
    }
}

fn main() {
    let verb = datenbank::verbindung_aufbauen("postgres://localhost");
    
    // Das funktioniert NICHT, da "verbindungs_url" nur projektintern (pub(crate)) ist:
    // println!("URL: {}", verb.verbindungs_url);
}

3. Die Konzepte im Detail: Crates, Packages und Workspaces

Um die Paketverwaltung und das Build-System in Rust zu verstehen, müssen wir drei Begriffe exakt voneinander abgrenzen:

  1. Crate (Übersetzungseinheit): Die kleinste Compilationseinheit, die der Compiler (rustc) verarbeitet. Ein Crate besteht aus einem Modulbaum und wird entweder zu einer ausführbaren Binärdatei (Binary Crate) oder zu einer wiederverwendbaren Bibliothek (Library Crate) übersetzt.
  2. Package (Paket): Ein Cargo-Projekt, das durch eine Cargo.toml-Datei beschrieben wird. Ein Package enthält Metadaten, Konfigurationen und kann:
    • Maximal ein Library Crate enthalten (src/lib.rs).
    • Beliebig viele Binary Crates enthalten (src/main.rs oder zusätzliche Dateien im Ordner src/bin/).
  3. Workspace (Arbeitsbereich): Eine Zusammenfassung mehrerer Packages in einer gemeinsamen Projektmappe. Ein Workspace ermöglicht es verschiedenen Packages, sich denselben Ausgabeordner (target/) und dieselbe Sperrdatei (Cargo.lock) zu teilen, was Speicherplatz spart und die Kompilierzeit drastisch verringert.

4. Fortgeschrittene Cargo.toml-Konfiguration

Neben den Standardabhängigkeiten von crates.io erlaubt Cargo feingranulare Einstellungen in der Cargo.toml.

Git- und lokale Pfad-Abhängigkeiten

Während der Entwicklung einer Bibliothek ist es oft unpraktisch, jede Änderung erst auf crates.io hochzuladen. Sie können stattdessen direkt lokale Verzeichnisse oder Git-Repositories referenzieren:

[dependencies]
# Lokale Abhängigkeit auf der Festplatte
datenbank_treiber = { path = "../datenbank_treiber" }

# Abhängigkeit direkt aus einem Git-Repository
crypt_helper = { git = "https://github.com/beispiel/crypt.git", branch = "main" }

Entwicklungsabhängigkeiten ([dev-dependencies])

Einige Bibliotheken werden ausschließlich für Tests, Beispiele oder Leistungsbenchmarks benötigt (z. B. spezielle Assertions-Bibliotheken). Damit diese im finalen Release-Build keine unnötige Größe verursachen und die Kompilierzeit der Anwender nicht belasten, deklarieren Sie sie unter [dev-dependencies]:

[dev-dependencies]
pretty_assertions = "1.4.0" # Verbessert die Lesbarkeit von Testfehlern

5. Build-Skripte (build.rs) und Build-Abhängigkeiten

Manchmal müssen vor dem eigentlichen Kompilieren des Rust-Codes Aufgaben auf Betriebssystemebene ausgeführt werden. Typische Beispiele sind:

  • Das automatische Generieren von Rust-Code aus anderen Formaten (z. B. Protocol Buffers oder SQL-Dateien).
  • Das Kompilieren und Verlinken einer alten C/C++-Bibliothek über FFI (Foreign Function Interface).
  • Das Auslesen von Umgebungsvariablen zur Compilezeit.

Dazu platzieren Sie eine Datei namens build.rs im Wurzelverzeichnis Ihres Packages (neben der Cargo.toml). Cargo kompiliert und führt dieses Skript aus, bevor der eigentliche Rust-Code übersetzt wird. Bibliotheken, die nur von diesem Build-Skript benötigt werden, tragen Sie unter [build-dependencies] ein:

# Cargo.toml
[build-dependencies]
cc = "1.0" # C-Compiler-Wrapper für Rust

Ein einfaches Beispiel für ein build.rs Skript:

// build.rs (wird vor dem eigentlichen Projekt ausgeführt)
fn main() {
    // Teilt Cargo mit: "Führe dieses Skript nur erneut aus, wenn sich src/api.proto ändert."
    println!("cargo:rerun-if-changed=src/api.proto");
    
    // Code-Generierung oder Linker-Anweisungen hier...
}

6. Bedingte Kompilierung und Feature-Flags

Rust ermöglicht es Ihnen, Teile des Codes je nach Zielplattform oder Anwenderkonfiguration ein- oder auszuschließen.

Das #[cfg(...)]-Attribut vs. das cfg!(...)-Makro

  • #[cfg(target_os = "windows")] (Attribut): Der markierte Code wird vom Compiler vollständig ignoriert, wenn das Zielbetriebssystem kein Windows ist. Dies ist zwingend erforderlich, wenn Sie Windows-spezifische APIs aufrufen, die auf Linux gar nicht existieren.
  • cfg!(target_os = "windows") (Makro): Liefert zur Laufzeit einen booleschen Wert (true/false). Der gesamte Code wird jedoch auf allen Plattformen kompiliert. Nutzen Sie das Makro nur für plattformübergreifende Pfade, die auf allen Systemen syntaktisch valide sind.

Feature-Flags zur modularen Code-Steuerung

Feature-Flags erlauben es Bibliotheksautoren, optionale Funktionalitäten anzubieten. Anwender aktivieren nur die Features, die sie tatsächlich benötigen.

Definition in der Cargo.toml:

[features]
# Standardmäßig aktive Features
default = ["json"]

# Feature-Definitionen
json = []
pdf_export = ["dep:pdf_writer"] # Aktiviert ein optionales Crate

[dependencies]
pdf_writer = { version = "0.7", optional = true }

Im Rust-Code nutzen Sie das cfg-Attribut:

#![allow(unused)]
fn main() {
#[cfg(feature = "pdf_export")]
pub fn dokument_exportieren() {
    println!("PDF wird generiert...");
}
}

7. Cargo Workspaces für Multi-Projekt-Architekturen

Bei sehr großen Applikationen (z. B. einer Web-App bestehend aus einem API-Server, einem CLI-Client und einer gemeinsamen Logik-Bibliothek) ist es Best Practice, das Projekt in einen Workspace aufzuteilen.

Die Struktur eines Workspaces sieht typischerweise so aus:

mein_workspace/
├── Cargo.toml       <-- Workspace-Konfiguration
├── Cargo.lock       <-- Geteilte Sperrdatei
├── target/          <-- Geteiltes Ausgabeverzeichnis
├── api_server/      <-- Eigenständiges Package (mit eigener Cargo.toml)
├── cli_client/      <-- Eigenständiges Package (mit eigener Cargo.toml)
└── core_lib/        <-- Logik-Bibliothek (mit eigener Cargo.toml)

In der Haupt-Cargo.toml deklarieren Sie den Workspace:

[workspace]
members = [
    "api_server",
    "cli_client",
    "core_lib",
]
resolver = "2"

Die Vorteile:

  • Geteilter Build-Cache: Wenn sowohl api_server als auch cli_client die Bibliothek serde verwenden, wird diese nur ein einziges Mal kompiliert. Das spart massiv Zeit und Festplattenplatz.
  • Einheitliche Versionen: Die Cargo.lock sorgt dafür, dass alle Packages im Workspace exakt dieselben Versionen ihrer externen Abhängigkeiten nutzen.