Kapitel 4. Abhängigkeiten

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Wenn die Götter uns bestrafen wollen, erhören sie unsere Gebete.

Oscar Wilde

Jahrzehntelang war die Idee der Wiederverwendung von Code nur ein Traum. Die Vorstellung, dass Code einmal geschrieben, in eine Bibliothek gepackt und in vielen verschiedenen Anwendungen wiederverwendet werden kann, war ein Ideal, das nur für einige wenige Standardbibliotheken und für firmeninterne Tools realisiert wurde.

Das Wachstum des Internets und der Aufstieg von Open-Source-Software änderte dies schließlich. Das erste offen zugängliche Repository, das eine große Sammlung nützlicher Bibliotheken, Tools und Hilfsmittel enthielt, die alle zur einfachen Wiederverwendung verpackt waren, war CPAN: das Comprehensive Perl Archive Network, das seit 1995 online ist. Heute verfügt fast jede moderne Sprache über eine umfassende Sammlung von Open-Source-Bibliotheken, die in einem Paket-Repository untergebracht sind, das das Hinzufügen neuer Abhängigkeiten einfach und schnell macht.1

Mit dieser Einfachheit, Bequemlichkeit und Schnelligkeit gehen jedoch auch neue Probleme einher. Es ist in der Regel immer noch einfacher, vorhandenen Code wiederzuverwenden, als ihn selbst zu schreiben, aber es gibt potenzielle Fallstricke und Risiken, die mit der Abhängigkeit von fremdem Code einhergehen. Dieses Kapitel des Buches hilft dir, diese zu erkennen.

Der Fokus liegt speziell auf Rust und damit auf der Verwendung des cargo Werkzeugs, aber viele der behandelten Anliegen, Themen und Fragen gelten auch für andere Toolchains (und andere Sprachen).

Punkt 21: Verstehe, was die semantische Versionierung verspricht

Wenn wir anerkennen, dass SemVer eine verlustbehaftete Schätzung ist und nur einen Teil des möglichen Umfangs der Veränderungen darstellt, können wir es als ein stumpfes Instrument betrachten.

