So verwenden Sie die Protokollpuffer von Google in Python

Wenn Menschen, die verschiedene Sprachen sprechen, zusammenkommen und sprechen, versuchen sie, eine Sprache zu verwenden, die jeder in der Gruppe versteht.

Um dies zu erreichen, muss jeder seine Gedanken, die normalerweise in seiner Muttersprache sind, in die Sprache der Gruppe übersetzen. Diese „Kodierung und Dekodierung“ der Sprache führt jedoch zu einem Verlust an Effizienz, Geschwindigkeit und Präzision.

Das gleiche Konzept ist in Computersystemen und ihren Komponenten vorhanden. Warum sollten wir Daten in XML, JSON oder einem anderen für Menschen lesbaren Format senden, wenn wir nicht verstehen müssen, worüber sie direkt sprechen? Solange wir es noch in ein für Menschen lesbares Format übersetzen können, wenn dies ausdrücklich benötigt wird.

Protokollpuffer sind eine Möglichkeit, Daten vor dem Transport zu codieren, wodurch Datenblöcke effizient verkleinert und daher die Geschwindigkeit beim Senden erhöht werden. Es abstrahiert Daten in ein sprach- und plattformneutrales Format.

Inhaltsverzeichnis

  • Warum brauchen wir Protokollpuffer?
  • Was sind Protokollpuffer und wie funktionieren sie?
  • Protokollpuffer in Python
  • Schlussbemerkungen

Warum Protokollpuffer?

Der ursprüngliche Zweck von Protokollpuffern bestand darin, die Arbeit mit Anforderungs- / Antwortprotokollen zu vereinfachen. Vor ProtoBuf verwendete Google ein anderes Format, das eine zusätzliche Marshalling-Behandlung für die gesendeten Nachrichten erforderte.

Darüber hinaus mussten die Entwickler bei neuen Versionen des vorherigen Formats sicherstellen, dass neue Versionen verstanden werden, bevor sie alte ersetzen, was die Arbeit mühsam machte.

Dieser Aufwand motivierte Google, eine Benutzeroberfläche zu entwickeln, die genau diese Probleme löst.

Mit ProtoBuf können Änderungen am Protokoll vorgenommen werden, ohne die Kompatibilität zu beeinträchtigen. Außerdem können Server die Daten weitergeben und Lesevorgänge für die Daten ausführen, ohne deren Inhalt zu ändern.

Da das Format etwas selbstbeschreibend ist, wird ProtoBuf als Basis für die automatische Codegenerierung für Serializer und Deserializer verwendet.

Ein weiterer interessanter Anwendungsfall ist, wie Google ihn für kurzlebige Remote Procedure Calls (RPC) und zum dauerhaften Speichern von Daten in Bigtable verwendet. Aufgrund ihres speziellen Anwendungsfalls haben sie RPC-Schnittstellen in ProtoBuf integriert. Dies ermöglicht eine schnelle und unkomplizierte Generierung von Code-Stubs, die als Ausgangspunkt für die eigentliche Implementierung verwendet werden können. (Mehr zu ProtoBuf RPC.)

Andere Beispiele dafür, wo ProtoBuf nützlich sein kann, sind IoT-Geräte, die über Mobilfunknetze verbunden sind, in denen die Menge der gesendeten Daten gering gehalten werden muss, oder Anwendungen in Ländern, in denen hohe Bandbreiten noch selten sind. Das Senden von Nutzdaten in optimierten Binärformaten kann zu spürbaren Unterschieden bei den Betriebskosten und der Geschwindigkeit führen.

Durch die Verwendung der gzipKomprimierung in Ihrer HTTPS-Kommunikation können diese Metriken weiter verbessert werden.

Was sind Protokollpuffer und wie funktionieren sie?

Im Allgemeinen sind Protokollpuffer eine definierte Schnittstelle für die Serialisierung strukturierter Daten. Es definiert eine normalisierte Art der Kommunikation, völlig unabhängig von Sprachen und Plattformen.

