REMARQUE:Ce site est obsolète. Le site sera désactivé après le 31 janvier 2023 et le trafic redirigera vers le nouveau site à l'adresse https://protobuf.dev. En attendant, les mises à jour ne seront effectuées que sur protobuf.dev.

Encodage

Restez organisé à l'aide des collections Enregistrez et classez les contenus selon vos préférences.

Ce document décrit le format de fil de tampon de protocole, qui définit la manière dont votre message est envoyé sur le réseau et la quantité d'espace qu'il utilise sur le disque. Vous n'avez probablement pas besoin de comprendre cela pour utiliser des tampons de protocole dans votre application, mais ces informations sont utiles pour effectuer des optimisations.

Si vous connaissez déjà les concepts, mais souhaitez une référence, passez à la section Carte de référence condensée.

Protoscope est un langage très simple qui décrit les extraits du format électronique de bas niveau. Il nous permet de fournir une référence visuelle pour l'encodage de divers messages. La syntaxe de Protoscope se compose d'une séquence de jetons qui sont codées sur une séquence d'octets spécifique.

Par exemple, les accents graves correspondent à un littéral hexadécimal brut, tel que `70726f746f6275660a`. Elle est encodée en octets exacts marqués comme hexadécimals dans le littéral. Les guillemets désignent les chaînes UTF-8 comme "Hello, Protobuf!". Ce littéral est synonyme de `48656c6c6f2c2050726f746f62756621` (qui, si vous observez attentivement, est composé d'octets ASCII). Nous présenterons plus en détail le langage Protoscope à mesure que nous évoquerons certains aspects du format de communication.

L'outil Protoscope peut également vider des tampons de protocole encodés sous forme de texte. Consultez les données de test pour obtenir des exemples.

Un message simple

Imaginons que vous ayez la définition de message très simple suivante:

message Test1 {
  optional int32 a = 1;
}

Dans une application, vous créez un message Test1 et définissez a sur 150. Vous sérialisez ensuite le message dans un flux de sortie. Si vous pouviez examiner le message encodé, vous obtiendriez trois octets:

08 96 01

Jusqu'à présent, il est si petit et numérique, mais qu'est-ce que cela signifie ? Si vous utilisez l'outil Protoscope pour vider ces octets, vous obtenez quelque chose comme 1: 150. Comment sait-il qu'il s'agit du contenu du message ?

Variété de base 128

Les entiers de largeur variable, ou variables, sont au cœur du format de raccordement. Ils permettent l'encodage d'entiers non signés de 64 bits entre un et dix octets, et de petites valeurs avec moins d'octets.

Chaque octet de la variable est associé à un bit de continuité qui indique si l'octet qui suit fait partie de la variable. Il s'agit du bit (MSB) le plus significatif de l'octet (parfois également appelé bit de signe). Les 7 bits inférieurs constituent une charge utile. L'entier résultant est créé en ajoutant les charges utiles 7 bits de ses octets constitutifs.

Par exemple, voici le chiffre 1, encodé en `01`. Il s'agit d'un octet unique, et le MSB n'est donc pas défini:

0000 0001
^ msb

Voici le code 150, encodé au format `9601`. C'est un peu plus compliqué:

10010110 00000001
^ msb    ^ msb

Comment savoir qu'il s'agit de 150 ? Vous devez d'abord supprimer le MSB de chaque octet, car cela nous permet de savoir si nous avons atteint la fin du nombre (comme vous pouvez le constater, il est défini dans le premier octet, car il existe plusieurs octets dans la variable). Ensuite, nous concaténons les charges utiles 7 bits et l'interprétons comme un entier non signé de 64 bits de petite taille:

10010110 00000001        // Original inputs.
 0010110  0000001        // Drop continuation bits.
 0000001  0010110        // Put into little-endian order.
 10010110                // Concatenate.
 128 + 16 + 4 + 2 = 150  // Interpret as integer.

Étant donné que les variables sont très importantes pour les tampons de protocole, nous les appelons "entiers bruts" dans la syntaxe protoscope. 150 est identique à `9601`.

Structure du message

Un message de tampon de protocole est une série de paires clé-valeur. La version binaire d'un message utilise simplement le numéro du champ comme clé. Le nom et le type déclaré de chaque champ ne peuvent être déterminés qu'à la fin du décodage en référençant la définition du type de message (c'est-à-dire le fichier .proto). Protoscope n'a pas accès à ces informations et ne peut donc fournir que les numéros de champs.

Lorsqu'un message est encodé, chaque paire clé/valeur est transformée en un enregistrement composé du numéro de champ, d'un type de fil et d'une charge utile. Le type de fil indique à l'analyseur la taille de la charge utile après celle-ci. Cela permet aux anciens analyseurs d'ignorer les nouveaux champs qu'ils ne comprennent pas. Ce type de schéma est parfois appelé valeur de longueur de tag (TLV).

Il existe six types de fils: VARINT, I64, LEN, SGROUP, EGROUP et I32.

ID Nom Utilisation
0 VARIANTE int32, int64, uint32, uint64, sint32, sint64, bool, énumération
1 I64 fixed64, fixe66, double
2 LEN chaîne, octets, messages intégrés, champs répétés empaquetés
3 SGROUP début du groupe (obsolète)
4 EGROUP fin du groupe (obsolète)
5 I32 fixed32, fixe32, flottant

Le "tag" d'un enregistrement est encodé sous la forme d'une variable formée à partir du numéro de champ et du type de fil via la formule (field_number << 3) | wire_type. En d'autres termes, après avoir décodé la variable représentant un champ, les 3 bits bas nous indiquent le type de fil, et le reste de l'entier nous indique le numéro du champ.

Revenons à notre exemple simple. Vous savez maintenant que le premier nombre du flux est toujours une clé de type varint. Ici, il s'agit de `08`, ou (supprimer le fichier MSB):

000 1000

Prenez les trois derniers bits pour obtenir le type de fil (0), puis décalez de trois pour obtenir le numéro de champ (1). Le prototoscope représente un tag comme un entier suivi du signe deux-points et du type de fil. Nous pouvons donc écrire les octets ci-dessus en tant que 1:VARINT.

Comme le type de fil est 0 ou VARINT, nous savons qu'il faut décoder une variable pour obtenir la charge utile. Comme nous l'avons vu ci-dessus, les octets `9601` var-decode à 150, ce qui nous donne notre enregistrement. Nous pouvons l'écrire dans Protoscope en tant que 1:VARINT 150.

Le prototype peut déduire le type d'un tag s'il y a un espace blanc après :. Pour ce faire, il considère le jeton suivant et devine ce que vous vouliez signifier (les règles sont documentées en détail dans le fichier language.txt de Protoscope). Par exemple, dans 1: 150, il existe une variable immédiatement après le tag non typé. Protoscope déduit que son type est VARINT. Si vous écrivez 2: {}, il affiche { et devine LEN. Si vous écrivez 3: 5i32, il affiche I32, et ainsi de suite.

Autres types entiers

Bools et énumérations

Les Bools et les énumérations sont encodés comme s'ils étaient des int32. Les bools, en particulier, sont toujours encodés sous la forme `00` ou `01`. Dans Protoscope, false et true sont des alias pour ces chaînes d'octets.

Entiers signés

Comme vous l'avez vu dans la section précédente, tous les types de tampons de protocole associés au type de fil 0 sont encodés en tant que variables. Toutefois, les variables signées ne sont pas signées. Par conséquent, les différents types signés (sint32 et sint64 et int32 ou int64) encodent les entiers négatifs différemment.

Les types intN encodent des nombres négatifs en complément de deux, ce qui signifie que, en tant qu'entiers non signés de 64 bits, leur nombre de bits le plus élevé est défini. Par conséquent, cela signifie que les 10 octets doivent être utilisés. Par exemple, -2 est converti par protoscope en

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

Il s'agit du complément à deux de 2, défini en arithmétique non signée par ~0 - 2 + 1, où ~0 est l'entier 1-61 bits. C'est un exercice utile pour comprendre pourquoi cela génère autant de facteurs.

D'autre part, sintN utilise l'encodage "ZigZag" au lieu de l'encodage de deux pour encoder les entiers négatifs. Les entiers positifs n sont codés en 2 * n (les nombres pairs), tandis que les entiers négatifs -n sont codés en 2 * n + 1 (les nombres impairs). L'encodage "zig-zags" entre les nombres positifs et négatifs. Exemple :

Texte d'origine signé Codé en tant que
0 0
-1 1
1 2
-2 3
0x7fffffff 0xfffffffe
-0x80000000 0xffffffff

En utilisant quelques astuces, vous pouvez convertir n en représentation ZigZag à moindre coût:

n + n + (n < 0)

Ici, nous supposons que la valeur booléenne n < 0 est convertie en entier 1 si la valeur est "true" ou en 0 si la valeur est "false".

Lorsque sint32 ou sint64 est analysé, sa valeur est décodée à partir de la version signée d'origine.

Dans le protoscope, le suffixe d'un entier avec z permet de l'encoder en ZigZag. Par exemple, -500z est identique à la variable 1001.

Nombres non variables

Les types numériques non variables sont simples : double et fixed64 ont un type de fil I64, qui indique à l'analyseur qu'il doit s'attendre à recevoir un bloc de données fixe de huit octets. Nous pouvons spécifier un enregistrement double en écrivant 5: 25.4 ou un enregistrement fixed64 avec 6: 200i64. Dans les deux cas, l'omission d'un type de fil explicite déduit le type de fil I64.

De même, float et fixed32 sont de type I32, ce qui indique qu'ils doivent attendre quatre octets. Pour ce faire, vous devez ajouter un préfixe i32. 25.4i32 émettra quatre octets, tout comme 200i32. Les types de tags sont déduits comme suit : I32.

Enregistrements limités en termes de longueur

Les préfixes de longueur sont un autre concept important du format de communication. Le type de fil LEN présente une longueur dynamique, spécifiée par une variable immédiatement après le tag, suivie de la charge utile, comme d'habitude.

Prenons l'exemple de schéma de message suivant:

message Test2 {
  optional string b = 2;
}

Un enregistrement pour le champ b est une chaîne qui est encodée en LEN. Si nous définissons b sur "testing", nous l'encodons en tant qu'enregistrement LEN avec le champ numéro 2 contenant la chaîne ASCII "testing". Le résultat est `120774657374696e67`. Répartir les octets

12 07 [74 65 73 74 69 6e 67]

nous constatons que la balise `12` est 00010 010 ou 2:LEN. L'octet qui suit est la variable 7, et les sept octets suivants sont l'encodage UTF-8 de "testing".

Dans Protoscope, cela s'écrit 2:LEN 7 "testing". Cependant, il peut être incohérent de répéter la longueur de la chaîne (qui, dans le texte du protoscope, est déjà délimitée par des guillemets). L'encapsulation du contenu Protoscope entre accolades génère un préfixe de longueur pour celui-ci : {"testing"} est un raccourci pour 7 "testing". {} est toujours déduit par des champs comme un enregistrement LEN. Nous pouvons donc écrire cet enregistrement simplement sous la forme 2: {"testing"}.

Les champs bytes sont encodés de la même manière.

Sous-messages

Les champs de sous-message utilisent également le type de fil LEN. Voici une définition de message avec un message intégré de notre exemple de message d'origine, Test1:

message Test3 {
  optional Test1 c = 3;
}

Si le champ a de Test1 (par exemple, Test3 du champ c.a) est défini sur 150, nous obtenons 1a03089601. Diviser:

 1a 03 [08 96 01]

Les trois derniers octets (dans []) sont exactement les mêmes que dans notre premier exemple. Ces octets sont précédés d'un tag de type LEN, et leur longueur est de 3 exactement comme les chaînes sont encodées.

Dans Protoscope, les sous-messages sont assez succincts. 1a03089601 peut être écrit sous la forme 3: {1: 150}.

Éléments facultatifs et répétés

Les champs optional manquants sont faciles à encoder: nous laissons simplement l'enregistrement s'il n'est pas présent. Cela signifie que les protos "énormes" ne comportant que quelques champs sont peu creux.

Les champs repeated sont un peu plus compliqués. Les champs répétés ordinaires (et non empaquetés) émettent un enregistrement pour chaque élément du champ. Ainsi, si nous avons

message Test4 {
  optional string d = 4;
  repeated int32 e = 5;
}

et que nous construisons un message Test4 avec d défini sur "hello", et e défini sur 1, 2 et 3, cet élément peut être encodé en `220568656c6c6f280128022803` ou écrit en protoscope.

4: {"hello"}
5: 1
5: 2
5: 3

Toutefois, les enregistrements de e n'ont pas besoin d'apparaître de manière consécutive et peuvent être entrelacés avec d'autres champs. Seul l'ordre des enregistrements du même champ est conservé. Cela aurait donc aussi pu être encodé

5: 1
5: 2
4: {"hello"}
5: 3

Il n'y a pas de traitement spécial pour les oneof au format fil.

Dernière victoire

Normalement, un message encodé ne contiendra jamais plus d'une occurrence d'un champ non-repeated. Cependant, les analyseurs sont censés gérer le cas dans lequel ils le font. Pour les chaînes et les types numériques, si le même champ apparaît plusieurs fois, l'analyseur accepte la dernière valeur affichée. Pour les champs de message intégrés, l'analyseur fusionne plusieurs instances du même champ, comme avec la méthode Message::MergeFrom. En d'autres termes, tous les champs scalaires singuliers de la seconde instance remplacent ceux de l'ancien message, les messages intégrés au singulier sont fusionnés et les champs repeated sont concaténés. L'effet de ces règles est que l'analyse de la concaténation de deux messages encodés produit exactement le même résultat que si vous aviez analysé les deux messages séparément et fusionné les objets obtenus. Autrement dit:

MyMessage message;
message.ParseFromString(str1 + str2);

équivaut à:

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

Cette propriété est parfois utile, car elle vous permet de fusionner deux messages (par concaténation), même si vous ne connaissez pas leurs types.

Champs répétés compressés

À partir de la version 2.1.0, les champs repeated de type scalaire peuvent être déclarés comme "empaquetés". Dans proto2, cela est effectué avec [packed=true], mais dans proto3, il s'agit de la version par défaut.

Au lieu d'être encodés sous la forme d'un seul enregistrement par entrée, ils sont encodés sous la forme d'un seul enregistrement LEN contenant chaque élément concaténé. Pour décoder, les éléments sont décodés un par un depuis l'enregistrement LEN jusqu'à ce que la charge utile soit épuisée. Le début de l'élément suivant est déterminé par la longueur du précédent, qui dépend du type du champ.

Par exemple, imaginons que vous ayez le type de message:

message Test5 {
  repeated int32 f = 6 [packed=true];
}

Imaginons maintenant que vous construisiez un Test5, en fournissant les valeurs 3, 270 et 86 942 pour le champ répété f. Elle est encodée en `3206038e029ea705`, ou sous forme de texte Protoscope.

6: {3 270 86942}

Seuls les champs répétés de types numériques primitifs peuvent être déclarés "empaquetés". Il s'agit des types qui utilisent normalement les types de fils VARINT, I32 ou I64.

Bien qu'il n'y ait généralement aucune raison d'encoder plusieurs paires clé/valeur pour un champ répété compressé, les analyseurs doivent être prêts à accepter plusieurs paires clé/valeur. Dans ce cas, les charges utiles doivent être concaténées. Chaque paire doit contenir un certain nombre d'éléments. Voici un encodage valide du même message ci-dessus que les analyseurs doivent accepter:

6: {3 270}
6: {86942}

Les analyseurs de tampon de protocole doivent pouvoir analyser les champs répétés qui ont été compilés en tant que packed, comme s'ils n'étaient pas empaquetés, et inversement. Cela permet d'ajouter [packed=true] aux champs existants de manière rétrocompatible.

Maps

Les champs de mappage sont un raccourci pour un type particulier de champ répété. Si nous avons

message Test6 {
  map<string, int32> g = 7;
}

c'est la même chose que

message Test6 {
  message g_Entry {
    optional string key = 1;
    optional int32 value = 2;
  }
  repeated g_Entry g = 7;
}

Ainsi, les cartes sont encodées exactement comme un champ de message repeated, sous la forme d'une séquence d'enregistrements de type LEN, avec deux champs chacun.

Groupes

Les groupes sont une fonctionnalité obsolète qui ne doit pas être utilisée, mais qui restent au format électronique et méritent d'être mentionnés.

Un groupe est un peu comme un sous-message, mais il est délimité par des tags spéciaux plutôt que par un préfixe LEN. Chaque groupe d'un message comporte un numéro de champ utilisé sur ces balises spéciales.

Un groupe portant le numéro de champ 8 commence par une balise 8:SGROUP. Les enregistrements SGROUP ayant des charges utiles vides, tout cela indique le début du groupe. Une fois que tous les champs du groupe sont répertoriés, une balise 8:EGROUP correspondante indique sa fin. Les enregistrements EGROUP n'ayant pas de charge utile, 8:EGROUP est l'enregistrement complet. Les numéros de champs des groupes doivent correspondre. Si la chaîne 7:EGROUP s'affiche alors à l'emplacement prévu pour 8:EGROUP, le message est incorrect.

Protoscope propose une syntaxe pratique pour écrire des groupes. Au lieu d'écrire

8:SGROUP
  1: 2
  3: {"foo"}
8:EGROUP

Protoscope permet

8: !{
  1: 2
  3: {"foo"}
}

Les repères de début et de fin du groupe sont alors générés. La syntaxe !{} ne peut se produire qu'après une expression de tag non saisie, comme 8:.

Numéro de champ

Les numéros de champ peuvent être déclarés dans n'importe quel ordre dans un fichier .proto. L'ordre sélectionné n'a aucun effet sur la sérialisation des messages.

Lorsqu'un message est sérialisé, l'écriture de ses champs connus ou inconnus n'est pas garantie. L'ordre de sérialisation est un détail d'implémentation. Les détails d'une implémentation particulière peuvent changer à l'avenir. Par conséquent, les analyseurs de tampon de protocole doivent pouvoir analyser les champs dans n'importe quel ordre.

Conséquences

  • Ne partez pas du principe que la sortie d'octets d'un message sérialisé est stable. Cela est particulièrement vrai pour les messages dont les champs d'octets transitifs représentent d'autres messages de tampon de protocole sérialisés.
  • Par défaut, les appels répétés de sérialisation sur une même instance de message de tampon de protocole peuvent ne pas produire le même résultat d'octets. En d'autres termes, la sérialisation par défaut n'est pas déterministe.
    • La sérialisation déterministe garantit uniquement le même résultat d'octets pour un binaire particulier. La sortie d'octets peut varier selon les versions du binaire.
  • Les vérifications suivantes peuvent échouer pour une instance de message de tampon de protocole foo :
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
  • Voici quelques exemples de scénarios dans lesquels les messages de tampon de protocole équivalent logiquement foo et bar peuvent sérialiser sur différentes sorties d'octets :
    • bar est sérialisée par un ancien serveur qui considère certains champs comme inconnus.
    • bar est sérialisée par un serveur implémenté dans un langage de programmation différent et sérialise les champs dans un ordre différent.
    • bar comporte un champ qui est sérialisé de manière non déterministe.
    • bar comporte un champ qui stocke la sortie d'octets sérialisée d'un message de tampon de protocole, qui est sérialisée différemment.
    • bar est sérialisé par un nouveau serveur qui sérialise les champs dans un ordre différent en raison d'une modification de la mise en œuvre.
    • foo et bar sont des concaténations des mêmes messages individuels dans un ordre différent.

Carte de référence condensée

Vous trouverez ci-dessous les parties les plus importantes du format de filaire dans un format facile à référencer.

message    := (tag value)*

tag        := (field << 3) bit-or wire_type;
                encoded as varint
value      := varint      for wire_type == VARINT,
              i32         for wire_type == I32,
              i64         for wire_type == I64,
              len-prefix  for wire_type == LEN,
              <empty>     for wire_type == SGROUP or EGROUP

varint     := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;
                encoded as varints (sintN are ZigZag-encoded first)
i32        := sfixed32 | fixed32 | float;
                encoded as 4-byte little-endian;
                memcpy of the equivalent C types (u?int32_t, float)
i64        := sfixed64 | fixed64 | double;
                encoded as 8-byte little-endian;
                memcpy of the equivalent C types (u?int32_t, float)

len-prefix := size (message | string | bytes | packed);
                size encoded as varint
string     := valid UTF-8 string (e.g. ASCII);
                max 2GB of bytes
bytes      := any sequence of 8-bit bytes;
                max 2GB of bytes
packed     := varint* | i32* | i64*,
                consecutive values of the type specified in `.proto`

Consultez également la documentation de référence sur le langage du protoscope.

Clé

message := (tag value)*
Un message est encodé sous la forme d'une séquence de zéro ou plusieurs paires de balises et de valeurs.
tag := (field << 3) bit-or wire_type
Un tag est la combinaison d'un wire_type, stocké dans les trois bits les moins significatifs, et du numéro de champ défini dans le fichier .proto.
value := varint for wire_type == VARINT, ...
Une valeur est stockée différemment en fonction du wire_type spécifié dans le tag.
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
Vous pouvez utiliser varint pour stocker n'importe quel type de données listé.
i32 := sfixed32 | fixed32 | float
Vous pouvez stocker tous les types de données listés à l'aide de fixed32.
i64 := sfixed64 | fixed64 | double
Vous pouvez utiliser fixed64 pour stocker n'importe quel type de données listé.
len-prefix := size (message | string | bytes | packed)
Une valeur avec un préfixe de longueur est stockée sous forme de longueur (codée sous la forme d'une variable), puis l'un des types de données répertoriés.
string := valid UTF-8 string (e.g. ASCII)
Comme décrit, une chaîne doit utiliser l'encodage de caractères UTF-8. Une chaîne ne peut pas dépasser 2 Go.
bytes := any sequence of 8-bit bytes
Comme décrit, les octets peuvent stocker des types de données personnalisés d'une taille maximale de 2 Go.
packed := varint* | i32* | i64*
Utilisez le type de données packed lorsque vous stockez des valeurs consécutives du type décrit dans la définition du protocole. Le tag est supprimé des valeurs après le premier, ce qui permet d'amortir les coûts des tags à un par champ plutôt qu'à chaque élément.