Share via


Créer des messages Protobuf pour les applications .NET

Par James Newton-King et Mark Rendle

gRPC utilise Protobuf comme langage IDL (Interface Definition Language). Protobuf IDL est un format de langage neutre permettant de spécifier les messages envoyés et reçus par les services gRPC. Les messages Protobuf sont définis dans des fichiers .proto. Ce document explique comment les concepts Protobuf sont mappés à .NET.

Messages Protobuf

Les messages sont le principal objet de transfert de données dans Protobuf. Ils sont conceptuellement similaires aux classes .NET.

syntax = "proto3";

option csharp_namespace = "Contoso.Messages";

message Person {
    int32 id = 1;
    string first_name = 2;
    string last_name = 3;
}  

La définition de message précédente spécifie trois champs sous forme de paires nom-valeur. Comme les propriétés sur les types .NET, chaque champ a un nom et un type. Le type de champ peut être un type de valeur scalaire Protobuf, par exemple int32, ou un autre message.

Le guide de style Protobuf recommande d’utiliser underscore_separated_names pour les noms de champs. Les nouveaux messages Protobuf créés pour les applications .NET doivent suivre les instructions relatives au style Protobuf. Les outils .NET génèrent automatiquement des types .NET qui utilisent des normes de nommage .NET. Par exemple, un champ Protobuf first_name génère une propriété .NET FirstName.

En plus d’un nom, chaque champ de la définition du message a un numéro unique. Les numéros de champ sont utilisés pour identifier les champs lorsque le message est sérialisé dans Protobuf. La sérialisation d’un petit nombre est plus rapide que la sérialisation du nom de champ entier. Étant donné que les numéros de champ identifient un champ, il est important de faire attention lors de leur modification. Pour plus d’informations sur la modification des messages Protobuf, consultez Gestion des versions des services gRPC.

Lorsqu’une application est générée, les outils Protobuf génèrent des types .NET à partir de fichiers .proto. Le message Person génère une classe .NET :

public class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Pour plus d’informations sur les messages Protobuf, consultez le guide de langue Protobuf.

Types de valeurs scalaires

Protobuf prend en charge une série de types de valeurs scalaires natifs. Le tableau suivant les répertorie tous avec leur type C# équivalent :

Type Protobuf Type C#
double double
float float
int32 int
int64 long
uint32 uint
uint64 ulong
sint32 int
sint64 long
fixed32 uint
fixed64 ulong
sfixed32 int
sfixed64 long
bool bool
string string
bytes ByteString

Les valeurs scalaires ont toujours une valeur par défaut et ne peuvent pas être définies sur null. Cette contrainte inclut string et ByteString qui sont des classes C#. string prend par défaut une valeur de chaîne vide et ByteString prend par défaut une valeur d’octets vides. La tentative de les définir sur null génère une erreur.

Les types de wrapper pouvant accepter la valeur Null peuvent être utilisés pour prendre en charge des valeurs Null.

Dates et heures

Les types scalaires natifs ne fournissent pas de valeurs de date et d’heure, équivalents aux DateTimeOffset, DateTime et TimeSpan de .NET. Ces types peuvent être spécifiés à l’aide de certaines extensions types connus de Protobuf. Ces extensions prennent en charge la génération de code et le runtime pour les types de champs complexes sur les plateformes prises en charge.

Le tableau suivant présente les types de date et d’heure :

Type .NET Type connu Protobuf
DateTimeOffset google.protobuf.Timestamp
DateTime google.protobuf.Timestamp
TimeSpan google.protobuf.Duration
syntax = "proto3";

import "google/protobuf/duration.proto";  
import "google/protobuf/timestamp.proto";

message Meeting {
    string subject = 1;
    google.protobuf.Timestamp start = 2;
    google.protobuf.Duration duration = 3;
}  

Les propriétés générées dans la classe C# ne sont pas les types de date et d’heure .NET. Les propriétés utilisent les classes Timestamp et Duration dans l’espace de noms Google.Protobuf.WellKnownTypes. Ces classes fournissent des méthodes de conversion en et à partir de DateTimeOffset, DateTime et TimeSpan.