Google bewirbt seinen ProtoBuf folgendermaßen:

Protokollpuffer sind Googles sprachneutraler, plattformneutraler, erweiterbarer Mechanismus zur Serialisierung strukturierter Daten - denken Sie an XML, aber kleiner, schneller und einfacher. Sie definieren, wie Ihre Daten einmal strukturiert werden sollen…

Die ProtoBuf-Schnittstelle beschreibt die Struktur der zu sendenden Daten. Nutzlaststrukturen werden in sogenannten Proto-Dateien als „Nachrichten“ definiert. Diese Dateien enden immer mit einem.protoErweiterung.

Die Grundstruktur einer todolist.proto- Datei sieht beispielsweise so aus. Wir werden uns im nächsten Abschnitt auch ein vollständiges Beispiel ansehen.

syntax = "proto3"; // Not necessary for Python, should still be declared to avoid name collisions // in the Protocol Buffers namespace and non-Python languages package protoblog; message TodoList { // Elements of the todo list will be defined here ... }

Diese Dateien werden dann verwendet, um mithilfe von Codegeneratoren im Protoc-Compiler Integrationsklassen oder Stubs für die Sprache Ihrer Wahl zu generieren. Die aktuelle Version Proto3 unterstützt bereits alle wichtigen Programmiersprachen. Die Community unterstützt viele weitere bei Open-Source-Implementierungen von Drittanbietern.

Generierte Klassen sind die Kernelemente von Protokollpuffern. Sie ermöglichen die Erstellung von Elementen durch Instanziieren neuer Nachrichten basierend auf den .protoDateien, die dann für die Serialisierung verwendet werden. Wir werden uns im nächsten Abschnitt genauer ansehen, wie dies mit Python gemacht wird.

Unabhängig von der Sprache für die Serialisierung werden die Nachrichten in ein nicht selbstbeschreibendes Binärformat serialisiert, das ohne die anfängliche Strukturdefinition ziemlich nutzlos ist.

Die Binärdaten können dann gespeichert, über das Netzwerk gesendet und auf andere Weise für von Menschen lesbare Daten wie JSON oder XML verwendet werden. Nach der Übertragung oder Speicherung kann der Byte-Stream mit jeder sprachspezifischen, kompilierten Protobuf-Klasse, die wir aus der .proto-Datei generieren , deserialisiert und wiederhergestellt werden .

Am Beispiel von Python könnte der Prozess ungefähr so ​​aussehen:

Zuerst erstellen wir eine neue Aufgabenliste und füllen sie mit einigen Aufgaben. Diese Aufgabenliste wird dann serialisiert und über das Netzwerk gesendet, in einer Datei gespeichert oder dauerhaft in einer Datenbank gespeichert.

Der gesendete Bytestream wird mithilfe der Analysemethode unserer sprachspezifischen, kompilierten Klasse deserialisiert.

Die meisten aktuellen Architekturen und Infrastrukturen, insbesondere Microservices, basieren auf REST-, WebSockets- oder GraphQL-Kommunikation. Wenn jedoch Geschwindigkeit und Effizienz entscheidend sind, können RPCs auf niedriger Ebene einen großen Unterschied machen.

Anstelle von Protokollen mit hohem Overhead können wir Daten schnell und kompakt zwischen den verschiedenen Entitäten in unseren Service verschieben, ohne viele Ressourcen zu verschwenden.

Aber warum wird es noch nicht überall verwendet?

Protokollpuffer sind etwas komplizierter als andere für Menschen lesbare Formate. Dies macht es vergleichsweise schwieriger, sie zu debuggen und in Ihre Anwendungen zu integrieren.

Die Iterationszeiten im Engineering nehmen ebenfalls tendenziell zu, da für Aktualisierungen der Daten die Protodateien vor der Verwendung aktualisiert werden müssen.

Es müssen sorgfältige Überlegungen angestellt werden, da ProtoBuf in vielen Fällen eine überentwickelte Lösung sein kann.

Welche Alternativen habe ich?

