OBSERVAÇÃO:este site foi descontinuado. O site será desativado após 31 de janeiro de 2023, e o tráfego será redirecionado para o novo site em https://protobuf.dev. Enquanto isso, as atualizações serão feitas apenas para protobuf.dev.

Noções básicas do buffer de protocolo: C#

Mantenha tudo organizado com as coleções Salve e categorize o conteúdo com base nas suas preferências.

Neste tutorial, apresentamos uma introdução básica do programador em C# ao trabalhar com buffers de protocolo usando a versão proto3 da linguagem de buffers de protocolo. Ao criar um aplicativo de exemplo simples, ele mostra como

  • Defina os formatos das mensagens em um arquivo .proto.
  • Use o compilador de buffer de protocolo.
  • Usar a API de buffer de protocolo do C# para gravar e ler mensagens.

Este não é um guia abrangente para usar buffers de protocolo em C#. Para informações mais detalhadas de referência, consulte o Guia da linguagem do buffer de protocolo, a Referência da API C# , o Guia de código gerado do C# e a Referência de codificação.

Por que usar buffers de protocolo?

O exemplo que vamos usar é um aplicativo de "agenda de endereços" muito simples que pode ler e gravar os detalhes de contato das pessoas de e para um arquivo. Cada pessoa na agenda tem um nome, ID, endereço de e-mail e número de telefone para contato.

Como serializar e recuperar dados estruturados como esse? Há algumas maneiras de resolver esse problema:

  • Use a serialização binária .NET com System.Runtime.Serialization.Formatters.Binary.BinaryFormatter e as classes associadas. Isso acaba sendo muito frágil devido a mudanças caras em termos de tamanho de dados em alguns casos. Ela também não funciona muito bem se você precisa compartilhar dados com aplicativos escritos para outras plataformas.
  • Você pode inventar uma maneira ad-hoc para codificar os itens de dados em uma única string, como codificar 4 ints como "12:3:-23:67". Essa é uma abordagem simples e flexível, embora ela exija a codificação do código e a análise única, e a análise impõe um pequeno custo em tempo de execução. Isso funciona melhor para codificar dados muito simples.
  • Serialize os dados para XML. Essa abordagem pode ser muito atraente, já que o XML é (um pouco legível) e há bibliotecas de vinculação para muitas linguagens. Essa pode ser uma boa opção se você quiser compartilhar dados com outros aplicativos/projetos. No entanto, o XML é muito usado em termos de espaço, e a codificação/decodificação pode impor uma enorme penalidade de desempenho nos aplicativos. Além disso, navegar em uma árvore XML DOM é consideravelmente mais complicado do que navegar por campos simples de uma classe.

Buffers de protocolo são a solução flexível, eficiente e automatizada que resolve exatamente esse problema. Com buffers de protocolo, você grava uma descrição de .proto da estrutura de dados que quer armazenar. A partir daí, o compilador de buffer de protocolo cria uma classe que implementa a codificação automática e a análise dos dados do buffer de protocolo com um formato binário eficiente. A classe gerada fornece getters e setters para os campos que compõem um buffer de protocolo e cuida dos detalhes da leitura e gravação do buffer de protocolo como uma unidade. É importante ressaltar que o formato de buffer de protocolo suporta a ideia de estender o formato ao longo do tempo de forma que o código ainda possa ler dados codificados com o formato antigo.

Onde encontrar o código de exemplo

Nosso exemplo é um aplicativo de linha de comando para gerenciar um arquivo de dados do catálogo de endereços, codificado usando buffers de protocolo. O comando AddressBook (consulte Program.cs) pode adicionar uma nova entrada ao arquivo de dados ou analisar o arquivo e imprimir os dados no console.

Veja o exemplo completo no diretório de exemplos e no diretório csharp/src/AddressBook do repositório do GitHub.

Como definir o formato do protocolo

Para criar seu aplicativo de catálogo de endereços, comece com um arquivo .proto. As definições em um arquivo .proto são simples: adicione uma mensagem a cada estrutura de dados que você quer serializar e especifique um nome e um tipo para cada campo na mensagem. Em nosso exemplo, o arquivo .proto que define as mensagens é addressbook.proto.

O arquivo .proto começa com uma declaração de pacote, o que ajuda a evitar conflitos de nomenclatura entre diferentes projetos.

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

Em C#, as classes geradas serão colocadas em um namespace correspondente ao nome package, se csharp_namespace não for especificado. Em nosso exemplo, a opção csharp_namespace foi especificada para substituir o padrão. Portanto, o código gerado usa um namespace de Google.Protobuf.Examples.AddressBook em vez de Tutorial.

