Protobuffer sind falsch

Für den größten Teil meines Berufslebens bin ich gegen die Verwendung von Protokollpuffern. Sie sind klar geschrieben von Amateuren, unglaublich hoch spezialisiert, leiden unter vielen Fallstricken, sind schwer zu kompilieren und lösen ein Problem, das niemand außer Google tatsächlich hat. Wenn diese Probleme der Protopuffer in der Quarantäne von Serialisierungsabstraktionen verbleiben würden, würden meine Behauptungen dort enden. Leider ist das schlechte Design von Protobuffern so aufdringlich, dass diese Probleme in Ihren Code eindringen können.

Enge Spezialisierung und Entwicklung durch Amateure

Hör auf. Schließen Sie Ihren E-Mail-Client, in dem Sie mir bereits einen Brief geschrieben haben, in dem es heißt: "Die besten Ingenieure der Welt arbeiten bei Google", "ihre Entwürfe können per Definition nicht von Amateuren erstellt werden." Das will ich nicht hören.

Lassen Sie uns dieses Thema einfach nicht diskutieren. Vollständige Offenlegung: Ich habe früher bei Google gearbeitet. Dies war der erste (aber leider nicht der letzte) Ort, an dem ich Protobuffer verwendet habe. Alle Probleme, über die ich sprechen möchte, sind in der Google-Codebasis vorhanden. Es ist nicht nur "Missbrauch von Protobuffern" und dergleichen.

Das mit Abstand größte Problem bei Protobuffern ist das schreckliche Typsystem. Java-Fans sollten sich hier wie zu Hause fühlen, aber leider hält buchstäblich niemand Java für ein gut gestaltetes Typsystem. Die Leute vom dynamischen Schreibcamp beschweren sich über unnötige Einschränkungen, während sich die Vertreter des statischen Schreibcamps wie ich über unnötige Einschränkungen und das Fehlen von allem beschweren, was Sie wirklich vom Typensystem wollen. In beiden Fällen verlieren.

Enge Spezialisierung und Entwicklung durch Amateure gehen Hand in Hand. Ein Großteil der Spezifikationen schien im letzten Moment verschraubt zu sein - und es war offensichtlich im letzten Moment verschraubt. Einige Einschränkungen zwingen Sie anzuhalten, sich am Kopf zu kratzen und zu fragen: "Was zur Hölle?" Dies sind jedoch nur Symptome eines tieferen Problems:

Offensichtlich werden Protobuffer von Amateuren erstellt, weil sie schlechte Lösungen für bekannte und bereits gelöste Probleme bieten.

Mangel an Zusammensetzung


Protobuffer bieten mehrere Funktionen, die nicht miteinander funktionieren. Schauen Sie sich zum Beispiel die Liste der orthogonalen, aber gleichzeitig eingeschränkten Eingabefunktionen an, die ich in der Dokumentation gefunden habe.

  • oneof Felder kann nicht repeated .
  • Die Felder map<k,v> haben eine spezielle Syntax für Schlüssel und Werte, werden jedoch in keinem anderen Typ verwendet.
  • Obwohl map parametriert werden können, ist kein benutzerdefinierter Typ mehr zulässig. Dies bedeutet, dass Sie nicht mehr in der Lage sind, Ihre eigenen Spezialisierungen in allgemeinen Datenstrukturen manuell anzugeben.
  • map können nicht repeated .
  • map können string , jedoch keine bytes . Enum ist ebenfalls verboten, obwohl letztere in allen anderen Teilen der Protobuffers-Spezifikation als Ganzzahlen gleichwertig angesehen werden.
  • map können keine anderen map .

Diese verrückte Liste von Einschränkungen ist das Ergebnis einer prinzipienlosen Auswahl an Design- und Schraubenfunktionen im letzten Moment. Beispielsweise kann oneof Felder nicht repeated , da der Codegenerator anstelle eines oneof gegenseitig ausschließende optionale Felder erzeugt. Eine solche Transformation gilt nur für ein einzelnes Feld (und wie wir später sehen werden, funktioniert sie nicht einmal für dieses Feld).

