Protobuf im Stream

Wenn man im Zuge der Arbeit schon mit Google’s Protobuf in Berührung kommt, dann verdient diese Software auch eine etwas genauere Betrachtung.

… besonders, wenn es darum geht diverse Problemchen in der C++ Implementierung zu kritisieren ;)


Wenn Datenobjekte über das Netz fließen sollen, stellen sich sich folgende Fragen:

  • Wie werden Datentypen in Bytestreams übersetzt?
  • Wie werden Zeichen so kodiert, dass sie eindeutig sind
  • Wie wird Speicherplatz gespart?
  • Wie soll der Code dazu aussehen

Oder vereinfacht gesagt:

Wie serialisieren und deserialisieren wir unsere Daten?

Protobuf

Google hat hierfür seine Hausmarke “Protobuf” der Öffentlichkeit zugänglich gemacht, und dieses Framework soll Daten in Objekten zwischen Plattformen und auch zwischen unterschiedlichen Programmiersprachen über das Netzwerk (oder andere Datenströme) austauschen können.

Protobuf ist binär und besonders auf’s Platzsparen optimiert. Ein “leeres” Objekt (was bedeutet, alle seine Unterelemente entsprechen einem vorbestimmten Standardwert) wird genau in Null Bytes übersetzt.

Code Generator

Eine wichtige Eigenschaft der Technologie ist, dass Protobuf Objekte in einer Art Interface-Metasprache definiert werden und am Ende durch einen Generator alle Quellcodes automatisch generiert werden.

Das hat den großen Vorteil, dass ein Datenobjekt automatisch in allen unterstützten Programmiersprachen generiert wird, darunter C++, C#, Java oder Python.

Wir definieren also eine “.proto” Textdatei, z.B. mit dem Inhalt

1syntax = "proto3";
2
3message SearchRequest {
4  string query = 1;
5  int32 page_number = 2;
6  int32 result_per_page = 3;
7}

und lassen daraus Quellcodes für die Zielsprache generieren, wo wir ein Objekt SearchRequest erhalten, das die Felder query, page_number und result_per_page enthält … und zusätzlich noch Methoden zu dessen Serialisierung und Deserialisierung.

Serialisierung

Ein Vorteil und gleichzeitig Nachteil ist das Fehlen von genauen Typeninformationen im serialisierten Byteblock. Damit wird nur sehr wenig Overhead produziert, doch es sind auch Doppeldeutigkeiten möglich.

Das makanteste Beispiel ist, dass viele Objekte sich als “leerer” Byteblock darstellen, wenn ihr Inhalt dem “Default-Zustand” des Objektes gleicht.
Besteht ein Objekt aus einem String, einem Integer und einem Boolean, wovon der String leer ist, der Integer auf 0 gesetzt und der Boolean false lautet, dann wird das Objekt als “0-Byte” Datenblock serialisiert.

Das bedeutet auch umgekehrt, dass aus einem 0-Byte Datenblock jedes beliebige Objekt in seinem Default-Zustand deserialisiert werden kann.

Wenn also mehrere Protobuf Objekte in einem Nachrichtenprotokoll gekapselt sind, muss darauf geachtet werden, dass jedes Objekt sauber vom nachfolgenden getrennt wird.

Streaming

In der Firma hatten wir neulich die Aufgabe, mehrere Protobuf Objekte hintereinander in einen Stream zu schreiben und dieser Stream sollte am andere Ende wieder in Objekte deserialisiert werden.

Tja … wie macht man das?

In C# oder Java Implementierungen gibt es dafür Methoden mit dem Namen parseDelimitedFrom und writeDelimitedTo. Diese schreiben zuerst die Länge des nachfolgenden Objektes in einen Puffer, gefolgt vom serialisierten Datenblock.
Ein “leeres” Objekt bestünde dann genau aus einer “0” für die Länge und sonst eben nichts mehr. Größere Objekte hätten dann entsprechend eine serialisierte Länge als Präfix vor den eigentlichen Daten.

Doch in der C++ Implementierung von Protobuf fehlen diese Methoden leider.

