Creare messaggi Protobuf per le app .NET

Di James Newton-King e Mark Rendle

gRPC usa Protobuf come IDL (Interface Definition Language). Protobuf IDL è un formato indipendente dalla lingua per specificare i messaggi inviati e ricevuti dai servizi gRPC. I messaggi Protobuf sono definiti nei .proto file. Questo documento illustra il mapping dei concetti protobuf a .NET.

Messaggi protobuf

I messaggi sono l'oggetto di trasferimento dati principale in Protobuf. Sono concettualmente simili alle classi .NET.

syntax = "proto3";

option csharp_namespace = "Contoso.Messages";

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

La definizione del messaggio precedente specifica tre campi come coppie nome-valore. Come le proprietà sui tipi .NET, ogni campo ha un nome e un tipo. Il tipo di campo può essere un tipo di valore scalare Protobuf, ad esempio int32, o un altro messaggio.

La guida di stile Protobuf consiglia di usare underscore_separated_names per i nomi dei campi. I nuovi messaggi Protobuf creati per le app .NET devono seguire le linee guida per lo stile Protobuf. Gli strumenti .NET generano automaticamente tipi .NET che usano standard di denominazione .NET. Ad esempio, un first_name campo Protobuf genera una FirstName proprietà .NET.

Oltre a un nome, ogni campo nella definizione del messaggio ha un numero univoco. I numeri di campo vengono usati per identificare i campi quando il messaggio viene serializzato in Protobuf. La serializzazione di un numero ridotto è più veloce rispetto alla serializzazione dell'intero nome del campo. Poiché i numeri di campo identificano un campo, è importante prestare attenzione quando vengono modificati. Per altre informazioni sulla modifica dei messaggi Protobuf, vedere Controllo delle versioni dei servizi gRPC.

Quando viene compilata un'app, gli strumenti Protobuf generano tipi .NET dai .proto file. Il Person messaggio genera una classe .NET:

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

Per altre informazioni sui messaggi Protobuf, vedere la guida al linguaggio Protobuf.

Tipi valore scalari

Protobuf supporta un intervallo di tipi valore scalari nativi. Nella tabella seguente sono elencate tutte con il tipo C# equivalente:

Tipo Protobuf Tipo 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

I valori scalari hanno sempre un valore predefinito e non possono essere impostati su null. Questo vincolo include string e ByteString quali sono classi C#. string il valore predefinito è un valore stringa vuoto e ByteString il valore predefinito è un valore di byte vuoto. Il tentativo di impostarli per null genera un errore.

I tipi wrapper nullable possono essere usati per supportare valori Null.

Date e ore

I tipi scalari nativi non forniscono valori di data e ora, equivalenti a . NET è DateTimeOffset, DateTimee TimeSpan. Questi tipi possono essere specificati usando alcune estensioni dei tipi noti di Protobuf. Queste estensioni forniscono il supporto di generazione e runtime del codice per i tipi di campo complessi nelle piattaforme supportate.

La tabella seguente illustra i tipi di data e ora:

Tipo .NET Tipo noto 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;
}  

Le proprietà generate nella classe C# non sono i tipi di data e ora .NET. Le proprietà usano le Timestamp classi e Duration nello spazio dei Google.Protobuf.WellKnownTypes nomi . Queste classi forniscono metodi per la conversione in e da DateTimeOffset, DateTimee 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();

Nota

Il Timestamp tipo funziona con le ore UTC. DateTimeOffset i valori hanno sempre un offset pari a zero e la DateTime.Kind proprietà è sempre DateTimeKind.Utc.

Tipi nullable

La generazione di codice Protobuf per C# usa i tipi nativi, ad esempio int per int32. I valori sono quindi sempre inclusi e non possono essere null.

Per i valori che richiedono espliciti null, ad esempio l'uso int? nel codice C#, i tipi noti di Protobuf includono wrapper compilati in tipi C# nullable. Per usarli, importare wrappers.proto nel .proto file, come nel codice seguente:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

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

wrappers.proto I tipi non vengono esposti nelle proprietà generate. Protobuf esegue automaticamente il mapping a tipi nullable .NET appropriati nei messaggi C#. Ad esempio, un google.protobuf.Int32Value campo genera una int? proprietà . Le proprietà del tipo di riferimento come string e ByteString sono invariate, tranne null che possono essere assegnate senza errori.

La tabella seguente mostra l'elenco completo dei tipi wrapper con il tipo C# equivalente:

Tipo C# Wrapper di tipo noto
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

Byte

I payload binari sono supportati in Protobuf con il bytes tipo di valore scalare. Una proprietà generata in C# usa ByteString come tipo di proprietà.

Usare ByteString.CopyFrom(byte[] data) per creare una nuova istanza da una matrice di byte:

var data = await File.ReadAllBytesAsync(path);

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

ByteString l'accesso ai dati viene eseguito direttamente tramite ByteString.Span o ByteString.Memory. In alternativa, chiamare ByteString.ToByteArray() per convertire nuovamente un'istanza in una matrice di byte:

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

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

Decimali

Protobuf non supporta in modo nativo il tipo .NET decimal , solo double e float. Nel progetto Protobuf è in corso una discussione sulla possibilità di aggiungere un tipo decimale standard ai tipi noti, con il supporto della piattaforma per linguaggi e framework che lo supportano. Non è ancora stato implementato alcun elemento.