Titus Winters, Softwareentwicklung bei Google (O'Reilly)

Cargo, der Paketmanager von Rust, ermöglicht die automatische Auswahl von Abhängigkeiten(Punkt 25) für Rust-Code gemäß der semantischen Versionierung (semver). Eine Cargo.toml Stanza wie:

[dependencies]
serde = "1.4"

gibt cargo an, welche Bereiche von Semver-Versionen für diese Abhängigkeit akzeptabel sind. In der offiziellen Dokumentation findest du die genauen Angaben zu den zulässigen Versionsbereichen, aber sind die folgenden Varianten die am häufigsten verwendeten:

"1.2.3"

Legt fest, dass jede Version, die mit 1.2.3 halbwegs kompatibel ist, akzeptiert wird.

"^1.2.3"

Ist eine andere Art, dieselbe Sache expliziter zu formulieren

"=1.2.3"

Pins zu einer bestimmten Version, wobei keine Ersatzversionen akzeptiert werden

"~1.2.3"

Erlaubt Versionen, die halbkompatibel mit 1.2.3 sind, aber nur, wenn sich die letzte angegebene Komponente ändert (also ist 1.2.4 akzeptabel, 1.3.0 aber nicht)

"1.2.*"

Akzeptiert jede Version, die auf den Platzhalter passt

Beispiele dafür, was diese Spezifikationen erlauben, sind in Tabelle 4-1 aufgeführt.

Tabelle 4-1. Spezifikation der Version der Ladungsabhängigkeit
Spezifikation 1.2.2 1.2.3 1.2.4 1.3.0 2.0.0

"1.2.3"

Nein

Ja

Ja

Ja

Nein

"^1.2.3"

Nein

Ja

Ja

Ja

Nein

"=1.2.3"

Nein

Ja

Nein

Nein

Nein

"~1.2.3"

Nein

Ja

Ja

Nein

Nein

"1.2.*"

Ja

Ja

Ja

Nein

Nein

"1.*"

Ja

Ja

Ja

Ja

Nein

"*"

Ja

Ja

Ja

Ja

Ja

Bei der Auswahl der Abhängigkeitsversionen wählt Cargo in der Regel die größte Version aus, die innerhalb der Kombination all dieser Halbwertbereiche liegt.

Da die semantische Versionierung das Herzstück des cargoProzesses zur Auflösung von Abhängigkeiten ist, wird in diesem Artikel genauer erklärt, was semver bedeutet.

Semver Essentials

Die Grundlagen der semantischen Versionierung von sind in der Zusammenfassung in der Semver-Dokumentation aufgeführt, die hier wiedergegeben wird:

Wenn du eine Versionsnummer MAJOR.MINOR.PATCH hast, erhöhe die:

  • MAJOR-Version, wenn du inkompatible API-Änderungen vornimmst

  • MINOR-Version, wenn du Funktionen auf abwärtskompatible Weise hinzufügst

  • PATCH-Version, wenn du rückwärtskompatible Fehlerbehebungen vornimmst

Ein wichtiger Punkt verbirgt sich in den Details:

  1. Sobald ein versioniertes Paket veröffentlicht wurde, darf der Inhalt dieser Version NICHT mehr verändert werden. Jede Änderung MUSS als neue Version veröffentlicht werden.

Um es in andere Worte zu fassen:

  • Wenn du etwas änderst, brauchst du eine neue Patch-Version.

  • Wenn du die API soerweiterst, dass bestehende Benutzer der Kiste weiterhin kompilieren und arbeiten können, ist ein kleines Versions-Upgrade erforderlich.

  • Das Entfernen oder Ändernvon Dingen in der API erfordert ein großes Versions-Upgrade.

Es gibt noch einen wichtigen Zusatz zu den Semver-Regeln:

  1. Die Hauptversion Null (0.y.z) ist für die Erstentwicklung. Alles KANN sich jederzeit ändern. Die öffentliche API SOLLTE NICHT als stabil angesehen werden.

Cargo passt diese letzte Regel leicht an, indem es die früheren Regeln "nach links verschiebt", so dass Änderungen in der ganz linken Nicht-Null-Komponente inkompatible Änderungen anzeigen. Das bedeutet, dass 0.2.3 zu 0.3.0 eine inkompatible API-Änderung enthalten kann, genauso wie 0.0.4 zu 0.0.5.

Semver für Kistenautoren

In der Theorie ist die Theorie dasselbe wie die Praxis. In der Praxis ist sie es nicht.

Als Autor von crate ist die erste dieser Regeln theoretisch leicht zu befolgen: Wenn du etwas veränderst, brauchst du eine neue Version. Die Verwendung von Git-Tags zum Zuordnen von Versionen kann dabei helfen-standardmäßig ist ein Tag an einen bestimmten Commit gebunden und kann nur mit einer manuellen --forceOption verschoben werden. Kisten, die auf crates.io veröffentlicht werden, werden automatisch überwacht, da die Registry einen zweiten Versuch, dieselbe Crate-Version zu veröffentlichen, ablehnt. Die größte Gefahr für die Nichteinhaltung besteht darin, dass du einen Fehler kurz nach der Veröffentlichung bemerkst und der Versuchung widerstehen musst, einfach eine Korrektur einzubauen.

Die Semver-Spezifikation deckt die API-Kompatibilität ab. Wenn du also eine kleine Änderung am Verhalten vornimmst, die die API nicht verändert, sollte ein Update der Patch-Version ausreichen. (Wenn deine Kiste jedoch weit verbreitet ist, musst du dich in der Praxis an das Hyrum'sche Gesetz halten: Egal, wie geringfügig du den Code änderst, irgendjemand da draußen wird sichwahrscheinlich auf das alte Verhalten verlassen - selbstwenn die API unverändert ist).

Der schwierige Teil für Kistenautoren sind die letztgenannten Regeln, die eine genaue Bestimmung erfordern, ob eine Änderung rückwärtskompatibel ist oder nicht. Einige Änderungen sind offensichtlich inkompatibel - das Entfernen von öffentlichen Einstiegspunkten oder Typen, das Ändern von Methodensignaturen - und einige Änderungen sind offensichtlich abwärtskompatibel (z.B. das Hinzufügen einer neuen Methode zu einer struct oder das Hinzufügen einer neuen Konstante), aber dazwischen gibt es eine Menge Grauzonen.

Um dir dabei zu helfen, geht das Cargo-Buch sehr detailliert darauf ein, was rückwärtskompatibel ist und was nicht. Die meisten dieser Details sind nicht überraschend, aber es gibt ein paar Bereiche, die hervorzuheben sind:

  • Das Hinzufügen neuer Elemente ist normalerweise sicher, kann aber zu Konflikten führen, wenn der Code, der die Kiste verwendet, bereits etwas verwendet, das zufällig den gleichen Namen wie das neue Element hat.

  • Da Rust darauf besteht, alle Möglichkeiten abzudecken, kann eine Änderung der Menge der verfügbaren Möglichkeiten eine einschneidende Veränderung sein.

    • Eine match auf enum muss alle Möglichkeiten abdecken. Wenn also eine Kiste eine neue enum Variante hinzufügt, ist das eine brechende Änderung (es sei denn, die enum ist bereits als non_exhaustive-das Hinzufügenvonnon_exhaustive ist auch eine brechende Änderung).

    • Das explizite Erstellen einer Instanz von struct erfordert einen Anfangswert für alle Felder, daher ist das Hinzufügen eines Feldes zu einer Struktur, die öffentlich instanziiert werden kann,eine bahnbrechende Änderung. Strukturen mit privaten Feldern sind in Ordnung, da Kistenbenutzer sie ohnehin nicht explizit erstellen können; eine struct kann auchals non_exhaustive markiert werden, um zu verhindern, dass externe Benutzersie expliziterstellen.

  • Wenn du einen Trait so änderst, dass er nicht mehr objektsicher ist(Punkt 12), ist das eine bahnbrechende Änderung; alle Benutzer, die Trait-Objekte für diesen Trait erstellen, können ihren Code nicht mehr kompilieren.

  • Das Hinzufügen einer neuen Blanket-Implementierung für einen Trait ist eine bahnbrechende Änderung; alle Benutzer, die den Trait bereits implementieren, haben nun zwei sich widersprechende Implementierungen.

  • Die Änderung der Lizenz einer Open-Source-Kiste ist eine inkompatible Änderung: Benutzer deiner Kiste, die strengeEinschränkungen bezüglich der zulässigen Lizenzen haben, werden durch die Änderung möglicherweise beeinträchtigt. Betrachte die Lizenz als einen Teil deiner API.

  • Das Ändern der Standardfunktionen(Punkt 26) einer Kiste ist eine potenziell schädliche Änderung. Das Entfernen einer Standardfunktion wird mit ziemlicher Sicherheit etwas kaputt machen (es sei denn, die Funktion war bereits deaktiviert); das Hinzufügen einer Standardfunktion kann etwas kaputt machen, je nachdem, was sie ermöglicht. Betrachte das Standard-Feature-Set als Teil deiner API.

  • Wenn du den Code einer Bibliothek so änderst, dass er ein neues Feature von Rust verwendet , kann das eine inkompatible Änderung sein, weil Benutzer deines Crates, die ihren Compiler noch nicht auf eine Version aktualisiert haben, die das Feature enthält, von der Änderung betroffen sind.Die meisten Rust-Crates behandeln jedoch eine Erhöhung der minimal unterstützten Rust-Version (MSRV) als eine nicht-brechende Änderung, also überlege, ob die MSRV Teil deiner API ist.

Eine offensichtliche Konsequenz der Regeln ist: Je weniger öffentliche Gegenstände eine Kiste hat, desto weniger Dinge gibt es, die eine unvereinbare Veränderung bewirken können(Punkt 22).

Es lässt sich jedoch nicht vermeiden, dass der Vergleich aller öffentlichen API-Elemente auf Kompatibilität von einer Version zur nächsten ein zeitaufwändiger Prozess ist, der bestenfalls eine ungefähre Einschätzung des Änderungsgrads (Major/Minor/Patch) liefert. Da dieser Vergleich ein eher mechanischer Prozess ist, wird es hoffentlich bald Werkzeuge(Punkt 31) geben, die diesen Prozess vereinfachen.2

Wenn du eine inkompatible Hauptversion ändern musst, ist es gut, deinen Nutzern das Leben zu erleichtern, indem du sicherstellst, dass nach der Änderung die gleiche Gesamtfunktionalität zur Verfügung steht, auch wenn sich die API radikal geändert hat. Wenn möglich, ist die hilfreichste Reihenfolge für deine Kistennutzer die folgende:

  1. Gib ein kleines Versionsupdate heraus, das die neue Version der API enthält und die ältere Variante alsdeprecatedmarkiert und einen Hinweis darauf enthält, wie die Migration erfolgen kann.

  2. Gib ein großes Versions-Update heraus, das die veralteten Teile der API entfernt.

Ein etwas subtilerer Punkt ist, dass du Änderungen, die zu einem Bruch führen, unterlassen solltest. Wenn deine Kiste ihr Verhalten auf eine Art und Weise ändert, die für bestehende Benutzer nicht kompatibel ist, aber die gleiche API wiederverwenden könnte: Tu es nicht. Erzwinge eine Änderung der Typen (und eine größere Versionserweiterung), um sicherzustellen, dass die Benutzer die neue Version nicht versehentlich falsch verwenden können.

Für die weniger greifbaren Teile deiner API - wie z. B. die MSRV oder die Lizenz - solltest du eine CI-Prüfung(Punkt 32) einrichten, die Änderungen erkennt und bei Bedarf Tools (z. B. cargo-deny; siehe Punkt 25) einsetzt.

Und schließlich solltest du dich nicht vor Version 1.0.0 fürchten, denn das ist eine Verpflichtung, dass deine API jetzt fix ist. Viele Kisten tappen in die Falle, für immer bei Version 0.x zu bleiben, aber das reduziert die ohnehin schon begrenzte Ausdrucksfähigkeit von semver von drei Kategorien (major/minor/patch) auf zwei (effective-major/effective-minor).

Semver für Kistenbenutzer

Für den Nutzer einer Kiste sind die theoretischen Erwartungen an eine neue Version einer Abhängigkeit wie folgt:

  • Eine neue Patch-Version einer Dependency Crate sollte einfach funktionieren.™

  • Eine neue Minor-Version einer Dependency Crate sollte einfach funktionieren,™ aber es kann sich lohnen, die neuen Teile der API zu erkunden, um zu sehen, ob es jetzt sauberere oder bessere Möglichkeiten gibt, die Crate zu nutzen. Wenn du jedoch die neuen Teile verwendest, kannst du die Abhängigkeit nicht mehr auf die alte Version zurücksetzen.

  • Die Chancen stehen gut, dass sich dein Code nicht mehr kompilieren lässt und du Teile deines Codes neu schreiben musst, um der neuen API zu entsprechen. Selbst wenn sich dein Code noch kompilieren lässt, solltest duüberprüfen, ob deine Verwendung der API auch nach einem Versionswechsel noch gültig ist, da sich die Einschränkungen und Voraussetzungen der Bibliothek geändert haben können.

In der Praxis können selbst die ersten beiden Arten von Änderungen aufgrund des Hyrum'schen Gesetzes zu unerwarteten Verhaltensänderungen führen, selbst bei Code, der sich noch gut kompilieren lässt.

Als Folge dieser Erwartungen werden deine Abhängigkeitsangaben üblicherweise eine Form wie "1.4.3" oder"0.7" annehmen, die nachfolgende kompatible Versionen einschließt. Vermeide es, eine vollständige Wildcard-Abhängigkeit wie "*" oder "0.*"anzugeben. Eine vollständige Wildcard-Abhängigkeit bedeutet, dass jede Version der Abhängigkeit mitjeder API von deinem Crate verwendet werden kann - was wahrscheinlich nicht das ist, was du wirklich willst.Die Vermeidung von Wildcards ist auch eine Voraussetzung für die Veröffentlichung von auf crates.io; Einreichungen mit "*" Wildcards werdenabgelehnt.

Auf lange Sicht ist es jedoch nicht sicher, größere Versionsänderungen in Abhängigkeiten einfach zu ignorieren. Sobald eine Bibliothek eine größere Versionsänderung erfahren hat, ist die Wahrscheinlichkeit groß, dass keine weiteren Fehlerbehebungen - und vor allem keine Sicherheitsupdates - für die vorherige Hauptversion vorgenommen werden. Eine Versionsspezifikation wie "1.4" gerät dann immer weiter ins Hintertreffen, wenn neue 2.x-Versionen erscheinen, und Sicherheitsprobleme bleibenunbehandelt.

Daher musst du entweder das Risiko in Kauf nehmen, auf einer alten Version sitzen zu bleiben, oder du musst die großen Versions-Upgrades für deine Abhängigkeiten verfolgen. Tools wie cargo update oder Dependabot(Punkt 31) können dich informieren, wenn Updates verfügbar sind.

Diskussion

Die semantische Versionierung hat ihren Preis: Jede Änderung an einer Kiste muss anhand ihrer Kriterien bewertet werden, um zu entscheiden, welche Art von Versionssprung angemessen ist. Die semantische Versionierung ist auch ein stumpfes Werkzeug: Im besten Fall spiegelt sie die Vermutung des Kistenbesitzers wider, in welche der drei Kategorien die aktuelle Version fällt. Nicht jeder macht es richtig, nicht alles ist klar, was genau "richtig" bedeutet, und selbst wenn du es richtig machst, besteht immer die Möglichkeit, dass du gegen Hyrums Gesetz verstößt.

Für alle, die nicht den Luxus haben, in einer Umgebung wieGoogles hochgradig getesteter gigantischer interner Monorepo zu arbeiten, ist Semver jedoch die einzige Möglichkeit, die es gibt. Daher ist es wichtig, seine Konzepte und Grenzen zu verstehen, um Abhängigkeiten zu verwalten.

Punkt 22: Sichtbarkeit minimieren

Rust ermöglicht es, Elemente des Codes entweder vor anderen Teilen der Codebasis zu verstecken oder für sie sichtbar zu machen. Dieser Artikel untersucht die dafür vorgesehenen Mechanismen und gibt Tipps, wo und wann sie eingesetzt werden sollten.

Sichtbarkeit Syntax

Die grundlegende Einheit der Sichtbarkeit in Rust ist das Modul. Standardmäßig sind die Elemente eines Moduls (Typen, Methoden, Konstanten)privat und nur für den Code desselben Moduls und seiner Untermodule zugänglich.

Code, der allgemein zugänglich sein soll, wird mit dem Schlüsselwort pub gekennzeichnet und damit für einen anderen Bereich öffentlich gemacht. Für die meisten syntaktischen Rust-Features bedeutet die Kennzeichnung pub nicht automatisch, dass auch derInhaltöffentlich ist- dieTypen und Funktionen in einem pub mod sind nicht öffentlich, ebenso wenig wie die Felder in einem pub struct. Es gibt jedoch einige Ausnahmen, bei denen die Anwendung der Sichtbarkeit auf den Inhalt sinnvoll ist:

  • Wenn du einen enum öffentlich machst, werden automatisch auch die Varianten des Typs öffentlich (zusammen mit allen Feldern, die in diesen Varianten enthalten sein können).

  • Wenn du einen trait public machst, werden automatisch auch die Methoden des Traits public.

Also eine Sammlung von Typen in einem Modul:

pub mod somemodule {
    // Making a `struct` public does not make its fields public.
    #[derive(Debug, Default)]
    pub struct AStruct {
        // By default fields are inaccessible.
        count: i32,
        // Fields have to be explicitly marked `pub` to be visible.
        pub name: String,
    }

    // Likewise, methods on the struct need individual `pub` markers.
    impl AStruct {
        // By default methods are inaccessible.
        fn canonical_name(&self) -> String {
            self.name.to_lowercase()
        }
        // Methods have to be explicitly marked `pub` to be visible.
        pub fn id(&self) -> String {
            format!("{}-{}", self.canonical_name(), self.count)
        }
    }

    // Making an `enum` public also makes all of its variants public.
    #[derive(Debug)]
    pub enum AnEnum {
        VariantOne,
        // Fields in variants are also made public.
        VariantTwo(u32),
        VariantThree { name: String, value: String },
    }

    // Making a `trait` public also makes all of its methods public.
    pub trait DoSomething {
        fn do_something(&self, arg: i32);
    }
}

erlaubt den Zugriff auf pub Dinge und die bereits erwähnten Ausnahmen:

use somemodule::*;

let mut s = AStruct::default();
s.name = "Miles".to_string();
println!("s = {:?}, name='{}', id={}", s, s.name, s.id());

let e = AnEnum::VariantTwo(42);
println!("e = {e:?}");

#[derive(Default)]
pub struct DoesSomething;
impl DoSomething for DoesSomething {
    fn do_something(&self, _arg: i32) {}
}

let d = DoesSomething::default();
d.do_something(42);

aber Dinge, die nichtpub sind, sind im Allgemeinen unzugänglich:

let mut s = AStruct::default();
s.name = "Miles".to_string();
println!("(inaccessible) s.count={}", s.count);
println!("(inaccessible) s.canonical_name()={}", s.canonical_name());
error[E0616]: field `count` of struct `somemodule::AStruct` is private
   --> src/main.rs:230:45
    |
230 |     println!("(inaccessible) s.count={}", s.count);
    |                                             ^^^^^ private field
error[E0624]: method `canonical_name` is private
   --> src/main.rs:231:56
    |
86  |         fn canonical_name(&self) -> String {
    |         ---------------------------------- private method defined here
...
231 |     println!("(inaccessible) s.canonical_name()={}", s.canonical_name());
    |                                         private method ^^^^^^^^^^^^^^
Some errors have detailed explanations: E0616, E0624.
For more information about an error, try `rustc --explain E0616`.

Die häufigste Sichtbarkeitsmarkierung ist das Schlüsselwort pub, das das Element für alle sichtbar macht, die das Modul sehen können, in dem es sich befindet. Dieses letzte Detail ist wichtig: Wenn ein somecrate::somemodule Modul für andere Codes nicht sichtbar ist, ist auch alles, was pub darin steht, nicht sichtbar.

Es gibt jedoch auch einige spezifischere Varianten von pub, die es ermöglichen, den Umfang der Sichtbarkeit einzuschränken. In absteigender Reihenfolge ihrer Nützlichkeit sind dies die folgenden:

pub(crate)

Überall innerhalb der eigenen Kiste zugänglich. Dies ist besonders nützlich für crate-weite interne Hilfsfunktionen, die nicht für externe Crate-Benutzer zugänglich sein sollten.

pub(super)

Zugriff auf das übergeordnete Modul des aktuellen Moduls und seine Untermodule. Dies ist gelegentlich nützlich, um die Sichtbarkeit in einer Kiste mit einer tiefen Modulstruktur selektiv zu erhöhen. Es ist auch die effektive Sichtbarkeitsstufe für Module: Ein einfaches mod mymodule ist für das übergeordnete Modul oder die Kiste und die entsprechendenUntermodule sichtbar.

pub(in <path>)

Zugänglich für Code in <path>, der eine Beschreibung eines Vorgängermoduls des aktuellen Moduls sein muss. Dies kann gelegentlich nützlich sein, um den Quellcode zu organisieren, denn so können Teilbereiche der Funktionalität in Untermodule verschoben werden, die in der öffentlichen API nicht unbedingt sichtbar sind. Die Rust-Standardbibliothek fasst zum Beispiel alleIterator-Adapter in einem internen std::iter::adapters Untermodul zusammen und hat die folgenden:

pub(self)

Äquivalent zu pub(in self), was gleichbedeutend damit ist, nicht pub zu sein. Die Anwendungen dafür sind sehr obskur, z. B. um die Anzahl der Sonderfälle zu reduzieren, die in Makros zur Codegenerierung benötigt werden.

Der Rust-Compiler warnt dich, wenn du ein Codeelement hast, das für das Modul privat ist, aber nicht innerhalb dieses Moduls (und seiner Untermodule) verwendet wird:

pub mod anothermodule {
    // Private function that is not used within its module.
    fn inaccessible_fn(x: i32) -> i32 {
        x + 3
    }
}

Obwohl die Warnung darauf hinweist, dass der Code in seinem eigenen Modul "nie verwendet" wird, bedeutet diese Warnung in der Praxis oft, dass der Code nicht von außerhalb des Moduls verwendet werden kann, weil die Sichtbarkeitsbeschränkungen dies nicht zulassen:

warning: function `inaccessible_fn` is never used
  --> src/main.rs:56:8
   |
56 |     fn inaccessible_fn(x: i32) -> i32 {
   |        ^^^^^^^^^^^^^^^
   |
   = note: `#[warn(dead_code)]` on by default

Semantik der Sichtbarkeit

Unabhängig von der Frage, wie man die Sichtbarkeit erhöht, stellt sich die Frage, wann man dies tun sollte. Die allgemein akzeptierte Antwort darauf lautet: so wenig wie möglich, zumindest für jeden Code, der möglicherweise in der Zukunft verwendet und wiederverwendet werden kann.

Der erste Grund für diesen Ratschlag ist, dass Änderungen an der Sichtbarkeit nur schwer rückgängig gemacht werden können. Sobald ein Kistenelement öffentlich ist, kann es nicht wieder privat gemacht werden, ohne dass der Code, der die Kiste verwendet, kaputt geht, was einen großen Versionssprung erfordert(Punkt 21). Das Gegenteil ist der Fall: Wenn ein privates Element öffentlich gemacht wird, ist in der Regel nur ein kleiner Versionssprung nötig und die Benutzer von Kisten bleiben davon unberührt. Lies dir die API-Kompatibilitätsrichtlinien von Rust durch und du wirst feststellen, dass viele davon nur relevant sind, wenn pub Elemente im Spiel sind.

Ein noch wichtigerer - wenn auch subtilerer - Grund, die Privatsphäre zu bevorzugen, ist, dass sie deine Optionen offen hält. Je mehr Dinge offengelegt werden, desto mehr Dinge müssen für die Zukunft fixiert werden (es sei denn, es gibt eine inkompatible Änderung). Wenn du die internen Implementierungsdetails einer Datenstruktur offenlegst, wird eine mögliche zukünftige Änderung zur Verwendung eines effizienteren Algorithmus zu einem Bruch. Wenn du interne Hilfsfunktionen offenlegst, ist es unvermeidlich, dass externer Code von den genauen Details dieser Funktionen abhängt.

Das gilt natürlich nur für Bibliothekscode, der potenziell viele Nutzer und eine lange Lebensdauer hat. Aber nichts ist so dauerhaft wie eine vorübergehende Lösung, und deshalb ist es eine gute Angewohnheit, sich das anzugewöhnen.

Der Rat, die Sichtbarkeit einzuschränken, gilt keineswegs nur für oder für Rust:

  • Die Rust-API-Richtlinien enthalten diesen Hinweis: Strukturen sollten private Felder haben.

  • Effective Java, 3. Auflage, (Addison-Wesley Professional) hat die folgenden Punkte:

    • Punkt 15: Minimiere die Zugänglichkeit von Klassen und Mitgliedern.

    • Punkt 16: Verwende in öffentlichen Klassen Accessor-Methoden, nicht öffentliche Felder.

  • Effective C++ von Scott Meyers (Addison-Wesley Professional) hat in seiner zweiten Auflage Folgendes zu bieten:

    • Punkt 18: Strebe nach Klassenschnittstellen, die vollständig und minimal sind (meine Kursivschrift).

    • Punkt 20: Vermeide Datenelemente in der öffentlichen Schnittstelle.

Punkt 23: Vermeide Wildcard-Importe

Die use Anweisung von Rust zieht ein benanntes Element aus einer anderen Kiste oder einem anderen Modul ein und macht diesen Namen ohne Einschränkung für die Verwendung im Code des lokalen Moduls verfügbar. Ein Wildcard-Import (oder glob-Import) der Form use somecrate::module::* besagt, dass jedes öffentliche Symbol aus diesem Modul dem lokalen Namensraum hinzugefügt werden soll.

Wie in Punkt 21 beschrieben, kann eine externe Kiste im Rahmen eines kleineren Versions-Upgrades neue Elemente zu ihrer API hinzufügen; dies wird als abwärtskompatible Änderung betrachtet.

Die Kombination dieser beiden Beobachtungen lässt die Sorge aufkommen, dass eine nicht bahnbrechende Änderung an einer Abhängigkeit deinen Code kaputt machen könnte: Was passiert, wenn die Abhängigkeit ein neues Symbol hinzufügt, das mit einem Namen kollidiert, den du bereits verwendest?

Auf der einfachsten Ebene stellt dies kein Problem dar: Die Namen in einem Wildcard-Import werden als nachrangig behandelt, sodass alle übereinstimmenden Namen in deinem Code Vorrang haben:

use bytes::*;

// Local `Bytes` type does not clash with `bytes::Bytes`.
struct Bytes(Vec<u8>);

Leider gibt es immer noch Fälle, in denen es zu Überschneidungen kommen kann. Nehmen wir zum Beispiel den Fall, dass die Abhängigkeit einen neuen Trait hinzufügt und ihn für einen Typ implementiert:

trait BytesLeft {
    // Name clashes with the `remaining` method on the wildcard-imported
    // `bytes::Buf` trait.
    fn remaining(&self) -> usize;
}

impl BytesLeft for &[u8] {
    // Implementation clashes with `impl bytes::Buf for &[u8]`.
    fn remaining(&self) -> usize {
        self.len()
    }
}

Wenn Methodennamen aus dem neuen Trait cmit bestehenden Methodennamen, die für den Typ gelten, übereinstimmen, kann der Compiler nicht mehr eindeutig herausfinden, welche Methode gemeint ist:

wie der Kompilierfehler anzeigt:

error[E0034]: multiple applicable items in scope
  --> src/main.rs:40:18
   |
40 |     assert_eq!(v.remaining(), 2);
   |                  ^^^^^^^^^ multiple `remaining` found
   |
note: candidate #1 is defined in an impl of the trait `BytesLeft` for the
      type `&[u8]`
  --> src/main.rs:18:5
   |
18 |     fn remaining(&self) -> usize {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = note: candidate #2 is defined in an impl of the trait `bytes::Buf` for the
           type `&[u8]`
help: disambiguate the method for candidate #1
   |
40 |     assert_eq!(BytesLeft::remaining(&v), 2);
   |                ~~~~~~~~~~~~~~~~~~~~~~~~
help: disambiguate the method for candidate #2
   |
40 |     assert_eq!(bytes::Buf::remaining(&v), 2);
   |                ~~~~~~~~~~~~~~~~~~~~~~~~~

Daher solltest du Wildcard-Importe aus Kisten, die du nicht kontrollierst, vermeiden.

Wenn du die Quelle des Wildcard-Imports kontrollierst, verschwinden die oben genannten Bedenken. Es ist zum Beispiel üblich, dass ein test Modul use super::*; verwendet. Es ist auch möglich, dass Kisten, die Module hauptsächlich zur Aufteilung des Codes verwenden, einen Wildcard-Import aus einem internen Modul haben:

mod thing;
pub use thing::*;

Es gibt jedoch noch eine weitere häufige Ausnahme, bei der Wildcard-Importe sinnvoll sind. Einige Kisten haben die Konvention, dass gemeinsame Elemente für die Kiste aus einem Vorläufermodul reexportiert werden, das ausdrücklich für den Wildcard-Import vorgesehen ist:

use thing::prelude::*;

Obwohl in der Theorie die gleichen Bedenken gelten, wird ein solches Auftaktmodul in der Praxis wahrscheinlich sorgfältig kuratiert, und der höhere Komfort kann das geringe Risiko zukünftiger Probleme aufwiegen.

Wenn du die Ratschläge in diesem Punkt nicht befolgst, solltest du in Erwägung ziehen, Abhängigkeiten, die du mit einem Platzhalter importierst, an eine bestimmte Version zu binden (siehe Punkt 21), damit kleinere Versions-Upgrades der Abhängigkeit nicht automatisch zugelassen werden.

Punkt 24: Abhängigkeiten neu exportieren, deren Typenin deiner API erscheinen

Der Titel dieses Artikels ist ein wenig verwirrend, aber wenn du ein Beispiel durcharbeitest, wird es klarer.3

Unter Punkt 25 wird beschrieben, wie cargo es unterstützt, dass verschiedene Versionen derselben Bibliothekskiste auf transparente Weise in eine einzige Binärdatei gelinkt werden. Betrachten wir eine Binärdatei, die die rand crate verwendet - genauer gesagt, eine, die eine 0.8 Version der crate verwendet:

# Cargo.toml file for a top-level binary crate.
[dependencies]
# The binary depends on the `rand` crate from crates.io
rand = "=0.8.5"

# It also depends on some other crate (`dep-lib`).
dep-lib = "0.1.0"
// Source code:
let mut rng = rand::thread_rng(); // rand 0.8
let max: usize = rng.gen_range(5..10);
let choice = dep_lib::pick_number(max);

Die letzte Codezeile verwendet auch eine fiktive dep-lib Kiste als weitere Abhängigkeit. Bei dieser Kiste kann es sich um eine andere Kiste von crates.io handeln, oder um eine lokale Kiste, die über den path Mechanismus von Cargo gefunden wird.

Diese dep-lib Kiste verwendet intern eine 0.7 Version der rand Kiste:

# Cargo.toml file for the `dep-lib` library crate.
[dependencies]
# The library depends on the `rand` crate from crates.io
rand = "=0.7.3"
// Source code:
//! The `dep-lib` crate provides number picking functionality.
use rand::Rng;

/// Pick a number between 0 and n (exclusive).
pub fn pick_number(n: usize) -> usize {
    rand::thread_rng().gen_range(0, n)
}

Einem aufmerksamen Leser fällt vielleicht ein Unterschied zwischen den beiden Codebeispielen auf:

  • In der Version 0.7.x von rand (wie sie von der dep-lib library crate verwendet wird), benötigt dierand::gen_range() Methode zwei Parameter, low und high.

  • In der Version 0.8.x von rand (wie sie von der Binärkiste verwendet wird), nimmt dierand::gen_range() Methode einen einzigen Parameter range.

Dies ist keine rückwärtskompatible Änderung, und deshalb hat rand seine ganz linke Versionskomponente entsprechend erhöht, wie es die semantische Versionierung(Punkt 21) verlangt. Trotzdem funktioniert die Binärdatei, die die beiden inkompatiblen Versionen kombiniert, einwandfrei -cargo bringt alles in Ordnung.

Die Dinge werden jedoch sehr viel unangenehmer, wenn die API der dep-lib Bibliothek einen Typ aus ihrer Abhängigkeit offenlegt, was diese Abhängigkeit zu einer öffentlichen Abhängigkeit macht.

Nehmen wir zum Beispiel an, dass der Einstiegspunkt dep-lib ein Element Rng betrifft - und zwar ein Element der Version 0.7 Rng:

/// Pick a number between 0 and n (exclusive) using
/// the provided `Rng` instance.
pub fn pick_number_with<R: Rng>(rng: &mut R, n: usize) -> usize {
    rng.gen_range(0, n) // Method from the 0.7.x version of Rng
}

Übrigens: Überlege dir gut, bevor du die Typen einer anderen Kiste in deiner API verwendest: Deine Kiste ist dadurch eng mit der der Abhängigkeit verbunden. Wenn du zum Beispiel die Version der Abhängigkeit(Artikel 21) erhöhst, muss auch deine Kiste eine neue Version erhalten.

In diesem Fall ist rand eine halbwegs standardisierte Kiste, die weit verbreitet ist und nur wenige eigene Abhängigkeiten mit sich bringt(Artikel 25), so dass die Aufnahme ihrer Typen in die Kisten-API unterm Strich wahrscheinlich in Ordnung ist.

Um zum Beispiel zurückzukehren: Der Versuch, diesen Einstiegspunkt von der Top-Level-Binärdatei aus zu verwenden, schlägt fehl:

Ungewöhnlich für Rust, ist die Compiler-Fehlermeldung nicht sehr hilfreich:

error[E0277]: the trait bound `ThreadRng: rand_core::RngCore` is not satisfied
  --> src/main.rs:22:44
   |
22 |     let choice = dep_lib::pick_number_with(&mut rng, max);
   |                  ------------------------- ^^^^^^^^ the trait
   |                  |                `rand_core::RngCore` is not
   |                  |                 implemented for `ThreadRng`
   |                  |
   |                  required by a bound introduced by this call
   |
   = help: the following other types implement trait `rand_core::RngCore`:
             &'a mut R

Die Untersuchung der beteiligten Typen führt zu Verwirrung, weil die relevanten Traits zwar implementiert zu sein scheinen, aber der Aufrufer tatsächlich eine (fiktive) RngCore_v0_8_5 implementiert und die Bibliothek eine Implementierung vonRngCore_v0_7_3 erwartet.

Wenn du die Fehlermeldung endlich entschlüsselt und erkannt hast, dass der Versionskonflikt die Ursache ist, wie kannst du ihn beheben?4 Die wichtigste Erkenntnis ist, dass die Binärdatei zwar nicht direkt zwei verschiedene Versionen derselben Kiste verwenden kann, wohl aber indirekt (wie in dem zuvor gezeigten Beispiel).

Aus der Sicht des Autors der Binärkiste könnte das Problem umgangen werden, indem eine Wrapper-Kiste dazwischengeschaltet wird, die die nackte Verwendung der rand v0.7 Typen verbirgt. Eine Wrapper-Kiste unterscheidet sich von der Binärkiste und darf daher von rand v0.7 unabhängig von der Abhängigkeit der Binärkiste von rand v0.8 abhängen.

Das ist umständlich, und dem Autor der Bibliothekskiste steht eine viel bessere Lösung zur Verfügung. Er kann seinen Nutzern das Leben leichter machen, indem er eine derfolgenden Möglichkeiten explizit re-exportiert:

  • Die an der API beteiligten Typen

  • Die gesamte Dependenzkiste

Für dieses Beispiel eignet sich der letztere Ansatz am besten: Er macht nicht nur die Typen der Version 0.7 Rng und RngCore verfügbar, sondern auch die Methoden (wie thread_rng()), die Instanzen des Typs konstruieren:

// Re-export the version of `rand` used in this crate's API.
pub use rand;

Der aufrufende Code hat nun eine andere Möglichkeit, direkt auf die Version 0.7 von rand zu verweisen, nämlich dep_lib::rand:

let mut prev_rng = dep_lib::rand::thread_rng(); // v0.7 Rng instance
let choice = dep_lib::pick_number_with(&mut prev_rng, max);

Mit diesem Beispiel im Hinterkopf sollte der Ratschlag im Titel des Artikels nun etwas weniger obskur sein: Exportiere die Abhängigkeiten, deren Typen in deiner API vorkommen, erneut.

Punkt 25: Verwalte deinen Abhängigkeitsgraph

Wie die meisten modernen Programmiersprachen macht es auch Rust einfach, externe Bibliotheken in Form von Crates einzubinden. Die meisten nicht-trivialen Rust-Programme verwenden externe Crates, und diese Crates können selbst zusätzliche Abhängigkeiten haben, die einen Abhängigkeitsgraphen für das Programm als Ganzes bilden.

Standardmäßig lädt Cargo alle Kisten, die im Abschnitt [dependencies] deiner Cargo.toml-Datei genannt sind, von crates.io und sucht nach Versionen dieser Kisten, die den in derCargo.toml konfigurierten Anforderungen entsprechen.

Hinter dieser einfachen Aussage verbergen sich ein paar Feinheiten. Zunächst einmal ist zu beachten, dass die Kistennamen voncrates.io einen einzigen flachen Namensraum bilden - und dieser globale Namensraum überschneidet sich auch mit den Namen der Features in einer Kiste (siehe Punkt 26).5

Wenn du vorhast, eine Kiste auf crates.iozu veröffentlichen, solltest du wissen, dass die Namen in der Regel nach dem Prinzip "Wer zuerst kommt, mahlt zuerst" vergeben werden; es kann also sein, dass dein bevorzugter Name für eine öffentliche Kiste bereits vergeben ist. Das so genannte "name-squatting", also das Reservieren eines Kistennamens durch die Vorregistrierung einer leeren Kiste, ist jedoch verpönt, es sei denn, du hast wirklich vor, in naher Zukunft Code zu veröffentlichen.

Außerdem gibt es einen kleinen Unterschied zwischen dem, was als Kistenname im Crates-Namensraum erlaubt ist, und dem, was als Bezeichner im Code erlaubt ist: Eine Kiste kann some-crate heißen, aber im Code erscheint sie als some_crate(mit einem Unterstrich). Anders ausgedrückt: Wenn du some_crate im Code siehst, kann der entsprechende Kistenname entweder some-crate oder some_crate sein.

Die zweite Besonderheit, die du verstehen musst, ist, dass Cargo es erlaubt, dass mehrere semver-inkompatible Versionen der gleichen Kiste im Build vorhanden sind. Das mag zunächst überraschen, da jede Cargo.toml-Datei nur eine einzige Version einer bestimmten Abhängigkeit enthalten kann, aber die Situation tritt häufig bei indirekten Abhängigkeiten auf: Deine Crate hängt vonsome-crate Version 3.x ab, aber auch von older-crate, die wiederum von some-crate Version 1.x abhängt.

Das kann zu Verwirrung führen, wenn die Abhängigkeit auf irgendeine Weise offengelegt und nicht nur intern verwendet wird(Punkt 24) - der Compiler wird die beiden Versionen als unterschiedliche Kisten behandeln, aber seine Fehlermeldungen werden das nicht unbedingt deutlich machen.

Das Zulassen mehrerer Versionen eines Crates kann auch schiefgehen, wenn der Crate C/C++-Code enthält, auf den über die FFI-Mechanismen von Rust zugegriffen wird(Punkt 34). Die Rust-Toolchain kann intern verschiedene Versionen von Rust-Code unterscheiden, aber jeder enthaltene C/C++-Code unterliegt der Ein-Definitions-Regel: Es kann nur eine einzige Version einer Funktion, Konstante oder globalen Variable geben.

Es gibt Einschränkungen bei der Unterstützung mehrerer Versionen durch Cargo. Cargo erlaubt nicht mehrere Versionen derselben Kiste innerhalb eines halbwegs kompatiblen Bereichs(Punkt 21):

  • some-crate 1.2 und some-crate 3.1 können nebeneinander bestehen

  • some-crate 1.2 und some-crate 1.3 können nicht

Cargo erweitert auch die semantischen Versionsregeln für Kisten vor 1.0, so dass die erste Subversion, die nicht Null ist, wie eine Hauptversion zählt, so dass eine ähnliche Einschränkung gilt:

  • other-crate 0.1.2 und other-crate 0.2.0 können nebeneinander existieren

  • other-crate 0.1.2 und other-crate 0.1.4 können nicht

Der Versionsauswahlalgorithmus von Cargo findet heraus, welche Versionen aufgenommen werden sollen. Jede Cargo.toml-Abhängigkeitszeile gibt gemäß den semantischen Versionsregeln einen akzeptablen Versionsbereich an, den Cargo berücksichtigt, wenn dieselbe Kiste an mehreren Stellen im Abhängigkeitsgraphen erscheint. Wenn sich die akzeptablen Bereiche überschneiden und semver-kompatibel sind, wählt Cargo (standardmäßig) die neueste Version der Kiste innerhalb der Überschneidung. Wenn es keine semver-kompatible Überschneidung gibt, erstellt Cargo mehrere Kopien der Abhängigkeit mit unterschiedlichen Versionen.

Sobald Cargo akzeptable Versionen für alle Abhängigkeiten ausgewählt hat, wird die Auswahl in der Datei Cargo.lock festgehalten.Nachfolgende Builds verwenden dann die in Cargo.lock kodierte Auswahl, damit der Build stabil ist und keine neuen Downloads erforderlich sind.

Du hast also die Wahl: Solltest du deine Cargo.lock-Dateien in die Versionskontrolle übertragen oder nicht? Der Rat der Cargo-Entwicklerlautet wie folgt:

  • Dinge, die ein Endprodukt erzeugen, also Anwendungen und Binärdateien, sollten Cargo.lock übertragen, um einen deterministischen Build zu gewährleisten.

  • Bibliothekskisten sollten keine Cargo.lock-Datei übermitteln, da sie für nachgelagerte Nutzer der Bibliothek irrelevant ist - sie haben ihre eigene Cargo . lock-Datei.

Selbst für eine Bibliothekskiste kann es hilfreich sein, eine eingecheckte Cargo.lock-Datei zu haben, um sicherzustellen, dass regelmäßige Builds und CI(Punkt 32) kein bewegliches Ziel haben. Obwohl das Versprechen der semantischen Versionierung(Punkt 21) in der Theorie Fehler verhindern sollte, passieren in der Praxis immer wieder Fehler, und es ist frustrierend, wenn Builds fehlschlagen, weil jemand irgendwo eine Abhängigkeit einer Abhängigkeit geändert hat.

Wenn du jedoch eine Versionskontrolle für Cargo.lock durchführst, solltest du einen Prozess für Upgrades einrichten (wie z.B. Dependabot von GitHub). Wenn du das nicht tust, bleiben deine Abhängigkeiten an ältere, veraltete und potenziell unsichere Versionen angeheftet.

Das Anheften von Versionen mit einer eingecheckten Cargo.lock-Datei vermeidet zwar nicht die Probleme bei der Handhabung von Abhängigkeits-Upgrades, aber es bedeutet, dass du sie zu einem Zeitpunkt deiner Wahl behandeln kannst und nicht sofort, wenn sich die Upstream-Kiste ändert. Ein Teil der Probleme mit Abhängigkeits-Upgrades löst sich auch von selbst: Für eine Kiste, die mit einem Problem veröffentlicht wird, wird oft innerhalb kurzer Zeit eine zweite, korrigierte Version veröffentlicht, und bei einem gebündelten Upgrade-Prozess kann es sein, dass nur die zweite Version veröffentlicht wird.

Die dritte Raffinesse des Auflösungsprozesses von Cargo ist die Vereinheitlichung von Merkmalen: Die Merkmale, die für eine abhängige Kiste aktiviert werden, sind die Vereinigung der Merkmale, die an verschiedenen Stellen des Abhängigkeitsgraphen ausgewählt wurden; siehe Punkt 26 für weitere Details.

Version Spezifikation

Die Versionsspezifikationsklausel für eine Abhängigkeit definiert einen Bereich zulässiger Versionen, entsprechend denRegeln, die im Cargo-Buch erklärt werden:

Vermeide eine zu spezifische Versionsabhängigkeit

Das Anheften an eine bestimmte Version ("=1.2.3") ist in der Regel keine gute Idee: Du siehst keine neueren Versionen (die möglicherweise Sicherheitsverbesserungen enthalten) und schränkst den potenziellen Überschneidungsbereich mit anderen Kisten im Graphen, die sich auf dieselbe Abhängigkeit stützen, drastisch ein (denk daran, dass Cargo nur eine einzige Version einer Kiste innerhalb eines halbwegs kompatiblen Bereichs zulässt). Wenn du sicherstellen willst, dass deine Builds einen konsistenten Satz von Abhängigkeiten verwenden, ist die Datei Cargo.lock das richtige Werkzeug dafür.

Vermeiden Sie eine zu allgemeine Versionsabhängigkeit

Es ist zwar möglich, eine Versionsabhängigkeit ("*") anzugeben, die es erlaubt, eine beliebige Version der Abhängigkeit zu verwenden, aber das ist eine schlechte Idee. Wenn die Abhängigkeit eine neue Hauptversion der Crate herausgibt, die jeden Aspekt ihrer API komplett verändert, ist es unwahrscheinlich, dass dein Code noch funktioniert, nachdem cargo update die neueVersion eingespielt hat.

Die gebräuchlichste Goldilocks-Spezifikation - nicht zu präzise, nicht zu vage - ist es, semver-kompatible Versionen ("1") einer Kiste zuzulassen, möglicherweise mit einer bestimmten Mindestversion, die eine von dir benötigte Funktion oder Korrektur enthält ("1.4.23"). Beide Versionsspezifikationen nutzen das Standardverhalten von Cargo, nämlich Versionen zuzulassen, die mit der angegebenen Version semver-kompatibel sind. Du kannst dies explizit machen, indem du ein Caret hinzufügst:

  • Eine Version von "1" ist gleichbedeutend mit "^1", die alle 1.x Versionen zulässt (und somit auch gleichbedeutend mit "1.*" ist).

  • Eine Version von "1.4.23" ist gleichbedeutend mit "^1.4.23", die alle 1.x Versionen erlaubt, die größer als 1.4.23 sind.

Probleme mit Werkzeugen lösen

In Artikel 31 wird empfohlen, dass du die Vorteile der verschiedenen Tools nutzt, die im Rust-Ökosystem verfügbar sind. In diesem Abschnitt werden einige Probleme mit Abhängigkeitsgraphen beschrieben, bei denen Werkzeuge helfen können.

Der Compiler wird dir ziemlich schnell sagen, wenn du eine Abhängigkeit in deinem Code verwendest, diese aber nicht inCargo.toml aufnimmst. Aber was ist mit dem umgekehrten Fall? Wenn es eine Abhängigkeit in Cargo.toml gibt, die du nicht in deinem Code verwendest - oder wahrscheinlicher, die du nicht mehr in deinem Code verwendest -, dann macht Cargo mit seiner Arbeit weiter. Das cargo-udeps Tool wurde entwickelt, um genau dieses Problem zu lösen: Es warnt dich, wenn deine Cargo.toml eine unbenutzte Abhängigkeit ("udep") enthält.

Ein vielseitigeres Tool ist cargo-denydas deinen Abhängigkeitsgraphen analysiert, um eine Vielzahl von potenziellen Problemen in allen transitiven Abhängigkeiten zu erkennen:

  • Abhängigkeiten, die in der enthaltenen Version bekannte Sicherheitsprobleme haben

  • Abhängigkeiten, die von einer inakzeptablen Lizenz abgedeckt werden

  • Abhängigkeiten, die einfach inakzeptabel sind

  • Abhängigkeiten, die in mehreren verschiedenen Versionen im Abhängigkeitsbaum enthalten sind

Jede dieser Funktionen kann konfiguriert werden und es können Ausnahmen festgelegt werden. Der Ausnahmemechanismus wird in der Regel für größere Projekte benötigt, insbesondere die Warnung vor mehreren Versionen: Je größer der Abhängigkeitsgraph wird, desto größer ist die Wahrscheinlichkeit, dass man vorübergehend von verschiedenen Versionen der gleichen Kiste abhängig ist. Es lohnt sich zu versuchen, diese Duplikate so weit wie möglich zu reduzieren - nicht zuletzt aus Gründen der Größe der Binärdatei und der Kompilierungszeit -, aber manchmal gibt es keine mögliche Kombination von Abhängigkeitsversionen, die ein Duplikat vermeiden kann.

Diese Tools können einmalig eingesetzt werden, aber es ist besser, sie regelmäßig und zuverlässig auszuführen, indem du sie in dein CI-System einbaust(Punkt 32). Dies hilft dabei, neu eingeführte Probleme zu erkennen - auch solche, die außerhalb deines Codes in einer Upstream-Abhängigkeit entstanden sind (z. B. eine neu gemeldete Sicherheitslücke).

Wenn eines dieser Tools ein Problem meldet, kann es schwierig sein, genau herauszufinden, wo im Abhängigkeitsdiagramm das Problem auftritt. Der cargo tree der in cargo enthalten ist, hilft dir dabei, denn er zeigt den Abhängigkeitsgraph als Baumstruktur an:

dep-graph v0.1.0
├── dep-lib v0.1.0
│   └── rand v0.7.3
│       ├── getrandom v0.1.16
│       │   ├── cfg-if v1.0.0
│       │   └── libc v0.2.94
│       ├── libc v0.2.94
│       ├── rand_chacha v0.2.2
│       │   ├── ppv-lite86 v0.2.10
│       │   └── rand_core v0.5.1
│       │       └── getrandom v0.1.16 (*)
│       └── rand_core v0.5.1 (*)
└── rand v0.8.3
    ├── libc v0.2.94
    ├── rand_chacha v0.3.0
    │   ├── ppv-lite86 v0.2.10
    │   └── rand_core v0.6.2
    │       └── getrandom v0.2.3
    │           ├── cfg-if v1.0.0
    │           └── libc v0.2.94
    └── rand_core v0.6.2 (*)

cargo tree enthält eine Reihe von Optionen , die helfen können, bestimmte Probleme zu lösen, wie zum Beispiel diese:

--invert

Zeigt an, was von einem bestimmten Paket abhängt und hilft dir, dich auf eine bestimmte problematische Abhängigkeit zu konzentrieren

--edges features

Zeigt an, welche Crate-Features durch einen Abhängigkeitslink aktiviert werden, was dir hilft, herauszufinden, was mit der Vereinheitlichung von Features los ist(Punkt 26)

--duplicates

Zeigt Kisten an, von denen mehrere Versionen im Abhängigkeitsdiagramm vorhanden sind

Worauf du dich verlassen kannst

In den vorangegangenen Abschnitten wurde der eher mechanische Aspekt der Arbeit mit Abhängigkeiten behandelt, aber es gibt eine philosophischere (und daher schwieriger zu beantwortende) Frage: Wann solltest du eine Abhängigkeit eingehen?

Meistens gibt es nicht viel zu entscheiden: Wenn du die Funktionalität einer Kiste brauchst, brauchst du diese Funktion, und die einzige Alternative wäre, sie selbst zu schreiben.6

Aber jede neue Abhängigkeit hat ihren Preis, zum Teil in Form von längeren Builds und größeren Binärdateien, aber vor allem in Form des Entwickleraufwands, um Probleme mit Abhängigkeiten zu beheben, wenn sie auftreten.

Je größer dein Abhängigkeitsgraph ist, desto wahrscheinlicher ist es, dass du solchen Problemen ausgesetzt bist. Das Rust-Crate-Ökosystem ist genauso anfällig für ungewollte Abhängigkeitsprobleme wie andere Paket-Ökosysteme. Die Geschichte hat gezeigt, dass ein Entwickler, der ein Paket entfernt, oder ein Team , das die Lizenzierung für sein Paket ändert, weitreichende Auswirkungen haben kann.

Noch besorgniserregender sind Angriffe auf die Lieferkette, bei denen ein böswilliger Akteur absichtlich versucht, gemeinsam genutzte Abhängigkeiten zu unterwandern, sei es durch Tippfehler-Squatting, die Entführung eines Maintainer-Kontos oder andere ausgefeiltere Angriffe.

Diese Art des Angriffs betrifft nicht nur deinen kompilierten Code. Sei dir bewusst, dass eine Abhängigkeit beliebigen Code zurBuild-Zeit ausführen kann, und zwar über build.rs Skripte oder prozedurale Makros(Punkt 28). Das bedeutet, dass eine kompromittierte Abhängigkeit am Ende einen Cryptocurrency Miner als Teil deines CI-Systems ausführen könnte!

Bei Abhängigkeiten, die eher "kosmetischer Natur" sind, lohnt es sich also manchmal zu überlegen, ob es die Kosten wert ist, die Abhängigkeit hinzuzufügen.

Die Antwort lautet jedoch in der Regel "ja". Am Ende ist der Zeitaufwand für die Bewältigung von Abhängigkeitsproblemen viel geringer als die Zeit, die nötig wäre, um eine entsprechende Funktion von Grund auf neu zu schreiben.

Dinge zum Erinnern

  • Die Kistennamen auf crates.io bilden einen einzigen flachen Namensraum (der mit den Feature-Namen geteilt wird).

  • Kistennamen können einen Bindestrich enthalten, der aber im Code als Unterstrich erscheint.

  • Cargo unterstützt mehrere Versionen der gleichen Kiste im Abhängigkeitsgraphen, aber nur, wenn es sich um unterschiedliche, semver-inkompatible Versionen handelt. Das kann bei Kisten, die FFI-Code enthalten, schiefgehen.

  • Ziehe es vor, semver-kompatible Versionen von Abhängigkeiten zuzulassen ("1", oder "1.4.23", um eine Mindestversion einzuschließen).

  • Verwende Cargo.lock-Dateien, um sicherzustellen, dass deine Builds wiederholbar sind, aber erinnere dich daran, dass die Cargo.lock-Datei nicht mit einer veröffentlichten Kiste ausgeliefert wird.

  • Verwende Werkzeuge (cargo tree, cargo deny, cargo udep, ...), um Abhängigkeitsprobleme zu finden und zu beheben.

  • Verstehe, dass das Einbeziehen von Abhängigkeiten dir das Schreiben von Code erspart, aber nicht umsonst ist.

Punkt 26: Vorsicht vor feature creep

Mit dem Feature-Mechanismus von Cargo, der auf einem untergeordneten Mechanismus für die bedingte Kompilierung aufbaut, kann dieselbe Codebasis eine Vielzahl verschiedener Konfigurationen unterstützen. Der Feature-Mechanismus hat jedoch einige Feinheiten, die es zu beachten gilt und die in diesem Artikel untersucht werden.

Bedingte Kompilierung

Rust bietet Unterstützung für bedingte Kompilierung, die durch cfg (und cfg_attr) Attribute gesteuert wird.Diese Attribute legen fest, ob die Funktion, die Zeile, der Block usw., an die sie angehängt sind, in den kompilierten Quellcode aufgenommen wird oder nicht (im Gegensatz zum zeilenbasierten Präprozessor von C/C++). Die bedingte Einbeziehung wird durch Konfigurationsoptionen gesteuert, die entweder einfache Namen (z. B. test) oder Paare von Namen und Werten (z. B. panic = "abort") sind.

Beachte, dass die Name/Wert-Varianten der Konfigurationsoptionen mehrwertig sind - es ist möglich, mehr als einen Wert für denselben Namen zu setzen:

// Build with `RUSTFLAGS` set to:
//   '--cfg myname="a" --cfg myname="b"'
#[cfg(myname = "a")]
println!("cfg(myname = 'a') is set");
#[cfg(myname = "b")]
println!("cfg(myname = 'b') is set");
cfg(myname = 'a') is set
cfg(myname = 'b') is set

Abgesehen von den feature Werten, die in diesem Abschnitt beschrieben werden, sind die am häufigsten verwendeten Konfigurationswerte diejenigen, die die Toolchain automatisch mit Werten auffüllt, die die Zielumgebung für den Build beschreiben. Dazu gehören das Betriebssystem (target_os), die CPU-Architektur (target_arch), Zeigerbreite(target_pointer_width), und Endianness (target_endianDies ermöglicht die Portabilität des Codes, da Funktionen, die für ein bestimmtes Ziel spezifisch sind, nur dann kompiliert werden, wenn sie für dieses Ziel erstellt werden.

Die Option standard target_has_atomic Option liefert auch ein Beispiel für die Mehrwertigkeit von Konfigurationswerten: Sowohl [cfg(target_has_atomic = "32")] als auch[cfg(target_has_atomic = "64")] werden für Ziele gesetzt, die sowohl 32-Bit- als auch 64-Bit-Atomoperationen unterstützen. (Weitere Informationen zu Atomics findest du in Kapitel 2 von Mara Bos' Rust Atomics and Locks [O'Reilly]).

Eigenschaften

Der Cargo-Paketmanager baut auf diesem cfg Name/Wert-Mechanismus auf, um das Konzept der Features bereitzustellen: benannte, selektive Aspekte der Funktionalität einer Kiste, die bei der Erstellung der Kiste aktiviert werden können. Cargo stellt sicher, dass die Option feature für jede Kiste, die es kompiliert, mit den konfigurierten Werten gefüllt wird, die kistenspezifisch sind.

Dies ist eine Cargo-spezifische Funktion: Für den Rust-Compiler ist feature nur eine weitere Konfigurationsoption.

Zum Zeitpunkt der Erstellung dieses Artikels ist die zuverlässigste Methode, um festzustellen, welche Funktionen für eine Kiste verfügbar sind, die Manifestdatei Cargo.toml der Kiste zu untersuchen. Der folgende Ausschnitt einer Manifestdatei enthält zum Beispiel sechs Funktionen:

[features]
default = ["featureA"]
featureA = []
featureB = []
# Enabling `featureAB` also enables `featureA` and `featureB`.
featureAB = ["featureA", "featureB"]
schema = []

[dependencies]
rand = { version = "^0.8", optional = true }
hex = "^0.4"

Wenn man bedenkt, dass es nur fünf Einträge in der Strophe [features] gibt, muss man auf ein paar Feinheiten achten.

Die erste ist, dass die default Zeile in der [features] Stanza ein spezieller Feature-Name ist, der verwendet wird, um cargo mitzuteilen, welche der Features standardmäßig aktiviert sein sollen. Diese Funktionen können immer noch deaktiviert werden, indem das --no-default-features Flag an den Build-Befehl übergeben wird, und ein Verbraucher der Crate kann dies in seinerCargo.toml-Datei wie folgt kodieren:

[dependencies]
somecrate = { version = "^0.3", default-features = false }

default zählt aber immer noch als Feature-Name, der im Code getestet werden kann:

#[cfg(feature = "default")]
println!("This crate was built with the \"default\" feature enabled.");
#[cfg(not(feature = "default"))]
println!("This crate was built with the \"default\" feature disabled.");

Die zweite Raffinesse der Definitionen ist im [dependencies] Abschnitt des ursprünglichen Cargo.tomlBeispiels versteckt: Die rand Kiste ist eine Abhängigkeit, die als optional = true markiert ist, und die "rand" effektiv zum Namen eines Features macht.7 Wenn die Kiste mit --features rand kompiliert wird, wird diese Abhängigkeit aktiviert:

#[cfg(feature = "rand")]
pub fn pick_a_number() -> u8 {
    rand::random::<u8>()
}

#[cfg(not(feature = "rand"))]
pub fn pick_a_number() -> u8 {
    4 // chosen by fair dice roll.
}

Das bedeutet auch, dass Kisten- und Feature-Namen sich einen Namensraum teilen, auch wenn ein typischerweise global ist (und in der Regel von crates.io geregelt wird) und der andere lokal für die betreffende Kiste ist. Wähle daher die Namen von Funktionen sorgfältig aus, um Überschneidungen mit den Namen von Kisten zu vermeiden, die als potenzielle Abhängigkeiten relevant sein könnten. Es ist möglich, Überschneidungen zu vermeiden, da Cargo einen Mechanismus enthält , mit dem importierte Kisten umbenannt werden können (die Tastepackage ), aber es ist einfacher, dies nicht tun zu müssen.

Du kannst die Eigenschaften einer Kiste also sowohl auf [features] als auch optional [dependencies] in der Datei Cargo.toml der Kiste. Um eine Funktion einer Abhängigkeit zu aktivieren, fügst du die Option features der entsprechenden Zeile in der Stanza[dependencies] deiner eigenen Manifestdatei hinzu:

[dependencies]
somecrate = { version = "^0.3", features = ["featureA", "rand" ] }

Diese Zeile stellt sicher, dass somecrate mit den beiden Funktionen featureA und rand erstellt wird. Das sind aber möglicherweise nicht die einzigen Features, die aktiviert sind; aufgrund eines Phänomens, das als Feature Unification bekannt ist, können auch andere Features aktiviert sein. Das bedeutet, dass eine Kiste mit der Vereinigung aller Funktionen gebaut wird, die von irgendetwas im Build-Graph angefordert werden. Mit anderen Worten: Wenn eine andere Abhängigkeit im Build-Graph ebenfalls auf somecrate angewiesen ist, aber nur featureB aktiviert ist, wird die Kiste mit allen featureA, featureB und rand aktiviert, um alle zufriedenzustellen.8 Die gleiche Überlegung gilt für Standardfunktionen: Wenn deine Kiste default-features = false für eine Abhängigkeit setzt, aber an einer anderen Stelle im Build-Graph die Standardfunktionen aktiviert lässt, dann werden sie auch aktiviert sein.

Die Vereinheitlichung von Funktionen bedeutet, dass Funktionen additiv sein sollten. Es ist keine gute Idee, inkompatible Funktionen zu haben, weil nichts verhindert, dass die inkompatiblen Funktionen gleichzeitig von verschiedenen Nutzern aktiviert werden.

Wenn eine Kiste zum Beispiel eine struct und ihre Felder öffentlich zugänglich macht, ist es eine schlechte Idee, die Felder funktionsabhängig zu machen:

Ein Benutzer der Kiste, der versucht, eine Instanz von struct zu erstellen, steht vor einem Dilemma: Soll er das Feld schemaausfüllen oder nicht? Eine Möglichkeit, dieses Problem zu lösen, besteht darin, eine entsprechende Funktion in der Cargo.toml des Benutzers hinzuzufügen:

[features]
# The `use-schema` feature here turns on the `schema` feature of `somecrate`.
# (This example uses different feature names for clarity; real code is more
# likely to reuse the feature names across both places.)
use-schema = ["somecrate/schema"]

und die Konstruktion von struct von diesem Merkmal abhängig zu machen:

Dies deckt jedoch nicht alle Eventualitäten ab: Der Code schlägt fehl, wenn dieser Codesomecrate/schema nicht aktiviert, eine andere transitive Abhängigkeit jedoch schon. Der Kern des Problems ist, dass nur die Kiste, die das Feature hat, das Feature überprüfen kann; es gibt keine Möglichkeit für den Benutzer der Kiste festzustellen, ob Cargosomecrate/schema aktiviert hat oder nicht. Aus diesem Grund solltest du es vermeiden, öffentliche Felder in Strukturen mit einem Feature-Gating zu versehen.

Eine ähnliche Überlegung gilt für öffentliche Traits, die außerhalb der Kiste, in der sie definiert sind, verwendet werden sollen. Nehmen wir einen Trait, der ein Feature Gate in einer seiner Methoden enthält:

Externe Trait-Implementierer stehen wieder vor einem Dilemma: Sollen sie die Methode cddl(&self) implementieren oder nicht? Der externe Code, der versucht, den Trait zu implementieren, weiß nicht - und kann auch nicht sagen, ob er die feature-gated Methode implementieren soll oder nicht.

Im Endeffekt solltest du es also vermeiden, Methoden für öffentliche Traits mit einem Feature-Gating zu versehen. Eine Trait-Methode mit einer Standardimplementierung(Punkt 13) könnte eine Ausnahme sein - aber nur, wenn es für externen Code niemals sinnvoll ist, die Standardimplementierung zu überschreiben.

Feature-Unification bedeutet auch, dass, wenn deine Kiste N unabhängige Features hat,9 dann können in der Praxis alle 2N möglichen Build-Kombinationen auftreten. Um unangenehme Überraschungen zu vermeiden, solltest du sicherstellen, dass dein CI-System(Punkt 32) alle diese 2N Kombinationen in allen verfügbaren Testvarianten(Punkt 30) abdeckt.

Die Verwendung von optionalen Funktionen ist jedoch sehr hilfreich, um die Exposition gegenüber einem erweiterten Abhängigkeitsgraphen zu kontrollieren(Punkt 25). Dies ist vor allem bei Low-Level-Crates nützlich, die in einer no_std Umgebung verwendet werden können(Punkt 33) - es ist üblich, ein std oder alloc Feature zu haben, das Funktionen aktiviert, die auf diese Bibliotheken angewiesen sind.

Dinge zum Erinnern

  • Die Namen der Merkmale überschneiden sich mit den Namen der Abhängigkeiten.

  • Die Namen der Merkmale sollten sorgfältig gewählt werden, damit sie nicht mit den Namen möglicher Abhängigkeiten kollidieren.

  • Merkmale sollten additiv sein.

  • Vermeide Feature Gates auf öffentlichen struct Feldern oder Trait-Methoden.

  • Eine Vielzahl unabhängiger Funktionen kann zu einer kombinatorischen Explosion von verschiedenen Build-Konfigurationen führen.

1 Mit der bemerkenswerten Ausnahme von C und C++, wo die Paketverwaltung etwas fragmentiert bleibt.

2 Zum Beispiel, cargo-semver-checks ist ein Tool, das versucht, etwas in diese Richtung zu tun.

3 Dieses Beispiel (und auch Item) ist von dem Ansatz inspiriert, der in den RustCrypto-Kisten verwendet wird.

4 Diese Art von Fehler kann sogar auftreten, wenn der Abhängigkeitsgraph zwei Alternativen für eine Kiste mit der gleichen Version enthält, wenn etwas im Build-Graph das path Feld verwendet, um ein lokales Verzeichnis anstelle eines crates.io Ortes anzugeben.

5 Es ist auch möglich, eine alternative Kistenregistrierung zu konfigurieren (z. B. eine interne Unternehmensregistrierung). Jeder Abhängigkeitseintrag in Cargo.toml kann dann den Schlüssel registry verwenden, um anzugeben, von welcher Registry eine Abhängigkeit bezogen werden soll.

6 Wenn du auf eine no_std Umgebung ausrichtest, kann diese Entscheidung für dich getroffen werden: Viele Kisten sind nicht mit no_std kompatibel, insbesondere wenn alloc ebenfalls nicht verfügbar ist(Punkt 33).

7 Dieses Standardverhalten kann durch einen "dep:<crate>" Verweis an anderer Stelle in der features Stanza deaktiviert werden; Details dazu findest du in der Dokumentation.

8 Der Befehl cargo tree --edges features kann dir dabei helfen, herauszufinden, welche Funktionen für welche Kisten aktiviert sind und warum.

9 Merkmale können die Aktivierung anderer Merkmale erzwingen; im Originalbeispiel erzwingt das Merkmal featureAB die Aktivierung von featureA und featureB.

Get Effektiver Rost now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.