Zum Glück schrieb einer der Mitentwickler von Protobuf die simple Lösung in einem Stackoverflow-Posting, die in etwa so aussieht:

  1. Serialisierung:
    • Man erstellt einen google::protobuf::io::ZeroCopyOutputStream von seinem eigenen IO-Stream (wie. z.B. std::stringstream)
    • Nun instanziiert man einen google::protobuf::io::CodedOutputStream als Wrapper vom ZeroCopyOutputStream.
    • Jetzt holt man sich die Länge des Objekts in seiner serialisierten Form mit int msg_size = proto_message.ByteSize(); und schreibt sie mit coded_output.WriteVariant32(msg_size); in den Puffer.
    • Am Ende schreibt man die Objekt-Daten mit msg.SerializeToCodedStream(coded_output); ebenso in den Stream.
    • Nach der Freigabe der beiden Stream-Wrapper, sollte std::stringstream das gleich beinhalten, was writeDelimitedTo in anderen Sprachen schreiben würde.
  2. Deserialisierung:
    • Man erstellt z.B. einen google::protobuf::io::ZeroCopyInputStream als Wrapper aus einem std::istream oder gerne auch einen ArrayInputStream aus einem existierenden C-array bzw. std::vector.
    • Nun kapselt man den Wrapper in einen google::protobuf::io::CodedInputStream.
    • Jetzt kann man die Größe des folgenden Objektes mit coded_input.ReadVariant32(&msg_size); auslesen.
    • Mit google::protobuf::io::CodedInputStream::Limit limit = coded_input.PushLimit(msg_size); verbieten wir, dass die folgende Leseoperation mehr Bytes liest, als zu unserem Objekt gehört.
    • msg.ParseFromCodedStream(coded_input); liest jetzt bis zum Limit und befüllt unser Objekt mit den deserialisierten Daten.
    • Die Abfrage msg.ConsumedEntireMessage() sagt uns, ob das gesamte Objekt aus dem Stream gezogen werden konnte.
    • Und am Ende kann man mit coded_input.PopLimit(limit); das alte Leselimit (was vermutlich bei “unendlich” liegt) wieder zurücksetzen um den CodedInputStream für andere Operationen zu nutzen.

Was im Internet nicht (so genau) beschrieben wird …

Vorsicht!
Denn bei der Deserialisierung gibt es einige Stolpersteine. Zum einen gehen wir davon aus, dass der Input-Stream immer ein vollständiges Objekt beinhaltet. Das muss beim stückweisen Einlesen eines Datenpuffers (z.B. bei einer Netzwerkübertragung) nicht der Fall sein.

coded_input.ConsumedEntireMessage() gibt nämlich gerne auch mal true zurück, wenn im Puffer das Objekt an einer Stelle abgeschnitten wird, wo danach nur noch “optionale”, also möglicherweise leere Felder folgen.
In diesem Fall werden die Felder nämlich leer belassen und so getan, als hätte man schon das gesamte Objekt auslesen können.

Mein “quick-and-dirty” Workaround sah dann so aus, dass ich nach der Deserialisierung nochmals msg.ByteSize() aufgerufen habe, um es mit der zuvor gelesenen erwarteten Größe des Objektes zu vergleichen.

War dem nicht so fehlte etwas, was noch eingelesen werden musste.
Das hätte man natürlich auch anders erraten können, wenn man nämlich die Größe des original Raw-Puffers vor der Kapselung mit der erwarteten Objektgröße verglichen hätte.
However, msg.ByteSize() tut das im Nachhinein auch.

Auch verhält sich der CodedInputStream recht ungut, in dem er trotz gesetzter Limitierung am Originalstream weiterliest.

Man kann also nicht davon ausgehen, dass der Datenzeiger des Original-Streams genau auf das nächste Objekt zeigt, wenn ein CodedInputStream benutzt wird.

Ich musste am Ende dazu übergehen, bei einer stückweisen Datenübertragung mit einem Memorystream die Daten aufzufangen und dessen Kopie an den Google Code zu übergeben. Dort wurde dekodiert und am Ende stand fest, wie viele Bytes für eine Deserialisierung notwendig war.
Und eben diese Menge wurde dann aus dem Memorystream entfernt, bevor die Google Streams zurückgesetzt wurden und die nächste Deserialisierung eingeleitet wurde.

Es mag aber sein, dass es hierfür eine bessere Lösung gibt.
Wie auch immer … mein Ansatz scheint zu funktionieren.

Fazit

Naja, ich weiß nicht, ob ich Protobuf jetzt für “super” halten soll.
Es tut innerhalb seiner Spezifikation das, was es soll, wenn auch mit ein paar offenen Punkten in der C++ Implementierung.
Doch ich sehe aktuell keinen Grund diese Technologie privat einzusetzen.

Da schätze ich andere angepasstere Formen der Serialisierung eben mehr.

Doch die Tatsache, dass es ein Objekt-Austauschformat zwischen allen wichtigen Programmiersprachen gibt, ist auf jeden Fall ein großer Vorteil, der in vielen Projekten die Produktivität ordentlich steigern kann.