Criar mensagens do Protobuf para aplicativos .NET

Por James Newton-King e Mark Rendle

O gRPC usa o Protobuf como sua linguagem IDL. A IDL do Protobuf é um formato neutro de linguagem para especificar as mensagens enviadas e recebidas pelos serviços gRPC. As mensagens do Protobuf são definidas nos arquivos .proto. Este documento explica como os conceitos do Protobuf são mapeados para o .NET.

Mensagens de Protobuf

As mensagens são o objeto principal da transferência de dados no Protobuf. Elas são conceitualmente semelhantes às classes do .NET.

syntax = "proto3";

option csharp_namespace = "Contoso.Messages";

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

A definição de mensagem anterior especifica três campos como pares nome-valor. Assim como as propriedades em tipos .NET, cada campo tem um nome e um tipo. O tipo de campo pode ser um tipo de valor escalar do Protobuf, por exemplo, int32, ou outra mensagem.

O guia de estilo do Protobuf recomenda usar underscore_separated_names para nomes de campo. Novas mensagens do Protobuf criadas para aplicativos .NET devem seguir as diretrizes de estilo do Protobuf. As ferramentas do .NET geram automaticamente tipos .NET que usam padrões de nomenclatura do .NET. Por exemplo, um campo first_name do Protobuf gera uma propriedade FirstName do .NET.

Além de um nome, cada campo na definição de mensagem tem um número exclusivo. Os números de campo são usados para identificar campos quando a mensagem é serializada para o Protobuf. Serializar um número pequeno é mais rápido do que serializar o nome do campo inteiro. Como os números de campo identificam um campo, é importante tomar cuidado ao alterá-los. Para obter mais informações sobre como alterar mensagens do Protobuf, consulte Controle de versão de serviços gRPC.

Quando um aplicativo é criado, as ferramentas do Protobuf geram tipos .NET dos arquivos .proto. A mensagem Person gera uma classe .NET:

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

Para obter mais informações sobre mensagens do Protobuf, consulte o guia de linguagem do Protobuf.

Tipos de valor escalar

O Protobuf dá suporte a uma série de tipos de valor escalar nativos. A tabela a seguir lista todos eles com o tipo C# equivalente:

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

Os valores escalares sempre têm um valor padrão e não podem ser definidos como null. Essa restrição inclui string e ByteString, os quais são classes C#. string usa como padrão um valor de cadeia de caracteres vazio e ByteString usa como padrão um valor de bytes vazio. Tentar defini-los para null gera um erro.

Tipos de wrapper anuláveis podem ser usados para dar suporte a valores nulos.

Datas e horas

Os tipos escalares nativos não fornecem valores de data e hora, equivalentes a DateTimeOffset, DateTime e TimeSpan do .NET. Esses tipos podem ser especificados usando algumas das extensões de Tipos Conhecidos do Protobuf. Essas extensões fornecem suporte de geração de código e runtime a tipos de campo complexos nas plataformas compatíveis.

A tabela a seguir mostra os tipos de data e hora:

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

As propriedades geradas na classe de C# não são os tipos de data e hora do .NET. As propriedades usam as classes Timestamp e Duration no namespace Google.Protobuf.WellKnownTypes. Essas classes fornecem métodos de conversão entre DateTimeOffset, DateTime e 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();

Observação

O tipo Timestamp funciona com horários UTC. Os valores de DateTimeOffset sempre têm um deslocamento de zero e a propriedade DateTime.Kind é sempre DateTimeKind.Utc.

Tipos anuláveis

A geração de código de Protobuf para C# usa os tipos nativos, como int para int32. Portanto, os valores são sempre incluídos e não podem ser null.

Para valores que exigem null explícito, como ao usar int? no código de C#, os Tipos Conhecidos do Protobuf incluem wrappers compilados para tipos de C# anuláveis. Para usá-los, importe wrappers.proto para o arquivo .proto, como o código a seguir:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

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

Os tipos wrappers.proto não são expostos em propriedades geradas. O Protobuf os mapeia automaticamente para tipos anuláveis apropriados do .NET em mensagens de C#. Por exemplo, um campo google.protobuf.Int32Value gera uma propriedade int?. As propriedades de tipo de referência, como string e ByteString, não são alteradas, exceto null podem ser atribuídas a elas sem erro.

A tabela a seguir mostra a lista completa de tipos de wrapper com seu tipo de C# equivalente:

Tipo de C# Wrapper de tipo conhecido
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

Bytes

Há suporte para conteúdos binários no Protobuf com o tipo de valor escalar bytes. Uma propriedade gerada em C# usa ByteString como o tipo de propriedade.

Use ByteString.CopyFrom(byte[] data) para criar uma nova instância de uma matriz de bytes:

var data = await File.ReadAllBytesAsync(path);

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

Os dados ByteString são acessados diretamente usando ByteString.Span ou ByteString.Memory. Ou chame ByteString.ToByteArray() para converter uma instância de volta em uma matriz de bytes:

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

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

Decimais

O Protobuf não dá suporte nativo ao tipo decimal do .NET, apenas a double e float. Há uma discussão em andamento no projeto do Protobuf sobre a possibilidade de adicionar um tipo padrão decimal aos tipos conhecidos, com suporte da plataforma para linguagens e estruturas compatíveis com ele. Nada foi implementado ainda.