Die Einschränkung von map , die nicht repeated , stammt ungefähr aus derselben Oper, zeigt jedoch eine andere Einschränkung des Typsystems. Hinter den Kulissen verwandelt sich die map<k,v> in etwas Ähnliches wie das repeated Pair<k,v> . Und da repeated das magische Schlüsselwort der Sprache ist und nicht der normale Typ, verbindet es sich nicht mit sich selbst.

Ihre Vermutungen über das Problem mit enum sind genauso wahr wie meine.

Was an all dem so frustrierend ist, ist ein schlechtes Verständnis der Funktionsweise moderner Typsysteme. Dieses Verständnis würde die Protobuffer-Spezifikation dramatisch vereinfachen und gleichzeitig alle willkürlichen Einschränkungen aufheben .

Die Lösung lautet wie folgt:

  • Machen Sie alle Felder in der required Nachricht. Dies macht jede Nachricht zu einem Produkttyp.
  • oneof Sie den Wert des oneof auf eigenständige Datentypen. Dies wird ein Nebenprodukttyp sein.
  • Ermöglichen der Parametrisierung von Produkttypen und Nebenprodukten anderer Typen.

Das ist alles! Diese drei Änderungen sind alles, was Sie benötigen, um mögliche Daten zu ermitteln. Mit diesem einfachen System können Sie alle anderen Protobuffer-Spezifikationen wiederholen.