option csharp_namespace = "Google.Protobuf.Examples.AddressBook";

Em seguida, você tem as definições de mensagem. Uma mensagem é apenas um agregador contendo um conjunto de campos digitados. Muitos tipos de dados simples padrão estão disponíveis como tipos de campo, incluindo bool, int32, float, double e string. Também é possível adicionar outras estruturas às suas mensagens usando outros tipos de mensagem como tipos de campo.

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

No exemplo acima, a mensagem Person contém mensagens PhoneNumber, enquanto a mensagem AddressBook contém mensagens Person. É possível até definir tipos de mensagens aninhados em outras mensagens. Como você pode ver, o tipo PhoneNumber é definido em Person. Também é possível definir tipos enum se você quiser que um dos campos tenha uma de uma lista predefinida de valores. Aqui, você quer especificar que um número de telefone pode ser MOBILE, HOME ou WORK.

Os marcadores " = 1", " = 2" em cada elemento identificam a "tag" exclusiva que o campo usa na codificação binária. Os números de tag de 1 a 15 exigem um byte a menos para codificar do que os números maiores. Assim, como uma otimização, você pode optar por usar essas tags para os elementos usados com frequência ou repetidos, deixando as tags 16 e superiores para elementos opcionais menos usados. Cada elemento em um campo repetido requer a recodificação do número da tag. Por esse motivo, campos repetidos são especialmente bons candidatos para essa otimização.

Se um valor de campo não for definido, um valor padrão será usado: zero para tipos numéricos, a string vazia para strings e falso para booleanos. Para mensagens incorporadas, o valor padrão é sempre a "instância padrão" ou o "prototipo" da mensagem, que não tem nenhum dos campos definidos. Chamar o acessador para receber o valor de um campo que não foi definido explicitamente sempre retornará o valor padrão desse campo.

Se um campo for repeated, ele poderá ser repetido quantas vezes for necessário (incluindo zero). A ordem dos valores repetidos vai ser preservada no buffer de protocolo. Pense em campos repetidos como matrizes de tamanho dinâmico.

Você encontra um guia completo para escrever arquivos .proto, incluindo todos os tipos de campo possíveis no Guia de linguagem do buffer de protocolo. Não procure recursos semelhantes à herança de classe, mas os buffers de protocolo não fazem isso.

Como compilar os buffers do protocolo

Agora que você tem um .proto, a próxima etapa é gerar as classes necessárias para ler e gravar mensagens AddressBook (e, portanto, Person e PhoneNumber). Para fazer isso, execute o compilador de buffer de protocolo protoc no seu .proto:

  1. Se você não tiver instalado o compilador, faça o download do pacote e siga as instruções no README.
  2. Agora, execute o compilador, especificando o diretório de origem (onde fica o código-fonte do seu app: o diretório atual será usado se você não fornecer um valor), o diretório de destino (onde você quer que o código gerado seja armazenado, geralmente o mesmo que $SRC_DIR) e o caminho para o .proto. Nesse caso, invoque:
    protoc -I=$SRC_DIR --csharp_out=$DST_DIR $SRC_DIR/addressbook.proto
    Como quer código C#, use a opção --csharp_out. Opções semelhantes são fornecidas para outros idiomas compatíveis.

Isso gera Addressbook.cs no diretório de destino especificado. Para compilar esse código, você precisará de um projeto com uma referência para a montagem Google.Protobuf.

As classes da lista de endereços

A geração do Addressbook.cs oferece cinco tipos úteis:

  • Uma classe Addressbook estática que contém metadados sobre as mensagens do buffer de protocolo.
  • Uma classe AddressBook com uma propriedade People somente leitura.
  • Uma classe Person com propriedades para Name, Id, Email e Phones.
  • Uma classe PhoneNumber, aninhada em uma classe Person.Types estática.
  • Uma enumeração PhoneType, também aninhada em Person.Types.

Leia mais sobre os detalhes exatamente o que foi gerado no Guia de código gerado em C# , mas, na maior parte, é possível tratar isso como tipos C# perfeitamente comuns. Um ponto a ser destacado é que todas as propriedades correspondentes a campos repetidos são somente leitura. É possível adicionar ou remover itens da coleção, mas não substituí-la por uma coleção totalmente separada. O tipo de coleção de campos repetidos é sempre RepeatedField<T>. Esse tipo é como List<T>, mas com alguns métodos extras de conveniência, como uma sobrecarga Add que aceita uma coleção de itens para uso em inicializadores de coleção.