// Create Timestamp and Duration from .NET DateTimeOffset and TimeSpan.
var meeting = new Meeting
{
    Time = Timestamp.FromDateTimeOffset(meetingTime), // also FromDateTime()
    Duration = Duration.FromTimeSpan(meetingLength)
};

// Convert Timestamp and Duration to .NET DateTimeOffset and TimeSpan.
var time = meeting.Time.ToDateTimeOffset();
var duration = meeting.Duration?.ToTimeSpan();

Notes

Le type Timestamp fonctionne avec les heures UTC. Les valeurs DateTimeOffset ont toujours un décalage de zéro, et la propriété DateTime.Kind est toujours DateTimeKind.Utc.

Types Nullable

La génération de code Protobuf pour C# utilise les types natifs, tels que int pour int32. Par conséquent, les valeurs sont toujours incluses et ne peuvent pas être null.

Pour les valeurs qui exigent explicitement null, comme l’utilisation de int? dans votre code C#, les « types connus » de Protobuf incluent des wrappers compilés en types C# pouvant accepter la valeur Null. Pour les utiliser, importez wrappers.proto dans votre fichier .proto, comme le code suivant :

syntax = "proto3";

import "google/protobuf/wrappers.proto";

message Person {
    // ...
    google.protobuf.Int32Value age = 5;
}

Les types wrappers.proto ne sont pas exposés dans les propriétés générées. Protobuf les mappe automatiquement aux types nullables .NET appropriés dans les messages C#. Par exemple, un champ google.protobuf.Int32Value génère une propriété int?. Les propriétés de type de référence telles que string et ByteString sont inchangées, sauf que null peut leur être attribuée sans erreur.

Le tableau suivant présente la liste complète des types de wrapper avec leur type C# équivalent :

Type C# Wrapper de type connu
bool? google.protobuf.BoolValue
double? google.protobuf.DoubleValue
float? google.protobuf.FloatValue
int? google.protobuf.Int32Value
long? google.protobuf.Int64Value
uint? google.protobuf.UInt32Value
ulong? google.protobuf.UInt64Value
string google.protobuf.StringValue
ByteString google.protobuf.BytesValue

Octets

Les charges utiles binaires sont prises en charge dans Protobuf avec le type de valeur scalaire bytes. Une propriété générée en C# utilise ByteString comme type de propriété.

Utilisez ByteString.CopyFrom(byte[] data) pour créer un instance à partir d’un groupe d’octets :

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = ByteString.CopyFrom(data);

Les données ByteString sont accessibles directement à l’aide de ByteString.Span ou ByteString.Memory. Vous pouvez également appeler ByteString.ToByteArray() pour convertir une instance en groupe d’octets :

var payload = await client.GetPayload(new PayloadRequest());

await File.WriteAllBytesAsync(path, payload.Data.ToByteArray());

Décimales

Protobuf ne prend pas en charge en mode natif le type .NET decimal, juste double et float. Il y a un débat en cours dans le projet Protobuf sur la possibilité d’ajouter un type décimal standard aux types connus, avec prise en charge des plateformes pour les langages et les infrastructures qui le prennent en charge. Rien n’a encore été mis en œuvre.

Il est possible de créer une définition de message pour représenter le type decimal qui fonctionne pour la sérialisation sécurisée entre les clients et les serveurs .NET. Mais les développeurs sur d’autres plateformes doivent comprendre le format utilisé et implémenter leur propre gestion.

Création d’un type décimal personnalisé pour Protobuf

package CustomTypes;

// Example: 12345.6789 -> { units = 12345, nanos = 678900000 }
message DecimalValue {

    // Whole units part of the amount
    int64 units = 1;

    // Nano units of the amount (10^-9)
    // Must be same sign as units
    sfixed32 nanos = 2;
}

Le champ nanos représente les valeurs de 0.999_999_999 à -0.999_999_999. Par exemple, la valeur decimal1.5m serait représentée comme { units = 1, nanos = 500_000_000 }. C’est pourquoi le champ nanos dans cet exemple utilise le type sfixed32, qui encode plus efficacement que int32 pour les valeurs plus volumineuses. Si le champ units est négatif, le champ nanos doit également être négatif.

