Rust Proto Design-Entscheidungen
Wie bei jeder Bibliothek ist Rust Protobuf unter Berücksichtigung der Bedürfnisse sowohl der firmeninternen Nutzung von Rust durch Google als auch der externen Benutzer konzipiert. Die Wahl eines Weges in diesem Designraum bedeutet, dass einige getroffene Entscheidungen für einige Benutzer in einigen Fällen nicht optimal sind, auch wenn es für die Implementierung insgesamt die richtige Wahl ist.
Diese Seite behandelt einige der größeren Designentscheidungen, die die Rust Protobuf-Implementierung trifft, und die Überlegungen, die zu diesen Entscheidungen geführt haben.
Entwickelt, um von anderen Protobuf-Implementierungen, einschließlich C++ Protobuf, „unterstützt“ zu werden
Protobuf Rust ist keine reine Rust-Implementierung von Protobuf, sondern eine sichere Rust-API, die auf bestehenden Protobuf-Implementierungen aufbaut, oder wie wir diese Implementierungen nennen: Kernel.
Der wichtigste Faktor für diese Entscheidung war die Ermöglichung kostenloser Hinzufügung von Rust zu einer bereits vorhandenen Binärdatei, die bereits Nicht-Rust-Protobuf verwendet. Durch die ABI-Kompatibilität der Implementierung mit dem von C++ Protobuf generierten Code ist es möglich, Protobuf-Nachrichten über die Sprachgrenze (FFI) als einfache Zeiger zu teilen, wodurch die Notwendigkeit entfällt, in einer Sprache zu serialisieren, das Byte-Array über die Grenze zu übergeben und in der anderen Sprache zu deserialisieren. Dies reduziert auch die Binärgröße für diese Anwendungsfälle, da keine redundanten Schema-Informationen für dieselben Nachrichten für jede Sprache in der Binärdatei eingebettet sind.
Google sieht Rust als eine Gelegenheit, inkrementell Speichersicherheit in Schlüsselbereiche bestehender, älterer C++-Server zu bringen; die Kosten der Serialisierung an Sprachgrenzen würden die Akzeptanz von Rust zur Ersetzung von C++ in vielen dieser wichtigen und leistungssensitiven Fälle verhindern. Wenn wir eine Greenfield-Rust-Protobuf-Implementierung verfolgen würden, die diese Unterstützung nicht hätte, würde dies die Akzeptanz von Rust blockieren und erfordern, dass diese wichtigen Fälle stattdessen bei C++ bleiben.
Protobuf Rust unterstützt derzeit drei Kernel
- C++-Kernel – der generierte Code wird von C++ Protocol Buffers (der „vollständigen“ Implementierung, typischerweise für Server verwendet) unterstützt. Dieser Kernel bietet In-Memory-Interoperabilität mit C++-Code, der die C++-Laufzeitumgebung verwendet. Dies ist der Standard für Server innerhalb von Google.
- C++ Lite-Kernel – der generierte Code wird von C++ Lite Protocol Buffers (typischerweise für mobile Geräte verwendet) unterstützt. Dieser Kernel bietet In-Memory-Interoperabilität mit C++-Code, der die C++ Lite-Laufzeitumgebung verwendet. Dies ist der Standard für mobile Apps innerhalb von Google.
- upb-Kernel – der generierte Code wird von upb unterstützt, einer hochperformanten und kleinen Protobuf-Bibliothek in C. upb ist als Implementierungsdetail von Protobuf-Laufzeitumgebungen in anderen Sprachen konzipiert. Dies ist der Standard in Open-Source-Builds, wo wir erwarten, dass statische Verknüpfungen mit Code, der bereits C++ Protobuf verwendet, seltener sind.
Rust Protobuf ist darauf ausgelegt, mehrere alternative Implementierungen (einschließlich mehrerer verschiedener Speicherlayouts) zu unterstützen, während genau dieselbe API exponiert wird, sodass derselbe Anwendungscode neu kompiliert werden kann, um von einer anderen Implementierung unterstützt zu werden. Diese Designbeschränkung beeinflusst maßgeblich unsere öffentlichen API-Entscheidungen, einschließlich der Typen, die für Getter verwendet werden (später in diesem Dokument besprochen).
Kein reiner Rust-Kernel
Da wir die API so konzipiert haben, dass sie von mehreren unterstützenden Implementierungen implementierbar ist, ist die natürliche Frage, warum die einzigen unterstützten Kernel heute in den speicherunsicheren Sprachen C und C++ geschrieben sind.
Während Rust als speichersichere Sprache die Anfälligkeit für kritische Sicherheitsprobleme erheblich reduzieren kann, ist keine Sprache immun gegen Sicherheitsprobleme. Die Protobuf-Implementierungen, die wir als Kernel unterstützen, wurden so gründlich geprüft und mit Fuzzing getestet, dass Google sich wohlfühlt, diese Implementierungen für nicht-sandboxed Parsing von nicht vertrauenswürdigen Eingaben in unseren eigenen Servern und Apps zu verwenden.
Ein Greenfield-Binärparser, der zu diesem Zeitpunkt in Rust geschrieben wäre, würde mit höherer Wahrscheinlichkeit kritische Schwachstellen enthalten als unsere bereits vorhandenen C++ Protobuf- oder upb-Parser, die ausgiebig mit Fuzzing getestet, geprüft und überprüft wurden.
Es gibt berechtigte Argumente für die langfristige Unterstützung einer reinen Rust-Kernel-Implementierung, einschließlich der Möglichkeit für Entwickler, beim Kompilieren von C-Code nicht Clang zur Verfügung haben zu müssen.
Wir erwarten, dass Google zu einem späteren Zeitpunkt eine reine Rust-Implementierung mit derselben exponierten API unterstützen wird, aber wir haben derzeit keine konkrete Roadmap dafür. Eine zweite offizielle Rust-Protobuf-Implementierung, die eine „bessere“ API hat, indem sie die Beschränkungen vermeidet, die sich aus der Unterstützung durch C++ Proto und upb ergeben, ist nicht geplant, da wir Googles eigene Protobuf-Nutzung nicht fragmentieren wollen.
View/Mut Proxy-Typen
Die Rust Proto API ist mit undurchsichtigen „Proxy“-Typen konzipiert. Für eine .proto-Datei, die message SomeMsg {} definiert, generieren wir die Rust-Typen SomeMsg, SomeMsgView<'_> und SomeMsgMut<'_>. Die einfache Faustregel ist, dass wir erwarten, dass die View- und Mut-Typen standardmäßig für &SomeMsg und &mut SomeMsg in allen Verwendungen stehen, während sie immer noch das gesamte Borrow-Checking-/Send-/etc.-Verhalten erhalten, das Sie von diesen Typen erwarten würden.
Eine weitere Linse zum Verständnis dieser Typen
Um die Nuancen dieser Typen besser zu verstehen, kann es hilfreich sein, diese Typen wie folgt zu betrachten
struct SomeMsg(Box<cpp::SomeMsg>);
struct SomeMsgView<'a>(&'a cpp::SomeMsg);
struct SomeMsgMut<'a>(&'a mut cpp::SomeMsg);
Unter dieser Linse können Sie sehen, dass
- Gegeben sei
&SomeMsg, es ist möglich, eineSomeMsgViewzu erhalten (ähnlich wie bei&Box<T>eine&Terhalten werden kann) - Gegeben sei
SomeMsgView, es ist *nicht* möglich, eine&SomeMsgzu erhalten (ähnlich wie bei&Tkeine&Box<T>erhalten werden konnte).
Genau wie beim &Box-Beispiel bedeutet dies, dass es bei Funktionsargumenten im Allgemeinen besser ist, standardmäßig SomeMsgView<'a> anstelle von &'a SomeMsg zu verwenden, da dies eine Obermenge von Aufrufern ermöglicht, die Funktion zu verwenden.
Warum
Es gibt zwei Hauptgründe für dieses Design: um mögliche Optimierungsnutzen zu erschließen, und als Ergebnis des Kernel-Designs.
Optimierungsmöglichkeitsnutzen
Da Protobuf eine so zentrale und weit verbreitete Technologie ist, ist sie ungewöhnlich anfällig dafür, dass alle beobachtbaren Verhaltensweisen von jemandem abhängig gemacht werden, und relativ kleine Optimierungen haben einen ungewöhnlich großen Gesamteffekt in großem Maßstab. Wir haben festgestellt, dass mehr Undurchsichtigkeit von Typen ungewöhnlich viel Hebelwirkung bietet: Sie erlaubt uns, über genau exponierte Verhaltensweisen nachzudenken und uns mehr Raum für die Optimierung der Implementierung zu geben.
Ein SomeMsgMut<'_> bietet diese Möglichkeiten, wo ein &mut SomeMsg dies nicht tun würde: nämlich dass wir sie verzögert und mit einem Implementierungsdetail konstruieren können, das nicht dasselbe ist wie die besessene Nachrichtenrepräsentation. Es erlaubt uns auch von Natur aus, bestimmte Verhaltensweisen zu kontrollieren, die wir sonst nicht einschränken oder kontrollieren könnten: Zum Beispiel kann jedes &mut mit std::mem::swap() verwendet werden, ein Verhalten, das starke Einschränkungen für die Invarianten zwischen einer Eltern- und einer Kindstruktur mit sich bringen würde, wenn &mut SomeChild an Aufrufer übergeben wird.
Inhärent zum Kernel-Design
Der andere Grund für die Proxy-Typen ist eher eine inhärente Einschränkung unseres Kernel-Designs; wenn Sie ein &T haben, muss irgendwo ein tatsächlicher Rust T-Typ im Speicher vorhanden sein.
Unser C++-Kernel-Design ermöglicht es Ihnen, eine Nachricht zu parsen, die verschachtelte Nachrichten enthält, und nur ein kleines, auf dem Stapel allokiertes Rust-Objekt zur Darstellung der Stamm-Nachricht zu erstellen, wobei der gesamte andere Speicher auf dem C++-Heap gespeichert wird. Wenn Sie später auf eine Kind-Nachricht zugreifen, gibt es kein bereits allokiertes Rust-Objekt, das dieser Kind-Nachricht entspricht, und somit gibt es in diesem Moment keine Rust-Instanz zum Ausleihen.
Durch die Verwendung von Proxy-Typen können wir bei Bedarf die Rust-Proxy-Typen erstellen, die semantisch als Borrows fungieren, ohne dass im Voraus Speicher für diese Instanzen allokiert wird.
Nicht-Standard-Typen
Einfache Typen, die möglicherweise einen direkt entsprechenden Standardtyp haben
In einigen Fällen kann die Rust Protobuf API eigene Typen erstellen, wo ein entsprechender Standardtyp mit demselben Namen existiert, wobei die aktuelle Implementierung sogar einfach den Standardtyp umschließen kann, zum Beispiel protobuf::UTF8Error.
Die Verwendung dieser Typen anstelle von Standardtypen gibt uns mehr Flexibilität bei der zukünftigen Optimierung der Implementierung. Während unsere aktuelle Implementierung die Rust-Standard-UTF-8-Validierung verwendet, ermöglicht uns die Erstellung unseres eigenen protobuf::Utf8Error-Typs, die Implementierung zu ändern und die hochoptimierte C++-Implementierung der UTF-8-Validierung zu verwenden, die wir von C++ Protobuf kennen und die schneller ist als die Rust-Standard-UTF-8-Validierung.
ProtoString
Rusts str und std::string::String-Typen halten die strikte Invariante ein, dass sie nur gültiges UTF-8 enthalten, aber C++s std::string-Typ erzwingt keine solche Garantie. string-typisierte Protobuf-Felder sollen nur gültiges UTF-8 enthalten, und C++ Protobuf verwendet einen korrekten und hochoptimierten UTF8-Validator. Die API-Oberfläche von C++ Protobuf ist jedoch nicht so eingerichtet, dass sie als Laufzeitinvariante streng erzwingt, dass seine string-Felder immer gültiges UTF-8 enthalten. Stattdessen erlaubt es in einigen Fällen das Setzen von Nicht-UTF8-Daten in ein string-Feld und die Validierung erfolgt erst später während der Serialisierung.
Um die Integration von Rust in bestehende Codebasen, die C++ Protobuf verwenden, zu ermöglichen und gleichzeitig kostenlose Grenzüberquerungen ohne Risiko von undefiniertem Verhalten in Rust zu ermöglichen, müssen wir leider die str/String-Typen für string-Feld-Getter vermeiden. Stattdessen werden die Typen ProtoStr und ProtoString verwendet, die äquivalente Typen sind, mit der Ausnahme, dass sie in seltenen Fällen ungültiges UTF-8 enthalten können. Diese Typen erlauben es dem Anwendungscode zu wählen, ob er die Validierung bei Bedarf durchführt, um die Felder als Result<&str> zu sehen, oder ob er mit den rohen Bytes arbeitet, um jede Laufzeitvalidierung zu vermeiden. Alle Setter-Pfade sind weiterhin so konzipiert, dass Sie &str- oder String-Typen übergeben können.
Wir sind uns bewusst, dass Vokabulartypen wie str für eine idiomatische Nutzung sehr wichtig sind, und beabsichtigen, im Auge zu behalten, ob diese Entscheidung die richtige ist, wenn sich die Nutzungsdetails von Rust weiterentwickeln.