Several projects take a similar approach to Google’s Protocol Buffers.

Google’s Flatbuffers and a third party implementation, called Cap’n Proto, are more focused on removing the parsing and unpacking step, which is necessary to access the actual data when using ProtoBufs. They have been designed explicitly for performance-critical applications, making them even faster and more memory efficient than ProtoBuf.

When focusing on the RPC capabilities of ProtoBuf (used with gRPC), there are projects from other large companies like Facebook (Apache Thrift) or Microsoft (Bond protocols) that can offer alternatives.

Python and Protocol Buffers

Python already provides some ways of data persistence using pickling. Pickling is useful in Python-only applications. It's not well suited for more complex scenarios where data sharing with other languages or changing schemas is involved.

Protocol Buffers, in contrast, are developed for exactly those scenarios.

The .proto files, we’ve quickly covered before, allow the user to generate code for many supported languages.

To compile the .protofile to the language class of our choice, we use protoc, the proto compiler.

If you don’t have the protoc compiler installed, there are excellent guides on how to do that:

  • MacOS / Linux
  • Windows

Once we’ve installed protoc on our system, we can use an extended example of our todo list structure from before and generate the Python integration class from it.

syntax = "proto3"; // Not necessary for Python but should still be declared to avoid name collisions // in the Protocol Buffers namespace and non-Python languages package protoblog; // Style guide prefers prefixing enum values instead of surrounding // with an enclosing message enum TaskState { TASK_OPEN = 0; TASK_IN_PROGRESS = 1; TASK_POST_PONED = 2; TASK_CLOSED = 3; TASK_DONE = 4; } message TodoList { int32 owner_id = 1; string owner_name = 2; message ListItems { TaskState state = 1; string task = 2; string due_date = 3; } repeated ListItems todos = 3; } 

Let’s take a more detailed look at the structure of the .proto file to understand it.

In the first line of the proto file, we define whether we’re using Proto2 or 3. In this case, we’re using Proto3.

The most uncommon elements of proto files are the numbers assigned to each entity of a message. Those dedicated numbers make each attribute unique and are used to identify the assigned fields in the binary encoded output.

One important concept to grasp is that only values 1-15 are encoded with one less byte (Hex), which is useful to understand so we can assign higher numbers to the less frequently used entities. The numbers define neitherthe order of encoding nor the position of the given attribute in the encoded message.

The package definition helps prevent name clashes. In Python, packages are defined by their directory. Therefore providing a package attribute doesn’t have any effect on the generated Python code.

Please note that this should still be declared to avoid protocol buffer related name collisions and for other languages like Java.

Enumerations are simple listings of possible values for a given variable.

In this case, we define an Enum for the possible states of each task on the todo list.

We’ll see how to use them in a bit when we look at the usage in Python.

As we can see in the example, we can also nest messages inside messages.

If we, for example, want to have a list of todos associated with a given todo list, we can use the repeated keyword, which is comparable to dynamically sized arrays.

To generate usable integration code, we use the proto compiler which compiles a given .proto file into language-specific integration classes. In our case we use the --python-out argument to generate Python-specific code.

protoc -I=. --python_out=. ./todolist.proto

In the terminal, we invoke the protocol compiler with three parameters:

  1. -I: defines the directory where we search for any dependencies (we use . which is the current directory)
  2. --python_out: defines the location we want to generate a Python integration class in (again we use . which is the current directory)
  3. The last unnamed parameter defines the .proto file that will be compiled (we use the todolist.proto file in the current directory)

This creates a new Python file called _pb2.py. In our case, it is todolist_pb2.py. When taking a closer look at this file, we won’t be able to understand much about its structure immediately.

This is because the generator doesn’t produce direct data access elements, but further abstracts away the complexity using metaclasses and descriptors for each attribute. They describe how a class behaves instead of each instance of that class.

The more exciting part is how to use this generated code to create, build, and serialize data. A straightforward integration done with our recently generated class is seen in the following:

