Arquitetura Backend

Serialização de dados com o Protocol Buffers

Veja porque trabalhar com o Protocol Buffer (ou Protobuf), mecanismo desenvolvido pelo Google que permite a serialização de dados estruturados.

Na área de TI trabalhamos com serialização de dados diariamente. JSON e XML são apenas alguns exemplos de formatos que podemos utilizar para serializar, transportar e armazenar dados. Esses formatos são amplamente utilizados e testados, ao ponto de atualmente ser difícil falar de APIs sem que JSON venha quase instantaneamente a sua mente. Apesar disso, não significa que JSON seja o melhor formato para tais tarefas. 

Para nós, desenvolvedores e consumidores de APIs, JSON é um formato muito simples de entender e utilizar – afinal é um formato desenvolvido para humanos, não para máquinas. Tendo esse pensamento em mãos, tente responder a seguinte pergunta: 

JSON, ou até mesmo outro formato “human-readable”, é o formato ideal para comunicação entre serviços?

Assim como muitas coisas na área da TI, não temos uma resposta 100% correta para essa pergunta.

JSON soluciona o seu problema? Ótimo! Mas se você ou sua empresa necessitam de uma alternativa menor, rápida e simples, vale a pena conhecer como funciona o Protocol Buffers.

Mas afinal, o que é isso?

Resumidamente, é um mecanismo desenvolvido pelo Google, independente de linguagem e plataforma, que permite a serialização de dados estruturados. 

Diferentemente do JSON, Protocol Buffers (ou Protobuf, para os íntimos) tem como objetivo otimizar a comunicação entre serviços, transformando os dados em um formato binário, separando a definição (contrato) da mensagem dos dados que são trafegados/armazenados. 

Muito texto, pouco exemplo… então vamos lá! 

Vamos considerar o exemplo hipotético abaixo que representa os dados de uma conta bancária em JSON.

{
     "bank": 121,
     "branch": 1,
     "account": 12345,
     "type": "CORRENTE",
     "balance": {
         "total": 1500.00,
         "spent": 500.00,
         "available": 1000.00
     },
     "blocked": false
 }

Perceba que ao mesmo tempo que temos os dados da conta, a mensagem também “define” o contrato, o “formato” que é utilizado para representar uma conta em nosso sistema. Ou seja, cada vez que um objeto desse tipo é serializado e transmitido, a definição do contrato também é. Porém, isso ocupa espaço – e conforme adicionamos mais dados na mensagem, mais espaço será necessário para definir e serializar cada campo novo. 

Quando estamos falando de Protobufs, o contrato e os dados são definidos separadamente. Cada mensagem é definida previamente utilizando a sintaxe específica do Protocol Buffers através de arquivos .proto. Esses arquivos precisam ser compilados posteriormente para código gerado automaticamente na linguagem de programação que utilizamos normalmente. 

O exemplo anterior poderia ser definido em Protocol Buffers da seguinte forma: 

syntax = "proto3";
 enum AccountType {
     UNSPECIFIED = 0;
     CORRENTE = 1;
     POUPANCA = 2;
 }
 message Limits {
     double total = 1;
     double spent = 2;
     double available = 3;
 }
 message Account {
     int32 bank = 1;
     int32 branch = 2;
     int32 account = 3;
     AccountType type = 4;
     Limits limits = 5;
     bool blocked = 6;
 }

Calma, não se assuste ainda… vamos aos poucos.

Um arquivo .proto é composto por algumas partes, a primeira delas é a definição da versão da sintaxe utilizada.

Atualmente existem 2 versões da linguagem (proto2 e proto3). Vamos focar 100% na versão 3, que é a mais recente e recomendada para novos serviços (já que a versão 2 terá seu suporte encerrado em breve). 

Logo depois podemos definir algumas opções e definições de pacote, porém não abordaremos isso agora. 

Na sequência podemos definir as nossas mensagens, onde cada campo da mensagem é estruturado da seguinte forma. 

[field_rule] field_type field_name = field_number;

Field Rule

Na versão 3 da linguagem temos apenas o field rule repeated disponível. Essa regra define que o campo pode aparecer N vezes (incluindo zero) na mensagem. Fazendo uma analogia com JSON, o campo repeated pode ser considerado um array.

Vale lembrar que a ordem dos valores de um campo repeated sempre será preservada.

Field Type

Protocol Buffer é uma linguagem fortemente tipada, ou seja, precisamos definir explicitamente qual o tipo de dado para cada campo.

Um campo pode ter um tipo escalar, um enum ou ser definido por outra mensagem.

Você pode consultar a documentação para ver quais os tipos escalares disponíveis.

Field Name

O nome do campo é uma string que o identifica. Esse nome será utilizado pelo compilador do Protobuf (chegaremos lá) para gerar o código na linguagem de programação de sua preferência que permitirá acessar os dados presentes na mensagem. 

Field Number