Sie können beispielsweise die optional Felder wiederholen:

 product Unit { // no fields } coproduct Optional<t> { t value = 0; Unit unset = 1; } 

Das Erstellen repeated Felder ist ebenfalls einfach:

 coproduct List<t> { Unit empty = 0; Pair<t, List<t>> cons = 1; } 

Die eigentliche Logik der Serialisierung ermöglicht es Ihnen natürlich, etwas Klügeres zu tun, als verknüpfte Listen über das Netzwerk zu übertragen - schließlich müssen Implementierung und Semantik nicht miteinander korrespondieren .

Zweifelhafte Wahl


Protobuffer im Java-Stil unterscheiden zwischen Skalar- und Nachrichtentypen . Skalare entsprechen mehr oder weniger Maschinenprimitiven - Dinge wie int32 , bool und string . Nachrichtentypen sind dagegen der Rest. Alle Bibliotheks- und Benutzertypen sind Nachrichten.

Natürlich haben die beiden Arten von Typen eine völlig unterschiedliche Semantik.

Felder mit Skalartypen sind immer vorhanden. Auch wenn Sie sie nicht installiert haben. Das habe ich schon gesagt (zumindest in proto3 1 ) Werden alle Protopuffer auf Nullen initialisiert, auch wenn sie absolut keine Daten haben? Skalarfelder erhalten gefälschte Werte: Beispielsweise wird uint32 auf 0 und string auf "" initialisiert.

Es ist nicht möglich, ein Feld, das sich nicht im Protopuffer befand, von einem Feld zu unterscheiden, dem ein Standardwert zugewiesen wurde. Vermutlich wurde diese Entscheidung zur Optimierung getroffen, um keine skalaren Standardeinstellungen weiterzuleiten. Dies ist nur eine Annahme, da in der Dokumentation diese Optimierung nicht erwähnt wird und Ihre Annahme daher nicht schlechter ist als meine.

Wenn wir Protobuffers Behauptungen einer idealen Lösung für die Abwärts- und zukünftige API-Kompatibilität diskutieren, werden wir sehen, dass diese Unfähigkeit, zwischen undefinierten und Standardwerten zu unterscheiden, ein wahrer Albtraum ist. Besonders wenn es wirklich eine bewusste Entscheidung ist, ein Bit (gesetzt oder nicht) für das Feld zu speichern.

Vergleichen Sie dieses Verhalten mit Nachrichtentypen. Während Skalarfelder „dumm“ sind, ist das Verhalten von Nachrichtenfeldern völlig verrückt . Intern sind die Nachrichtenfelder entweder vorhanden oder nicht, aber das Verhalten ist verrückt. Ein kleiner Pseudocode für ihren Accessor sagt mehr als tausend Worte. Stellen Sie sich das in Java oder anderswo vor:

 private Foo m_foo; public Foo foo { // only if `foo` is used as an expression get { if (m_foo != null) return m_foo; else return new Foo(); } // instead if `foo` is used as an lvalue mutable get { if (m_foo = null) m_foo = new Foo(); return m_foo; } } 

Wenn das Feld foo nicht festgelegt ist, wird theoretisch eine standardmäßig initialisierte Kopie angezeigt, unabhängig davon, ob Sie danach fragen oder nicht. Sie können den Container jedoch nicht ändern. Wenn Sie jedoch foo ändern, ändert sich auch das übergeordnete Element! All dies dient nur dazu, die Verwendung des Typs " Maybe Foo " und der damit verbundenen "Kopfschmerzen" zu vermeiden, um herauszufinden, was ein undefinierter Wert bedeuten sollte.

Ein solches Verhalten ist besonders ungeheuerlich, weil es gegen das Gesetz verstößt! Wir erwarten den Job msg.foo = msg.foo; wird nicht funktionieren. Stattdessen ändert die Implementierung msg stillschweigend in eine Kopie von foo mit Null-Initialisierung, wenn sie vorher nicht vorhanden war.

Im Gegensatz zu Skalarfeldern können Sie zumindest feststellen, dass das Nachrichtenfeld nicht festgelegt ist. Sprachbindungen für Protobuffer bieten so etwas wie die generierte Methode bool has_foo() . Wenn es vorhanden ist, müssen Sie beim häufigen Kopieren des Nachrichtenfelds von einem Protobuffer in einen anderen den folgenden Code schreiben:

 if (src.has_foo(src)) { dst.set_foo(src.foo()); } 

Bitte beachten Sie, dass diese Vorlage zumindest in Sprachen mit statischer Typisierung aufgrund der nominalen Beziehung zwischen den has_foo() foo() , set_foo() und has_foo() nicht abstrahiert werden has_foo() . Da alle diese Funktionen ihre eigenen Bezeichner sind , haben wir nicht die Möglichkeit, sie programmgesteuert zu generieren, mit Ausnahme des Präprozessor-Makros:

 #define COPY_IFF_SET(src, dst, field) \ if (src.has_##field(src)) { \ dst.set_##field(src.field()); \ } 

(Präprozessor-Makros sind jedoch im Google Style Guide verboten).

Wenn stattdessen alle zusätzlichen Felder als Maybe implementiert wären, könnten Sie die abstrahierten Dial-Peers sicher festlegen.

Um das Thema zu wechseln, sprechen wir über eine weitere zweifelhafte Entscheidung. Obwohl Sie eines der Felder in oneof definieren können, stimmt ihre Semantik nicht mit der Art des Nebenprodukts überein ! Newbie Fehler Jungs! Stattdessen erhalten Sie ein optionales Feld für jeden Fall und jeden magischen Code in den Setzern, wodurch jedes andere Feld einfach rückgängig gemacht wird, wenn es gesetzt ist.

Auf den ersten Blick scheint dies semantisch der richtigen Art der Vereinigung zu entsprechen. Aber stattdessen bekommen wir eine widerliche, unbeschreibliche Fehlerquelle! Wenn dieses Verhalten mit einer unzulässigen Implementierung kombiniert wird msg.foo = msg.foo; Eine solche scheinbar normale Zuordnung löscht stillschweigend beliebige Datenmengen!

Infolgedessen bedeutet dies, dass oneof Felder kein gesetzestreues Prism bildet und Nachrichten keine gesetzestreue Lens . Also viel Glück bei Ihren Versuchen, nichttriviale Protobuffer-Manipulationen ohne Fehler zu schreiben. Es ist buchstäblich unmöglich, einen universellen, fehlerfreien, polymorphen Code auf Protobuffer zu schreiben .

Dies ist nicht sehr angenehm zu hören, besonders für diejenigen von uns, die parametrischen Polymorphismus lieben, der genau das Gegenteil verspricht .

Rückwärts- und Zukunftskompatibilität liegt


Eine der oft erwähnten "Killer-Funktionen" von Protobuffers ist ihre "problemlose Fähigkeit, rückwärts- und vorwärtskompatible APIs zu schreiben". Diese Aussage wurde vor Ihren Augen aufgehängt, um die Wahrheit zu verschleiern.

Diese Protobuffer sind freizügig. Sie schaffen es, mit Nachrichten aus der Vergangenheit oder Zukunft umzugehen, weil sie absolut keine Zusagen darüber machen, wie Ihre Daten aussehen werden. Alles ist optional! Aber wenn Sie es brauchen, bereitet Protobuffers Ihnen gerne etwas mit Typprüfung vor und gibt Ihnen etwas, unabhängig davon, ob es sinnvoll ist.

Dies bedeutet, dass Protobuffer die versprochene "Zeitreise" ausführen und dabei standardmäßig leise das Falsche tun . Natürlich kann (und sollte) ein vorsichtiger Programmierer Code schreiben, der die Richtigkeit der empfangenen Protobuffer überprüft. Wenn Sie jedoch an jeder Site Schutzkorrektheitsprüfungen durchführen, bedeutet dies möglicherweise nur, dass der Deserialisierungsschritt zu zulässig war. Sie haben lediglich die Validierungslogik von einer genau definierten Grenze aus dezentralisiert und in der gesamten Codebasis verwischt.

Eines der möglichen Argumente ist, dass Protobuffer alle Informationen speichern, die sie in der Nachricht nicht verstehen. Im Prinzip bedeutet dies eine zerstörungsfreie Übertragung der Nachricht durch einen Vermittler, der diese Version des Schemas nicht versteht. Das ist ein klarer Sieg, nicht wahr?

Auf dem Papier ist dies natürlich eine coole Funktion. Aber ich habe noch nie eine Anwendung gesehen, in der diese Eigenschaft wirklich gespeichert ist. Mit Ausnahme der Routing-Software möchte kein Programm nur bestimmte Teile einer Nachricht prüfen und dann unverändert weiterleiten. Die überwiegende Mehrheit der Programme auf Protobuffern entschlüsselt die Nachricht, wandelt sie in eine andere um und sendet sie an einen anderen Ort. Leider werden diese Konvertierungen auf Bestellung durchgeführt und manuell codiert. Bei manuellen Konvertierungen von einem Protobuffer in einen anderen bleiben unbekannte Felder nicht erhalten, da dies buchstäblich sinnlos ist.

Diese allgegenwärtige Haltung gegenüber Protobuffern als universell kompatibel manifestiert sich auch auf andere hässliche Weise. Styleguides für Protobuffer lehnen DRY aktiv ab und schlagen vor, Definitionen nach Möglichkeit in Code einzubetten. Sie argumentieren, dass dies in Zukunft die Verwendung separater Nachrichten ermöglichen wird, wenn die Definitionen voneinander abweichen. Ich betone, dass sie anbieten, die 60-jährige Praxis des guten Programmierens aufzugeben, nur für den Fall , dass Sie plötzlich irgendwann in der Zukunft etwas ändern müssen.

Die Wurzel des Problems liegt darin, dass Google die Bedeutung von Daten mit seiner physischen Darstellung kombiniert. Wenn Sie auf einer Google-Skala arbeiten, ist dies sinnvoll. Am Ende haben sie ein internes Tool, das den Stundenlohn des Programmierers über das Netzwerk, die Kosten für das Speichern von X-Bytes und andere Dinge vergleicht. Im Gegensatz zu den meisten Technologieunternehmen ist das Gehalt von Programmierern eine der geringsten Ausgaben von Google. Finanziell ist es für sie sinnvoll, die Zeit der Programmierer damit zu verbringen, ein paar Bytes zu sparen.

Neben den fünf führenden Technologieunternehmen liegt niemand innerhalb der fünf Größenordnungen von Google. Ihr Startup kann es sich nicht leisten , Engineering-Stunden damit zu verbringen, Bytes zu sparen. Das Einsparen von Bytes und die Verschwendung von Programmierzeit ist genau das, wofür Protobuffer optimiert sind.

Seien wir ehrlich. Sie passen nicht in die Skala von Google und werden auch nie passen. Verwenden Sie den Frachtkult der Technologie nicht mehr, nur weil "Google ihn nutzt" und weil "dies Best Practices der Branche sind".

Protobuffer verschmutzen Codebasen


Wenn es möglich wäre, die Verwendung von Protobuffern nur auf das Netzwerk zu beschränken, würde ich nicht so hart über diese Technologie sprechen. Obwohl es im Prinzip mehrere Lösungen gibt, ist leider keine davon gut genug, um tatsächlich in echter Software verwendet zu werden.

Protobuffer entsprechen den Daten, die Sie über den Kommunikationskanal senden möchten. Sie sind häufig konsistent , aber nicht identisch mit den tatsächlichen Daten, mit denen die Anwendung arbeiten möchte. Dies bringt uns in eine unangenehme Lage. Sie müssen zwischen drei schlechten Optionen wählen:

  1. Pflegen Sie einen separaten Typ, der die Daten beschreibt, die Sie wirklich benötigen, und stellen Sie sicher, dass beide Typen gleichzeitig unterstützt werden.
  2. Packen Sie die vollständigen Daten in ein Format zur Übertragung und Verwendung durch die Anwendung.
  3. Rufen Sie jedes Mal vollständige Daten aus dem Kurzformat für die Übertragung ab.

Option 1 ist eindeutig die „richtige“ Lösung, für Protobuffer jedoch ungeeignet. Die Sprache ist nicht leistungsfähig genug, um Typen zu codieren, die doppelte Arbeit in zwei Formaten leisten können. Dies bedeutet, dass Sie einen vollständig separaten Datentyp schreiben, synchron mit Protobuffers entwickeln und speziell Serialisierungscode für diese schreiben müssen . Da die meisten Leute Protobuffer verwenden, um keinen Serialisierungscode zu schreiben, wird diese Option offensichtlich nie implementiert.

Stattdessen können sie mithilfe von Protobuffern in der gesamten Codebasis verteilt werden. Es ist eine Realität. Mein Hauptprojekt bei Google war ein Compiler, der ein in einer Variante von Protobuffers geschriebenes „Programm“ nahm und in einem anderen ein gleichwertiges „Programm“ produzierte. Die Eingabe- und Ausgabeformate waren sehr unterschiedlich, so dass ihre korrekten parallelen Versionen von C ++ nie funktionierten. Infolgedessen konnte mein Code keine der umfangreichen Compiler-Schreibtechniken verwenden, da die Protobuffer-Daten (und der generierte Code) zu schwierig waren, um mit ihnen etwas Interessantes zu tun.

Infolgedessen wurden anstelle von 50 Zeilen Rekursionsschemata 10.000 Zeilen spezielles Puffermischen verwendet. Der Code, den ich schreiben wollte, war mit Protopuffern buchstäblich unmöglich.

Obwohl dies ein Fall ist, ist es nicht eindeutig. Aufgrund der harten Natur der Codegenerierung werden die Manifestationen von Protopuffern in Sprachen niemals idiomatisch sein und können nicht erstellt werden - es sei denn, Sie schreiben den Codegenerator neu.

Aber selbst dann haben Sie immer noch ein Problem beim Einbetten eines beschissenen Typsystems in Ihre Zielsprache. Da die meisten Funktionen von Protobuffern schlecht durchdacht sind, gelangen diese zweifelhaften Eigenschaften in unsere Codebasen. Dies bedeutet, dass wir gezwungen sind, diese schlechten Ideen nicht nur umzusetzen, sondern auch in jedem Projekt zu verwenden, das mit Protobuffers interagieren möchte.

Auf einer soliden Basis ist es leicht, bedeutungslose Dinge zu erkennen, aber wenn Sie in eine andere Richtung gehen, werden Sie bestenfalls auf Schwierigkeiten stoßen, und im schlimmsten Fall mit echtem uraltem Entsetzen.

Geben Sie im Allgemeinen die Hoffnung an alle weiter, die Protobuffer in ihren Projekten implementieren.



1. Bis heute gibt es bei Google eine hitzige Diskussion über proto2 und darüber, ob die Felder jemals als required markiert required . Die Manifeste " optional gilt als schädlich" und " required als schädlich" werden gleichzeitig verteilt. Viel Glück, Jungs.

Source: https://habr.com/ru/post/de427265/


All Articles