Veja um exemplo de como criar uma instância de Person:

Person john = new Person
{
    Id = 1234,
    Name = "John Doe",
    Email = "jdoe@example.com",
    Phones = { new Person.Types.PhoneNumber { Number = "555-4321", Type = Person.Types.PhoneType.Home } }
};

Observe que, com o C# 6, você pode usar using static para remover a feiura do Person.Types:

// Add this to the other using directives
using static Google.Protobuf.Examples.AddressBook.Person.Types;
...
// The earlier Phones assignment can now be simplified to:
Phones = { new PhoneNumber { Number = "555-4321", Type = PhoneType.HOME } }

Análise e serialização

A finalidade do uso de buffers de protocolo é serializar os dados para que possam ser analisados em outro lugar. Cada classe gerada tem um método WriteTo(CodedOutputStream), em que CodedOutputStream é uma classe na biblioteca de ambiente de execução do buffer de protocolo. No entanto, geralmente você usa um dos métodos de extensão para gravar em um System.IO.Stream normal ou converter a mensagem em uma matriz de bytes ou ByteString. Essas mensagens de extensão estão na classe Google.Protobuf.MessageExtensions. Portanto, quando você quiser serializar, normalmente precisará de uma diretiva using para o namespace Google.Protobuf. Exemplo:

using Google.Protobuf;
...
Person john = ...; // Code as before
using (var output = File.Create("john.dat"))
{
    john.WriteTo(output);
}

A análise também é simples. Cada classe gerada tem uma propriedade Parser estática que retorna um MessageParser<T> para esse tipo. Isso, por sua vez, tem métodos para analisar streams, matrizes de bytes e ByteStrings. Para analisar o arquivo que acabamos de criar, podemos usar:

Person john;
using (var input = File.OpenRead("john.dat"))
{
    john = Person.Parser.ParseFrom(input);
}

Um programa completo de exemplo para manter um catálogo de endereços (adicionando novas entradas e listando as existentes) usando essas mensagens está disponível no repositório do GitHub.

Como estender um buffer de protocolo

Logo ou mais tarde, depois de liberar o código que usa o buffer do protocolo, sem dúvida você vai querer "melhorar" a definição do buffer do protocolo. Se você quiser que seus novos buffers sejam compatíveis com versões anteriores e que seus buffers antigos sejam compatíveis com versões anteriores, mas com certeza quer isso, há algumas regras que precisam ser seguidas. Na nova versão do buffer de protocolo:

  • não é possível mudar os números das tags dos campos existentes.
  • é possível excluir campos.
  • É possível adicionar novos campos, mas é preciso usar números de tag novos (ou seja, números de tag que nunca foram usados nesse buffer de protocolo, nem mesmo por campos excluídos).

algumas exceções a essas regras, mas elas são raramente usadas.

Se você seguir essas regras, o código antigo lerá novas mensagens e ignorará quaisquer novos campos. Para o código antigo, os campos excluídos que foram excluídos simplesmente terão o valor padrão, e os campos repetidos excluídos estarão vazios. O novo código também vai ler as mensagens antigas de forma transparente.

No entanto, lembre-se de que novos campos não estarão presentes nas mensagens antigas. Portanto, você precisará fazer algo razoável com o valor padrão. Um valor padrão específico de tipo é usado: para strings, o valor padrão é a vazia. Para booleanos, o valor padrão é "false". Para tipos numéricos, o valor padrão é zero.

Reflection

Os descritores de mensagens (as informações no arquivo .proto) e as instâncias das mensagens podem ser examinados de forma programática usando a API de reflexão. Isso pode ser útil ao escrever códigos genéricos, como um formato de texto diferente ou uma ferramenta de diferença inteligente. Cada classe gerada tem uma propriedade Descriptor estática, e o descritor de qualquer instância pode ser recuperado usando a propriedade IMessage.Descriptor. Como um exemplo rápido de como elas podem ser usadas, veja um breve método de imprimir os campos de nível superior de qualquer mensagem.

public void PrintMessage(IMessage message)
{
    var descriptor = message.Descriptor;
    foreach (var field in descriptor.Fields.InDeclarationOrder())
    {
        Console.WriteLine(
            "Field {0} ({1}): {2}",
            field.FieldNumber,
            field.Name,
            field.Accessor.GetValue(message);
    }
}