Notes

Des algorithmes supplémentaires sont disponibles pour l’encodage des valeurs decimal sous forme de chaînes d’octets. L’algorithme utilisé par DecimalValue :

  • Est facile à comprendre.
  • N’est pas affecté par big-endian ou little-endian sur différentes plateformes.
  • Prend en charge les nombres décimaux allant de positif 9,223,372,036,854,775,807.999999999 à négatif 9,223,372,036,854,775,808.999999999 avec une précision maximale de neuf décimales, ce qui n’est pas la plage complète d’un decimal.

La conversion entre ce type et le type BCL decimal peut être implémentée en C# comme suit :

namespace CustomTypes
{
    public partial class DecimalValue
    {
        private const decimal NanoFactor = 1_000_000_000;
        public DecimalValue(long units, int nanos)
        {
            Units = units;
            Nanos = nanos;
        }

        public static implicit operator decimal(CustomTypes.DecimalValue grpcDecimal)
        {
            return grpcDecimal.Units + grpcDecimal.Nanos / NanoFactor;
        }

        public static implicit operator CustomTypes.DecimalValue(decimal value)
        {
            var units = decimal.ToInt64(value);
            var nanos = decimal.ToInt32((value - units) * NanoFactor);
            return new CustomTypes.DecimalValue(units, nanos);
        }
    }
}

Le code précédent :

  • Ajoute une classe partielle pour DecimalValue. La classe partielle est combinée avec DecimalValue généré à partir du fichier.proto. La classe générée déclare les propriétés Units et Nanos .
  • A des opérateurs implicites pour la conversion entre DecimalValue et le type BCL decimal.

Collections

Listes

Les listes dans Protobuf sont spécifiées à l’aide du mot clé de préfixe repeated sur un champ. L'exemple suivant montre comment créer une liste :

message Person {
    // ...
    repeated string roles = 8;
}

Dans le code généré, les champs repeated sont représentés par le type générique Google.Protobuf.Collections.RepeatedField<T>.

public class Person
{
    // ...
    public RepeatedField<string> Roles { get; }
}

L'objet RepeatedField<T> implémente l'objet IList<T>. Vous pouvez donc utiliser des requêtes LINQ ou les convertir en tableau ou en liste. Les propriétés RepeatedField<T> n’ont pas de setter public. Des éléments doivent être ajoutés à la collection existante.

var person = new Person();

// Add one item.
person.Roles.Add("user");

// Add all items from another collection.
var roles = new [] { "admin", "manager" };
person.Roles.Add(roles);

Dictionnaires

Le type .NET IDictionary<TKey,TValue> est représenté dans Protobuf à l’aide de map<key_type, value_type>.

message Person {
    // ...
    map<string, string> attributes = 9;
}

Dans le code . NET généré, les champs map sont représentés par le type générique Google.Protobuf.Collections.MapField<TKey, TValue>. L'objet MapField<TKey, TValue> implémente l'objet IDictionary<TKey,TValue>. Comme les propriétés repeated, les propriétés map n’ont pas de setter public. Des éléments doivent être ajoutés à la collection existante.

var person = new Person();

// Add one item.
person.Attributes["created_by"] = "James";

// Add all items from another collection.
var attributes = new Dictionary<string, string>
{
    ["last_modified"] = DateTime.UtcNow.ToString()
};
person.Attributes.Add(attributes);

Messages non structurés et conditionnels

Protobuf est un format de messagerie « contrat en premier ». Les messages d’une application, y compris leurs champs et leurs types, doivent être spécifiés dans les fichiers .proto lorsque l’application est générée. La conception « contrat en premier » de Protobuf est excellente pour appliquer le contenu des messages, mais peut limiter les scénarios où un contrat strict n’est pas obligatoire :

  • Messages avec des charges utiles inconnues. Par exemple, un message avec un champ qui peut contenir n’importe quel message.
  • Messages conditionnels. Par exemple, un message retourné à partir d’un service gRPC peut être un résultat de réussite ou un résultat d’erreur.
  • Valeurs dynamiques. Par exemple, un message avec un champ qui contient une collection non structurée de valeurs, similaire à JSON.

