Comment utiliser les tampons de protocole de Google en Python
Lorsque des personnes parlant des langues différentes se réunissent et parlent, elles essaient d’utiliser une langue que tous les membres du groupe comprennent.
Pour y parvenir, chacun doit traduire ses pensées, généralement exprimées dans sa langue maternelle, dans la langue du groupe. Cet « encodage et décodage » du langage entraîne cependant une perte d'efficacité, de rapidité et de précision.
Le même concept est présent dans les systèmes informatiques et leurs composants. Pourquoi devrions-nous envoyer des données au format XML, JSON ou tout autre format lisible par l'homme si nous n'avons pas besoin de comprendre directement de quoi ils parlent ? Tant que nous pouvons toujours le traduire dans un format lisible par l'homme si cela est explicitement nécessaire.
Les tampons de protocole sont un moyen d'encoder les données avant le transport, ce qui réduit efficacement les blocs de données et augmente donc la vitesse d'envoi. Il extrait les données dans un format neutre en termes de langage et de plate-forme.
Table des matières
- Pourquoi avons-nous besoin de tampons de protocole ?
- Que sont les tampons de protocole et comment fonctionnent-ils ?
- Tampons de protocole en Python
- Notes finales
Pourquoi des tampons de protocole ?
L’objectif initial de Protocol Buffers était de simplifier le travail avec les protocoles de requête/réponse. Avant ProtoBuf, Google utilisait un format différent qui nécessitait une gestion supplémentaire du marshaling pour les messages envoyés.
En plus de cela, les nouvelles versions du format précédent obligeaient les développeurs à s'assurer que les nouvelles versions sont comprises avant de remplacer les anciennes, ce qui rendait le travail difficile.
Cette surcharge a motivé Google à concevoir une interface qui résout précisément ces problèmes.
ProtoBuf permet d'introduire des modifications dans le protocole sans rompre la compatibilité. De plus, les serveurs peuvent transmettre les données et exécuter des opérations de lecture sur les données sans modifier leur contenu.
Étant donné que le format est quelque peu auto-descriptif, ProtoBuf est utilisé comme base pour la génération automatique de code pour les sérialiseurs et les désérialiseurs.
Un autre cas d'utilisation intéressant est la façon dont Google l'utilise pour les appels de procédure à distance (RPC) de courte durée et pour stocker de manière persistante des données dans Bigtable. En raison de leur cas d'utilisation spécifique, ils ont intégré les interfaces RPC dans ProtoBuf. Cela permet une génération rapide et simple de stub de code qui peut être utilisé comme point de départ pour la mise en œuvre réelle. (En savoir plus sur ProtoBuf RPC.)
D'autres exemples où ProtoBuf peut être utile sont pour les appareils IoT connectés via des réseaux mobiles dans lesquels la quantité de données envoyées doit rester faible ou pour les applications dans les pays où les bandes passantes élevées sont encore rares. L’envoi de charges utiles dans des formats binaires optimisés peut entraîner des différences notables en termes de coût et de vitesse d’exploitation.
L'utilisation de la compression gzip
dans votre communication HTTPS peut encore améliorer ces métriques.
Que sont les tampons de protocole et comment fonctionnent-ils ?
De manière générale, les Protocol Buffers sont une interface définie pour la sérialisation des données structurées. Il définit une manière normalisée de communiquer, totalement indépendante des langages et des plateformes.
Google annonce son ProtoBuf comme ceci :
Les tampons de protocole sont le mécanisme extensible de Google, indépendant du langage et de la plate-forme, pour sérialiser les données structurées : pensez au XML, mais plus petit, plus rapide et plus simple. Vous définissez comment vous souhaitez que vos données soient structurées une fois…
L'interface ProtoBuf décrit la structure des données à envoyer. Les structures de charge utile sont définies comme des « messages » dans ce qu'on appelle des Proto-Files. Ces fichiers se terminent toujours par une extension .proto
.
Par exemple, la structure de base d'un fichier todolist.proto ressemble comme ça. Nous examinerons également un exemple complet dans la section suivante.
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
...
}
Ces fichiers sont ensuite utilisés pour générer des classes d'intégration ou des stubs pour le langage de votre choix à l'aide de générateurs de code au sein du compilateur de protocoles. La version actuelle, Proto3, prend déjà en charge tous les principaux langages de programmation. La communauté en prend en charge bien d’autres dans les implémentations open source tierces.
Les classes générées sont les éléments centraux des tampons de protocole. Ils permettent la création d'éléments en instanciant de nouveaux messages, basés sur les fichiers .proto
, qui sont ensuite utilisés pour la sérialisation. Nous verrons en détail comment cela est réalisé avec Python dans la section suivante.
Indépendamment du langage de sérialisation, les messages sont sérialisés dans un format binaire non auto-descriptif qui est plutôt inutile sans la définition de structure initiale.
Les données binaires peuvent ensuite être stockées, envoyées sur le réseau et utilisées de toute autre manière lisible par l'homme comme JSON ou XML. Après la transmission ou le stockage, le flux d'octets peut être désérialisé et restauré à l'aide de n'importe classe protobuf compilée spécifique au langage que nous générons à partir du fichier .proto.
En utilisant Python comme exemple, le processus pourrait ressembler à ceci :
Tout d’abord, nous créons une nouvelle liste de tâches et la remplissons de quelques tâches. Cette liste de tâches est ensuite sérialisée et envoyée sur le réseau, enregistrée dans un fichier ou stockée de manière persistante dans une base de données.
Le flux d'octets envoyé est désérialisé à l'aide de la méthode d'analyse de notre classe compilée spécifique au langage.
La plupart des architectures et infrastructures actuelles, en particulier les microservices, sont basées sur la communication REST, WebSockets ou GraphQL. Cependant, lorsque la vitesse et l’efficacité sont essentielles, les RPC de bas niveau peuvent faire une énorme différence.
Au lieu de protocoles à surcharge élevée, nous pouvons utiliser un moyen rapide et compact pour déplacer les données entre les différentes entités dans notre service sans gaspiller beaucoup de ressources.
Mais pourquoi n’est-il pas encore utilisé partout ?
Les tampons de protocole sont un peu plus compliqués que les autres formats lisibles par l'homme. Cela les rend comparativement plus difficiles à déboguer et à intégrer dans vos applications.
Les temps d'itération en ingénierie ont également tendance à augmenter puisque les mises à jour des données nécessitent la mise à jour des fichiers prototypes avant utilisation.
Des considérations minutieuses doivent être prises car ProtoBuf pourrait être une solution trop sophistiquée dans de nombreux cas.
Quelles alternatives ai-je ?
Plusieurs projets adoptent une approche similaire aux tampons de protocole de Google.
Les Flatbuffers de Google et une implémentation tierce, appelée Cap'n Proto, se concentrent davantage sur la suppression de l'étape d'analyse et de décompression, qui est nécessaire pour accéder aux données réelles lors de l'utilisation de ProtoBufs. Ils ont été conçus explicitement pour les applications critiques en termes de performances, ce qui les rend encore plus rapides et plus économes en mémoire que ProtoBuf.
Lorsque l'on se concentre sur les capacités RPC de ProtoBuf (utilisées avec gRPC), il existe des projets d'autres grandes entreprises comme Facebook (Apache Thrift) ou Microsoft (protocoles Bond) qui peuvent proposer des alternatives.
Python et tampons de protocole
Python propose déjà des moyens de persistance des données à l'aide du décapage. Le décapage est utile dans les applications Python uniquement. Il n'est pas bien adapté aux scénarios plus complexes impliquant le partage de données avec d'autres langages ou la modification de schémas.
Les tampons de protocole, en revanche, sont développés exactement pour ces scénarios.
Le .proto
, que nous avons rapidement abordés auparavant, permettent à l'utilisateur de générer du code pour de nombreuses langues prises en charge.
Pour compiler le fichier .proto
dans la classe de langage de notre choix, nous utilisons protoc le compilateur proto.
Si vous n'avez pas installé le compilateur de protocole, il existe d'excellents guides sur la façon de procéder :
- MacOS/Linux
- les fenêtres
Une fois que nous avons installé le protocole sur notre système, nous pouvons utiliser un exemple étendu de notre structure de liste de tâches d'avant et générer la classe d'intégration Python à partir de celui-ci.
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;
}
Examinons plus en détail la structure du fichier .proto
pour le comprendre.
Dans la première ligne du fichier proto, nous définissons si nous utilisons Proto2 ou 3. Dans ce cas, nous utilisons Proto3.
Les éléments les plus rares des fichiers proto sont les numéros attribués à chaque entité d'un message. Ces numéros dédiés rendent chaque attribut unique et sont utilisés pour identifier les champs attribués dans la sortie codée en binaire.
Un concept important à comprendre est que seules les valeurs 1 à 15 sont codées avec un octet de moins (Hex), ce qui est utile à comprendre afin de pouvoir attribuer des nombres plus élevés aux entités les moins fréquemment utilisées. Les nombres ne définissent ni l'ordre d'encodage ni la position de l'attribut donné dans le message codé.
La définition du package permet d'éviter les conflits de noms. En Python, les packages sont définis par leur répertoire. Par conséquent, fournir un attribut de package n’a aucun effet sur le code Python généré.
Veuillez noter que cela doit toujours être déclaré pour éviter les collisions de noms liées au tampon de protocole et pour d'autres langages comme Java.
Les énumérations sont de simples listes de valeurs possibles pour une variable donnée.
Dans ce cas, nous définissons une Enum pour les états possibles de chaque tâche de la liste de tâches.
Nous verrons comment les utiliser dans un instant lorsque nous regardons l'utilisation en Python.
Comme nous pouvons le voir dans l'exemple, nous pouvons également imbriquer des messages dans des messages.
Si nous voulons, par exemple, avoir une liste de tâches associées à un élément donné todo list, nous pouvons utiliser le mot-clé répété , qui est comparable aux tableaux de taille dynamique.
Pour générer du code d'intégration utilisable, nous utilisons le compilateur proto qui compile un fichier .proto donné en classes d'intégration spécifiques au langage. Dans notre cas, nous utilisons l'argument --python-out pour générer du code spécifique à Python.
protocole -I=. --python_out=. ./todolist.proto
Dans le terminal, nous invoquons le compilateur de protocole avec trois paramètres :
- -I : définit le répertoire dans lequel nous recherchons les éventuelles dépendances (nous utilisons . qui est le répertoire courant)
- --python_out : définit l'emplacement dans lequel nous voulons générer une classe d'intégration Python (encore une fois, nous utilisons . qui est le répertoire courant)
- Le dernier paramètre sans nom définit le fichier .proto qui sera compilé (on utilise le fichier todolist.proto dans le répertoire courant)
Cela crée un nouveau fichier Python appelé
En effet, le générateur ne produit pas d'éléments d'accès direct aux données, mais élimine davantage la complexité en utilisant des métaclasses et des descripteurs pour chaque attribut. Ils décrivent comment une classe se comporte au lieu de chaque instance de cette classe.
La partie la plus intéressante est de savoir comment utiliser ce code généré pour créer, construire et sérialiser des données. Une intégration simple réalisée avec notre classe récemment générée est présentée ci-dessous :
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)
Il crée simplement une nouvelle liste de tâches et y ajoute un élément. Nous imprimons ensuite l'élément de liste de tâches lui-même et pouvons voir la version non binaire et non sérialisée des données que nous venons de définir dans notre script.
owner_id: 1234
owner_name: "Tim"
todos {
state: TASK_DONE
task: "Test ProtoBuf for Python"
due_date: "31.10.2019"
}
Chaque classe Protocol Buffer possède des méthodes pour lire et écrire des messages à l'aide d'un codage spécifique à Protocol Buffer, qui code les messages au format binaire.
Ces deux méthodes sont SerializeToString()
et 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)
Dans l'exemple de code ci-dessus, nous écrivons la chaîne d'octets sérialisée dans un fichier à l'aide des indicateurs wb.
Puisque nous avons déjà écrit le fichier, nous pouvons relire le contenu et l'analyser à l'aide de ParseFromString. ParseFromString appelle une nouvelle instance de notre classe Serialized en utilisant les indicateurs rb et l'analyse.
Si nous sérialisons ce message et l'imprimons dans la console, nous obtenons la représentation en octets qui ressemble à ceci.
b'\x08\xd2\t\x12\x03Tim\x1a(\x08\x04\x12\x18Test ProtoBuf pour Python\x1a\n31.10.2019'
Notez le b devant les guillemets. Cela indique que la chaîne suivante est composée d'octets en Python.
Si nous comparons cela directement, par exemple, à XML, nous pouvons voir l'impact de la sérialisation ProtoBuf sur la taille.
<todolist>
<owner_id>1234</owner_id>
<owner_name>Tim</owner_name>
<todos>
<todo>
<state>TASK_DONE</state>
<task>Test ProtoBuf for Python</task>
<due_date>31.10.2019</due_date>
</todo>
</todos>
</todolist>
La représentation JSON, non améliorée, ressemblerait à ceci.
{
"todoList": {
"ownerId": "1234",
"ownerName": "Tim",
"todos": [
{
"state": "TASK_DONE",
"task": "Test ProtoBuf for Python",
"dueDate": "31.10.2019"
}
]
}
}
En jugeant les différents formats uniquement par le nombre total d'octets utilisés, en ignorant la mémoire nécessaire pour le formatage, nous pouvons bien sûr voir la différence.
Mais en plus de la mémoire utilisée pour les données, nous disposent également de 12 octets supplémentaires dans ProtoBuf pour formater les données sérialisées. En comparant cela au XML, nous avons 171 octets supplémentaires en XML pour le formatage des données sérialisées.
Sans schéma, nous avons besoin de 136 octets supplémentaires en JSON pour le formatage des données sérialisées.
S’il s’agit de plusieurs milliers de messages envoyés sur le réseau ou stockés sur disque, ProtoBuf peut faire la différence.
Cependant, il y a un hic. La plateforme Auth0.com a créé une comparaison approfondie entre ProtoBuf et JSON. Il montre que, une fois compressée, la différence de taille entre les deux peut être marginale (seulement environ 9 %).
Si vous êtes intéressé par les chiffres exacts, veuillez vous référer à l’article complet, qui donne une analyse détaillée de plusieurs facteurs comme la taille et la vitesse.
Une remarque intéressante est que chaque type de données a une valeur par défaut. Si les attributs ne sont pas attribués ou modifiés, ils conserveront les valeurs par défaut. Dans notre cas, si nous ne modifions pas le TaskState d'un ListItem, il a l'état de « TASK_OPEN » par défaut. L'avantage majeur de ceci est que les valeurs non définies ne sont pas sérialisées, ce qui permet d'économiser de l'espace supplémentaire.
Si nous changeons, par exemple, l'état de notre tâche de TASK_DONE à TASK_OPEN, elle ne sera pas sérialisée.
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 pour Python\x1a\n31.10.2019'
Notes finales
Comme nous l'avons vu, les tampons de protocole sont très pratiques en termes de rapidité et d'efficacité lorsque l'on travaille avec des données. En raison de sa nature puissante, l'adaptation au système ProtoBuf peut prendre un certain temps, même si la syntaxe de définition de nouveaux messages est simple.
Pour terminer, je tiens à souligner qu'il y a eu/sont des discussions en cours pour savoir si les tampons de protocole sont « utiles » pour les applications régulières. Ils ont été développés explicitement pour les problèmes que Google avait en tête.
Si vous avez des questions ou des commentaires, n'hésitez pas à me contacter sur n'importe quel réseau social comme Twitter ou par e-mail :)