import todolist_pb2 as TodoList my_list = TodoList.TodoList() my_list.owner_id = 1234 my_list.owner_name = "Tim" first_item = my_list.todos.add() first_item.state = TodoList.TaskState.Value("TASK_DONE") first_item.task = "Test ProtoBuf for Python" first_item.due_date = "31.10.2019" print(my_list)

It merely creates a new todo list and adds one item to it. We then print the todo list element itself and can see the non-binary, non-serialized version of the data we just defined in our script.

owner_id: 1234 owner_name: "Tim" todos { state: TASK_DONE task: "Test ProtoBuf for Python" due_date: "31.10.2019" }

Each Protocol Buffer class has methods for reading and writing messages using a Protocol Buffer-specific encoding, that encodes messages into binary format.

Those two methods are SerializeToString() and ParseFromString().

import todolist_pb2 as TodoList my_list = TodoList.TodoList() my_list.owner_id = 1234 # ... with open("./serializedFile", "wb") as fd: fd.write(my_list.SerializeToString()) my_list = TodoList.TodoList() with open("./serializedFile", "rb") as fd: my_list.ParseFromString(fd.read()) print(my_list)

In the code example above, we write the Serialized string of bytes into a file using the wb flags.

Since we have already written the file, we can read back the content and Parse it using ParseFromString. ParseFromString calls on a new instance of our Serialized class using the rb flags and parses it.

If we serialize this message and print it in the console, we get the byte representation which looks like this.

b'\x08\xd2\t\x12\x03Tim\x1a(\x08\x04\x12\x18Test ProtoBuf for Python\x1a\n31.10.2019'

Note the b in front of the quotes. This indicates that the following string is composed of byte octets in Python.

If we directly compare this to, e.g., XML, we can see the impact ProtoBuf serialization has on the size.

 1234 Tim   TASK_DONE Test ProtoBuf for Python 31.10.2019   

The JSON representation, non-uglified, would look like this.

{ "todoList": { "ownerId": "1234", "ownerName": "Tim", "todos": [ { "state": "TASK_DONE", "task": "Test ProtoBuf for Python", "dueDate": "31.10.2019" } ] } }

Judging the different formats only by the total number of bytes used, ignoring the memory needed for the overhead of formatting it, we can of course see the difference.

But in addition to the memory used for the data, we also have 12 extra bytes in ProtoBuf for formatting serialized data. Comparing that to XML, we have 171 extra bytes in XML for formatting serialized data.

Without Schema, we need 136 extra bytes in JSON forformattingserialized data.

If we’re talking about several thousands of messages sent over the network or stored on disk, ProtoBuf can make a difference.

However, there is a catch. The platform Auth0.com created an extensive comparison between ProtoBuf and JSON. It shows that, when compressed, the size difference between the two can be marginal (only around 9%).

If you’re interested in the exact numbers, please refer to the full article, which gives a detailed analysis of several factors like size and speed.

An interesting side note is that each data type has a default value. If attributes are not assigned or changed, they will maintain the default values. In our case, if we don’t change the TaskState of a ListItem, it has the state of “TASK_OPEN” by default. The significant advantage of this is that non-set values are not serialized, saving additional space.

If we, for example, change the state of our task from TASK_DONE to TASK_OPEN, it will not be serialized.

owner_id: 1234 owner_name: "Tim" todos { task: "Test ProtoBuf for Python" due_date: "31.10.2019" }

b'\x08\xd2\t\x12\x03Tim\x1a&\x12\x18Test ProtoBuf for Python\x1a\n31.10.2019'

Final Notes

As we have seen, Protocol Buffers are quite handy when it comes to speed and efficiency when working with data. Due to its powerful nature, it can take some time to get used to the ProtoBuf system, even though the syntax for defining new messages is straightforward.

As a last note, I want to point out that there were/are discussions going on about whether Protocol Buffers are “useful” for regular applications. They were developed explicitly for problems Google had in mind.

If you have any questions or feedback, feel free to reach out to me on any social media like twitter or email :)