Sådan bruges Googles protokolbuffere i Python

Når folk, der taler forskellige sprog, mødes og snakker, prøver de at bruge et sprog, som alle i gruppen forstår.

For at opnå dette skal alle oversætte deres tanker, som normalt findes på deres modersmål, til gruppens sprog. Denne "kodning og afkodning" af sprog fører imidlertid til tab af effektivitet, hastighed og præcision.

Det samme koncept er til stede i computersystemer og deres komponenter. Hvorfor skal vi sende data i XML, JSON eller ethvert andet menneskeligt læsbart format, hvis vi ikke har brug for at forstå, hvad de taler om direkte? Så længe vi stadig kan oversætte det til et menneskeligt læsbart format, hvis det udtrykkeligt er nødvendigt.

Protokolbuffere er en måde at kode data inden transport, hvilket effektivt krymper datablokke og derfor øger hastigheden, når de sendes. Det abstraherer data i et sprog- og platformneutralt format.

Indholdsfortegnelse

  • Hvorfor har vi brug for protokolbuffere?
  • Hvad er protokolbuffere, og hvordan fungerer de?
  • Protokolbuffere i Python
  • Afsluttende noter

Hvorfor protokolbuffere?

Det oprindelige formål med protokolbuffere var at forenkle arbejdet med anmodnings- / svarprotokoller. Før ProtoBuf brugte Google et andet format, som krævede yderligere håndtering af marskalering for de sendte meddelelser.

Derudover krævede nye versioner af det tidligere format, at udviklerne sørgede for, at nye versioner blev forstået, før de gamle blev udskiftet, hvilket gør det besværligt at arbejde med.

Denne overhead motiverede Google til at designe en grænseflade, der løser netop disse problemer.

ProtoBuf tillader ændringer af protokollen at blive introduceret uden at bryde kompatibilitet. Servere kan også videregive dataene og udføre læseoperationer på dataene uden at ændre deres indhold.

Da formatet er noget selvbeskrivende, bruges ProtoBuf som en base til automatisk kodegenerering til Serializers og Deserializers.

En anden interessant brugssag er, hvordan Google bruger det til kortvarige Remote Procedure Calls (RPC) og til vedvarende at gemme data i Bigtable. På grund af deres specifikke brugstilfælde integrerede de RPC-grænseflader i ProtoBuf. Dette giver mulighed for hurtig og ligefrem generering af kodestub, der kan bruges som udgangspunkt for den faktiske implementering. (Mere om ProtoBuf RPC.)

Andre eksempler på, hvor ProtoBuf kan være nyttigt, er til IoT-enheder, der er forbundet via mobilnetværk, hvor mængden af ​​sendte data skal holdes lille eller til applikationer i lande, hvor høj båndbredde stadig er sjælden. Afsendelse af nyttelast i optimerede, binære formater kan føre til mærkbare forskelle i driftsomkostninger og hastighed.

Brug af gzipkomprimering i din HTTPS-kommunikation kan forbedre disse metrics yderligere.

Hvad er protokolbuffere, og hvordan fungerer de?

Generelt er protokolbuffere en defineret grænseflade til serialisering af strukturerede data. Den definerer en normaliseret måde at kommunikere på, helt uafhængig af sprog og platforme.

Google annoncerer sin ProtoBuf sådan:

Protokolbuffere er Googles sprogneutrale, platformneutrale, udvidelige mekanisme til serialisering af strukturerede data - tænk XML, men mindre, hurtigere og enklere. Du definerer, hvordan du ønsker, at dine data skal struktureres en gang ...

ProtoBuf-grænsefladen beskriver strukturen på de data, der skal sendes. Nyttelaststrukturer er defineret som "meddelelser" i det, der kaldes Proto-Files. Disse filer slutter altid med en.protoudvidelse.

For eksempel ser den grundlæggende struktur for en todolist.proto- fil sådan ud. Vi vil også se på et komplet eksempel i det næste afsnit.

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 ... }

Disse filer bruges derefter til at generere integrationsklasser eller stubs til det valgte sprog ved hjælp af kodegeneratorer i protoc-kompilatoren. Den aktuelle version, Proto3, understøtter allerede alle de store programmeringssprog. Fællesskabet understøtter mange flere i tredjeparts open source-implementeringer.

Genererede klasser er kerneelementerne i protokolbuffere. De tillader oprettelse af elementer ved at instantere nye meddelelser baseret på .protofilerne, som derefter bruges til serialisering. Vi ser på, hvordan dette gøres med Python i detaljer i det næste afsnit.

Uafhængigt af sproget til serialisering serieres meddelelserne i et ikke-selvbeskrivende binært format, der er temmelig ubrugeligt uden den oprindelige strukturdefinition.

De binære data kan derefter gemmes, sendes over netværket og bruges på enhver anden måde, som mennesker kan læse, som JSON eller XML. Efter transmission eller lagring kan byte-stream deserialiseres og gendannes ved hjælp af en hvilken som helst sprogspecifik, kompileret protobuf-klasse, vi genererer fra .proto-filen.

Ved hjælp af Python som et eksempel kunne processen se sådan ud:

Først opretter vi en ny todo-liste og udfylder den med nogle opgaver. Denne todo-liste serialiseres derefter og sendes over netværket, gemmes i en fil eller vedvarende gemmes i en database.

Den sendte byte-strøm deserialiseres ved hjælp af analysemetoden i vores sprogspecifikke, kompilerede klasse.

De fleste nuværende arkitekturer og infrastrukturer, især mikrotjenester, er baseret på REST-, WebSockets- eller GraphQL-kommunikation. Når hastighed og effektivitet er afgørende, kan RPC'er på lavt niveau dog gøre en enorm forskel.

I stedet for høje overheadprotokoller kan vi bruge en hurtig og kompakt måde at flytte data mellem de forskellige enheder ind i vores service uden at spilde mange ressourcer.

Men hvorfor bruges det ikke overalt endnu?

Protokolbuffere er lidt mere komplicerede end andre, menneskeligt læsbare formater. Dette gør dem sammenligneligt sværere at debugge og integrere i dine applikationer.

Iterationstider inden for teknik har også en tendens til at stige, da opdateringer i dataene kræver opdatering af protofiler før brug.

Der skal tages nøje overvejelser, da ProtoBuf i mange tilfælde kan være en overkonstrueret løsning.

Hvilke alternativer har jeg?

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

Som vi har set, er protokolbuffere ret praktiske, når det kommer til hastighed og effektivitet, når du arbejder med data. På grund af sin stærke natur kan det tage noget tid at vænne sig til ProtoBuf-systemet, selvom syntaksen til at definere nye meddelelser er ligetil.

Som en sidste bemærkning vil jeg påpege, at der var / er diskussioner i gang om, hvorvidt protokolbuffere er “nyttige” til almindelige applikationer. De blev udviklet eksplicit til problemer, Google havde i tankerne.

Hvis du har spørgsmål eller feedback, er du velkommen til at kontakte mig på alle sociale medier som twitter eller e-mail :)