È possibile creare una definizione di messaggio per rappresentare il decimal tipo che funziona per la serializzazione sicura tra client .NET e server. Tuttavia, gli sviluppatori su altre piattaforme dovranno comprendere il formato usato e implementare la propria gestione.

Creazione di un tipo decimale personalizzato per 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;
}

Il nanos campo rappresenta i valori da 0.999_999_999 a -0.999_999_999. Ad esempio, il decimal valore 1.5m verrebbe rappresentato come { units = 1, nanos = 500_000_000 }. Questo è il motivo per cui il nanos campo in questo esempio usa il sfixed32 tipo , che codifica in modo più efficiente rispetto int32 ai valori più grandi. Se il units campo è negativo, anche il nanos campo deve essere negativo.

Nota

Sono disponibili algoritmi aggiuntivi per la codifica decimal dei valori come stringhe di byte. Algoritmo usato da DecimalValue:

  • Facilità di comprensione
  • Non è influenzato da big-endian o little-endian su piattaforme diverse.
  • Supporta numeri decimali compresi tra positivi 9,223,372,036,854,775,807.999999999 e negativi 9,223,372,036,854,775,808.999999999 con una precisione massima di nove cifre decimali, che non è l'intervallo completo di un oggetto decimal.

La conversione tra questo tipo e il tipo BCL decimal potrebbe essere implementata in C# come segue:

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);
        }
    }
}

Il codice precedente:

  • Aggiunge una classe parziale per DecimalValue. La classe parziale viene combinata con DecimalValue generata dal .proto file . La classe generata dichiara le Units proprietà e Nanos .
  • Dispone di operatori impliciti per la conversione tra DecimalValue e il tipo BCL decimal .

Raccolte

Elenchi

Gli elenchi in Protobuf vengono specificati usando la repeated parola chiave prefisso in un campo. L'esempio seguente illustra come creare un elenco:

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

Nel codice generato i repeated campi sono rappresentati dal Google.Protobuf.Collections.RepeatedField<T> tipo generico.

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

RepeatedField<T> implementa IList<T>. È quindi possibile usare query LINQ o convertirla in una matrice o in un elenco. RepeatedField<T> le proprietà non hanno un setter pubblico. Gli elementi devono essere aggiunti alla raccolta esistente.

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);

Dizionari

Il tipo .NET IDictionary<TKey,TValue> è rappresentato in Protobuf usando map<key_type, value_type>.

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

Nel codice .NET generato i map campi sono rappresentati dal Google.Protobuf.Collections.MapField<TKey, TValue> tipo generico. MapField<TKey, TValue> implementa IDictionary<TKey,TValue>. Come repeated le proprietà, map le proprietà non hanno un setter pubblico. Gli elementi devono essere aggiunti alla raccolta esistente.

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);

Messaggi non strutturati e condizionali

Protobuf è un formato di messaggistica contract-first. I messaggi di un'app, inclusi i relativi campi e tipi, devono essere specificati nei .proto file al momento della compilazione dell'app. La progettazione contract-first di Protobuf è ideale per applicare il contenuto dei messaggi, ma può limitare gli scenari in cui non è necessario un contratto rigoroso:

  • Messaggi con payload sconosciuti. Ad esempio, un messaggio con un campo che può contenere qualsiasi messaggio.
  • Messaggi condizionali. Ad esempio, un messaggio restituito da un servizio gRPC potrebbe essere un risultato positivo o un risultato di errore.
  • Valori dinamici. Ad esempio, un messaggio con un campo che contiene una raccolta non strutturata di valori, simile a JSON.

Protobuf offre funzionalità e tipi di linguaggio per supportare questi scenari.

Qualsiasi

Il Any tipo consente di usare i messaggi come tipi incorporati senza avere la relativa .proto definizione. Per usare il Any tipo , importare 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

oneof i campi sono una funzionalità del linguaggio. Il compilatore gestisce la oneof parola chiave quando genera la classe messaggio. L'uso oneof di per specificare un messaggio di risposta che potrebbe restituire o PersonError potrebbe essere simile al seguente:

message Person {
    // ...
}

message Error {
    // ...
}

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

I campi all'interno del oneof set devono avere numeri di campo univoci nella dichiarazione complessiva del messaggio.

Quando si usa oneof, il codice C# generato include un'enumerazione che specifica quale dei campi è stato impostato. È possibile testare l'enumerazione per trovare il campo impostato. I campi che non sono impostati restituiscono null o il valore predefinito, anziché generare un'eccezione.

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.");
}

Valore

Il Value tipo rappresenta un valore tipizzato in modo dinamico. Può essere null, un numero, una stringa, un valore booleano, un dizionario di valori (Struct) o un elenco di valori (ValueList). Value è un tipo noto protobuf che usa la funzionalità descritta oneof in precedenza. Per usare il Value tipo , importare 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'uso Value diretto può essere dettagliato. Un modo alternativo per usare Value è il supporto predefinito di Protobuf per il mapping dei messaggi a JSON. I tipi e JsonWriter protobuf JsonFormatter possono essere usati con qualsiasi messaggio Protobuf. Value è particolarmente adatto per essere convertito in e da JSON.

Questo è l'equivalente JSON del codice precedente:

// 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);

Risorse aggiuntive