É possível criar uma definição de mensagem para representar o tipo decimal, que funciona para uma serialização segura entre clientes e servidores .NET. Mas os desenvolvedores de outras plataformas teriam que entender o formato que está sendo usado e implementar o próprio processamento dele.

Criando um tipo decimal personalizado para 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;
}

O campo nanos representa valores de 0.999_999_999 a -0.999_999_999. Por exemplo, o valor decimal1.5m seria representado como { units = 1, nanos = 500_000_000 }. É por isso que o campo nanos neste exemplo usa o tipo sfixed32, que codifica com mais eficiência do que int32 para valores maiores. Se o campo units for negativo, o campo nanos também deverá ser negativo.

Observação

Algoritmos adicionais estão disponíveis para codificação de valores decimal como cadeias de caracteres de bytes. O algoritmo usado por DecimalValue:

  • É fácil de entender.
  • Não é afetado por big-endian ou little-endian em diferentes plataformas.
  • Dá suporte a números decimais que variam de positivo 9,223,372,036,854,775,807.999999999 a negativo 9,223,372,036,854,775,808.999999999 com uma precisão máxima de nove casas decimais, que não é o intervalo completo de um decimal.

A conversão entre esse tipo e o tipo BCL decimal pode ser implementada em C# da seguinte maneira:

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

O código anterior:

  • Adiciona uma classe parcial para DecimalValue. A classe parcial é combinada com DecimalValue gerada a partir do arquivo .proto. A classe gerada declara as propriedades Units e Nanos.
  • Tem operadores implícitos para converter entre DecimalValue e o tipo de BCL decimal.

Coleções

Listas

As listas no Protobuf são especificadas usando a palavra-chave com prefixo repeated em um campo. O seguinte exemplo mostra como criar uma lista:

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

No código gerado, os campos repeated são representados pelo tipo genérico Google.Protobuf.Collections.RepeatedField<T>.

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

RepeatedField<T> implementa IList<T>. Portanto, você pode usar consultas LINQ ou convertê-lo em uma matriz ou em uma lista. As propriedades RepeatedField<T> não têm um setter público. Os itens devem ser adicionados à coleção existente.

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

Dicionários

O tipo IDictionary<TKey,TValue> do .NET é representado no Protobuf usando map<key_type, value_type>.

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

No código gerado do .NET, os campos map são representados pelo tipo genérico Google.Protobuf.Collections.MapField<TKey, TValue>. MapField<TKey, TValue> implementa IDictionary<TKey,TValue>. Assim como as propriedades repeated, as propriedades map não têm um setter público. Os itens devem ser adicionados à coleção existente.

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

Mensagens não estruturadas e condicionais

O Protobuf é um formato de mensagens de primeiro contrato. As mensagens de um aplicativo, incluindo seus campos e tipos, devem ser especificadas nos arquivos .proto quando o aplicativo é criado. O design de primeiro contrato do Protobuf é ótimo para impor o conteúdo da mensagem, mas pode limitar cenários em que um contrato estrito não é necessário:

  • Mensagens com conteúdos desconhecidos. Por exemplo, uma mensagem com um campo que pode conter qualquer mensagem.
  • Mensagens condicionais. Por exemplo, uma mensagem retornada de um serviço gRPC pode ser um resultado de êxito ou um resultado de erro.
  • Valores dinâmicos. Por exemplo, uma mensagem com um campo que contém uma coleção não estruturada de valores, semelhante a JSON.

O Protobuf oferece recursos e tipos de linguagem para dar suporte a esses cenários.

Qualquer

O tipo Any permite que você use mensagens como tipos inseridos sem ter sua definição .proto. Para usar o tipo Any, importe 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

Os campos oneof são um recurso de linguagem. O compilador manipula a palavra-chave oneof quando gera a classe de mensagem. Usar oneof para especificar uma mensagem de resposta que pode retornar um Person ou Error pode ter esta aparência:

message Person {
    // ...
}

message Error {
    // ...
}

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

Os campos dentro do conjunto oneof deverão ter números de campo exclusivos na declaração geral da mensagem.

Ao usar um oneof, o código de C# gerado incluirá uma enumeração que especificará qual dos campos foi definido. É possível testar a enumeração para descobrir qual campo está definido. Campos que não estiverem definidos retornarão null ou o valor padrão, em vez de gerar uma exceção.

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

Valor

O tipo Value representa um valor tipado dinamicamente. Pode ser null, um número, uma cadeia de caracteres, um booliano, um dicionário de valores (Struct) ou uma lista de valores (ValueList). Value é um tipo conhecido do Protobuf que usa o recurso oneof discutido anteriormente. Para usar o tipo Value, importe 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;
    // ...
}

Usar Value diretamente pode ser verbose. Uma maneira alternativa de usar Value é com o suporte interno do Protobuf para mapear mensagens para JSON. Os tipos JsonFormatter e JsonWriter do Protobuf podem ser usados com qualquer mensagem do Protobuf. Value é particularmente adequado para ser convertido de e para JSON.

Esse é o equivalente a JSON do código anterior:

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

Recursos adicionais