Protobuf im Stream
« | 16 Nov 2019 | »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
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:
- 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 vomZeroCopyOutputStream
. - Jetzt holt man sich die Länge des Objekts in seiner serialisierten Form
mit
int msg_size = proto_message.ByteSize();
und schreibt sie mitcoded_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, waswriteDelimitedTo
in anderen Sprachen schreiben würde.
- Man erstellt einen
- Deserialisierung:
- Man erstellt z.B. einen
google::protobuf::io::ZeroCopyInputStream
als Wrapper aus einemstd::istream
oder gerne auch einenArrayInputStream
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 denCodedInputStream
für andere Operationen zu nutzen.
- Man erstellt z.B. einen
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.