Protobuf offre des fonctionnalités et des types de langage pour prendre en charge ces scénarios.

Quelconque

Le type Any vous permet d’utiliser des messages en tant que types incorporés sans avoir leur définition .proto. Pour utiliser le type Any, importez any.proto.

import "google/protobuf/any.proto";

message Status {
    string message = 1;
    google.protobuf.Any detail = 2;
}
// Create a status with a Person message set to detail.
var status = new ErrorStatus();
status.Detail = Any.Pack(new Person { FirstName = "James" });

// Read Person message from detail.
if (status.Detail.Is(Person.Descriptor))
{
    var person = status.Detail.Unpack<Person>();
    // ...
}

Oneof

Les champs oneof sont une fonctionnalité de langage. Le compilateur gère le mot clé oneof lorsqu’il génère la classe de message. L’utilisation de oneof pour spécifier un message de réponse qui peut renvoyer un Person ou un Error peut ressembler à ceci :

message Person {
    // ...
}

message Error {
    // ...
}

message ResponseMessage {
  oneof result {
    Error error = 1;
    Person person = 2;
  }
}

Les champs de l’ensemble oneof doivent avoir des numéros de champ uniques dans la déclaration de message globale.

Lorsque vous utilisez oneof, le code C# généré inclut une énumération qui spécifie les champs qui ont été définis. Vous pouvez tester l’énumération pour déterminer le champ défini. Les champs qui ne sont pas définis retournent null ou la valeur par défaut, plutôt que de lever une exception.

var response = await client.GetPersonAsync(new RequestMessage());

switch (response.ResultCase)
{
    case ResponseMessage.ResultOneofCase.Person:
        HandlePerson(response.Person);
        break;
    case ResponseMessage.ResultOneofCase.Error:
        HandleError(response.Error);
        break;
    default:
        throw new ArgumentException("Unexpected result.");
}

Valeur

Le type Value représente une valeur typée dynamiquement. Il peut s’agir de null, d’un nombre, d’une chaîne, d’un booléen, d’un dictionnaire de valeurs (Struct) ou d’une liste de valeurs (ValueList). Value est un « type connu » de Protobuf qui utilise la fonctionnalité oneof décrite précédemment. Pour utiliser le type Value, importez struct.proto.

import "google/protobuf/struct.proto";

message Status {
    // ...
    google.protobuf.Value data = 3;
}
// Create dynamic values.
var status = new Status();
status.Data = Value.ForStruct(new Struct
{
    Fields =
    {
        ["enabled"] = Value.ForBool(true),
        ["metadata"] = Value.ForList(
            Value.ForString("value1"),
            Value.ForString("value2"))
    }
});

// Read dynamic values.
switch (status.Data.KindCase)
{
    case Value.KindOneofCase.StructValue:
        foreach (var field in status.Data.StructValue.Fields)
        {
            // Read struct fields...
        }
        break;
    // ...
}

L’utilisation directe de Value peut être détaillée. Une autre façon d’utiliser Value consiste à utiliser la prise en charge intégrée de Protobuf pour le mappage des messages à JSON. Les types de Protobuf JsonFormatter et JsonWriter peuvent être utilisés avec n’importe quel message Protobuf. Value convient particulièrement à la conversion vers et à partir de JSON.

Il s’agit de l’équivalent JSON du code précédent :

// Create dynamic values from JSON.
var status = new Status();
status.Data = Value.Parser.ParseJson(@"{
    ""enabled"": true,
    ""metadata"": [ ""value1"", ""value2"" ]
}");

// Convert dynamic values to JSON.
// JSON can be read with a library like System.Text.Json or Newtonsoft.Json
var json = JsonFormatter.Default.Format(status.Data);
var document = JsonDocument.Parse(json);

Ressources supplémentaires