Protocol Buffers
Veja o vídeo desse arquivo aqui.
Este é o primeiro de uma série de tutoriais rápidos onde planejo cobrir o uso de gRPC e vários aspectos como testes, TLS, boas práticas e muito mais.
Para iniciar com gRPC precisamos ir até a base então vamos primeiro falar de Protocol Buffers.
Protocol Buffers é uma forma simples e agnóstica com relação à linguagem de se definir uma estrutura de dados, como XML, só que melhor, mais simples e mais rápido, muito mais rápido.
Também podemos dizer que Protocol Buffers é uma forma de definir como seus dados estão organizados e então você pode usar essa definição para gerar automaticamente código para várias linguagens. Hoje são suportadas C++, Go, JAVA, Python, Ruby, C#, Objective-C, Javascript e PHP é bem possível que a linguagem do seu coração esteja nessa lista, as minhas são logo as duas primeiras :D, mas se você procurar um pouco vai encontrar implementações para outras linguagens não oficialmente suportadas como Lua, por exemplo. O suporte a tantas linguagens de programação é possível porque o compilador que le a definição do seu protocolo usa um sistema de plugins para definir o código de saída. Aqui tem uma lista de plugins de terceiros
Além do nosso material tem muita coisa boa na internet explicando muito bem o funcionamento do Protocol Buffers, não deixe de, por exemplo, ver os vídeos do Francesc Campoy em JustForFunc
Agora vejamos um exemplo simples de como salvar e recuperar structs em um arquivo. Esse exemplo é derivado do exemplo do Francesc, a principal diferença está na hora de carregar os dados do arquivo, eu prefiro evitar carregar tudo para RAM e depois fazer o parse dos dados. No lugar disso é melhor ler o arquivo e ir parseando os dados.
Para poder usar o compilador protoc com Golang não deixe de instalar o plugin como no exemplo.
go get -u github.com/golang/protobuf/protoc-gen-go
Não ter o plugin da linguagem que se pretende gerar o código é a falha mais comum quando se usa protocol buffers.
Agora criemos um exemplo bem simples de arquivo .proto o user.proto
syntax = "proto3";
package user;
message User {
int64 ID = 1;
string email = 2;
string name = 3;
}
Os arquivos .proto são usados para definir as estruturas/mensagens serializadas pelo protocol buffer, com esse arquivo o compilador protoc pode gerar código para várias linguagens e esse é o grande truque, é rápido porque o código é gerado para tratar dados binários de uma estrutura específica facilitando muito o trabalho do parser, outros formatos como JSON existem muito mais carga de processamento.
No arquivo de exemplo um detalhe importante é o ID que vem depois do sinal de igualdade, como os dados serializadas serão binários esse ID será usado para distinguir os campos, você pode adicionar novos campos na ordem que quiser, basta ter um ID diferente.
Gerando código
Agora que temos o arquivo definindo o formato que os dados serão serializadas podemos usar o protoc para gerar um package contendo nossa struct já em código Go.
protoc --go_out=. user.proto
Esse é o comando para gerar o código manualmente, mas prefiro chamar o protoc via go generate, para isso coloquei dentro do a seguinte linha.
//go:generate protoc --go_out=. ./user/user.proto
Assim podemos gerar essa dependência e qualquer outra chamando go generate
go generate
O arquivo user.pb.go
será gerado contendo o package user
e todo o necessário para serializarmos e deserializamos ela.
Gravando dados serializadas
Após gerar o package agora vamos finalmente estudar nosso código de exemplo.
Temos duas funções, uma para adicionar e outra para listar usuários.
A função add
primeiro cria uma instância da struct user e popula os campos.
u := &user.User{
ID: id,
Name: name,
Email: email,
}
Em seguida serializamos essa struct para binário
b, err := proto.Marshal(u)
if err != nil {
return fmt.Errorf("could not encode task: %v", err)
}
Nesse ponto já temos a struct serializada, ou seja, ela pode ser gravada ou transmitida e qualquer linguagem que use o mesmo arquivo .proto para gerar o código conseguiria deserializar os dados.
No nosso caso gravaremos em um arquivo, então abramos um arquivo no modo append, ou se não existir criar um arquivo vazio.
f, err := os.OpenFile(dbPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("could not open %s: %v", dbPath, err)
}
Agora antes de gravar a struct no arquivo gravaremos um inteiro contendo o tamanho da struct, isso é necessário porque o protocol buffers não contem nenhum metadados dizendo o tamanho da struct nem nenhuma informação alem do essencial, esse é parte do motivo por ser tão rápido.
Então a parte a seguir não tem nada de protocol buffers mas é interessante por si só. Vamos gravar o tamanho no arquivo usando binary.Write
e isso depende do formato do inteiro na memória o que requer lidar com endianness
, essa é uma troca que temos que fazer quando queremos velocidade, precisamos descer até a estrutura da plataforma.
// add record length to file
if err = binary.Write(f, endianness, length(len(b))); err != nil {
return fmt.Errorf("could not encode length of message: %v", err)
}
Finalmente gravaremos a estrutura.
// add rocord to file
_, err = f.Write(b)
if err != nil {
return fmt.Errorf("could not write task to file: %v", err)
}
E terminamos fechando o arquivo.
err = f.Close()
if err != nil {
return fmt.Errorf("could not close file %s: %v", dbPath, err)
}
Lendo dados serializados
Agora os dados estão salvos no disco usando um formato bem simples, o tamanho dos dados seguido payload da estrutura, seguindo pelo tamanho do próximo registro e em seguida pelos seus dados e assim por diante até o fim do arquivo.
A primeira coisa que faremos é abrir o arquivo para leitura.
f, err := os.Open(dbPath)
if err != nil {
return fmt.Errorf("could not open file %s: %v", dbPath, err)
}
defer func() {
e := f.Close()
if e != nil {
fmt.Println(e)
}
}()
Em seguida entramos em um loop em que vamos ler o arquivo e só vamos sair dele quando terminarmos de ler o arquivo EOF
Primeiro lemos o inteiro contendo o tamanho do próximo registro, caso binary.Read
retorne erro ou EOF saímos do loop.
// load record file
var l length
err = binary.Read(f, endianness, &l)
if err != nil {
if err == io.EOF {
err = nil
return
}
return fmt.Errorf("could not read file %s: %v", dbPath, err)
}
Agora que sabemos o tamanho da struct que esta serializada no arquivo podemos usar io.ReadFull
para ler exatamente essa quantidade de bytes e para isso criamos um buffer.
// load record
bs := make([]byte, l)
_, err = io.ReadFull(f, bs)
if err != nil {
return fmt.Errorf("could not read file %s: %v", dbPath, err)
}
Nosso buffer agora contem os dados serializados e usaremos esses dados com proto.Unmarshal
para preencher uma nova instância de user.
// Unmarshal
var u user.User
err = proto.Unmarshal(bs, &u)
if err != nil {
return fmt.Errorf("could not read user: %v", err)
}
E por fim exibimos os dados na tela, usamos os getters que o protoc gerou para nos, mas eles não são necessários já que Golang não permite strings NULL, mas se fosse um ponteiro para a instância de user esses getters evitariam erro retornando strings vazias.
// Print
fmt.Println("id:", u.GetID())
fmt.Println("name:", u.GetName())
fmt.Println("e-mail:", u.GetEmail())
fmt.Println("------------------")
Gradualmente intercalaremos tópicos mais avançados como esse com tópicos mais simples e principalmente e práticos.