Essa é a parte mais estranha da mensagem. É ele que permite que a mensagem seja codificada e enviada sem a necessidade de enviar o field name. Cada campo da mensagem deve possuir um field number único, e mudanças futuras na definição da mensagem não devem reutilizar números utilizados anteriormente.

Caso você remova um campo, o field number que representava ele deverá ser marcado como reserved, assim é possível garantir a compatibilidade entre serviços que utilizam versões diferentes da mesma mensagem. Além disso o compilador garantirá que nenhum campo da mensagem utilizará um field number reservado.

Default Values

Na sintaxe do proto3, todos os campos presentes nas mensagens são considerados opcionais, e sempre que uma mensagem for codificada sem algum campo, esse campo assumirá um valor padrão baseado no tipo do campo.

  • string: valor padrão é uma string vazia.
  • bytes: valor padrão são bytes vazios.
  • bool: valor padrão é false.
  • doubleintfloat e demais tipos numéricos: valor padrão é 0.
  • enums: primeiro valor definido no enum, este deve ter identificador 0.
  • messages: campos que são possuem outra mensagem como tipo não são definidos. O valor exato é dependente da linguagem.
  • repeated: valor padrão é vazio, o que geralmente é representado por uma lista vazia nas linguagens.

Compilador… como assim?

O arquivo .proto serve para definir as mensagens, porém para utilizar as definições precisamos compilar o arquivo .proto para a linguagem de sua preferência.

Por padrão o compilador oferece suporte para as linguagens mais populares (C++, C#, Java, PHP, Go, Node, Python). Também existem plugins da comunidade que permitem compilar para outras linguagens bastante utilizadas (Swift, Kotlin, Rust, etc). 

As instruções e opções do compilador são específicas para cada linguagem, da mesma forma o código gerado pode utilizar recursos específicos da linguagem quando estes estiverem disponíveis. (Ex: código gerado para linguagens que possuem o conceito de enum utilizará enum nativos da linguagem, já em node serão utilizados objetos para representar o mesmo comportamento). 

Instalando o proto-compiler

No repositório do Protocol Buffers no Github existem as instruções de como instalar o proto-compiler. Algumas linguagens precisam, além do protoc, um compilador específico capaz de gerar código naquela linguagem.

A forma mais simples de instalar é utilizar as releases pré-buildadas disponibilizadas na página de release no Github.

Depois de instalado podemos gerar o código através do comando abaixo. Aqui temos um exemplo que irá gerar um código em Go para a mensagem que definimos anteriormente.

protoc --proto_path=. --go_out=go_folder_to_output account.proto

O comando acima irá gerar o arquivo account.pb.go. É um arquivo bem grande e que não devemos modificar, e sempre que modificarmos o .proto precisamos compilar novamente.

Neste arquivo estão disponíveis as estruturas que representam as nossas mensagens, métodos para encode e decode da mensagem, métodos para transformar a mensagem em JSON, e muito mais.

account := &pb.Account{
     Bank: 121,
     Branch: 1,
     Account: 12345,
     Type: pb.AccountType_CORRENTE, // ENUM nativo
     Limits: &pb.Limits{
         Total: 1500.00,
         Spent: 500.00,
         Available: 1000,
     },
     Blocked: false,
 }
 fmt.Println(account.GetBank())              // 121
 fmt.Println(account.GetLimits().GetTotal()) // 1500.00

Tendo a nossa mensagem criada, podemos serializar ela para armazená-la em disco, transmitir na rede, etc.

out, err := proto.Marshal(account)
 if err !=  nil  {
     log.Fatalln("falha no encode da conta:", err)  
 }
 if err := ioutil.WriteFile(accountFile,  out,  0644); err !=  nil  {
     log.Fatalln("falha ao salvar a conta no disco", err)  
 }

Da mesma forma podemos ler a mensagem salva no disco e realizar o processo inverso de desserialização.

in, err := ioutil.ReadFile(accountFile)  
 if err !=  nil  {
     log.Fatalln("falha ao ler arquivo:", err)  
 }
 account :=  &pb.Account{}  
 if err := proto.Unmarshal(in, account); err !=  nil  {
     log.Fatalln("falha ao desserializar a conta:", err)  
 }
 fmt.Println(account.GetBank())              // 121
 fmt.Println(account.GetLimits().GetTotal()) // 1500.00 

Conclusão

Protocol Buffers é uma ótima alternativa quando queremos otimizar a comunicação entre os nossos serviços, e temos outras vantagens como a definição centralizada de nossas mensagens, onde qualquer aplicação que precise utilizá-las pode fazer isso, basta compilar os arquivos .proto à linguagem desejada e pronto!

O Protobuf faz parte da fundação necessária para explorarmos outros assuntos no futuro e é essencial para o nosso próximo tópico, quando iremos falar um pouco a respeito de gRPC e gRPC-Web.

%d blogueiros gostam disto: