Micronaut: Utilizando Protobuf para serialização e desserialização dos dados enviados ao RabbitMQ
Sistemas de mensageria não são uma novidade para aqueles que estão acostumados a implementar serviços baseados na arquitetura de microsserviços. Entretanto, se você utiliza gRPC em suas comunicações, já pensou em utilizar o Protobuf para serialização/desserialização dos dados enviados ao RabbitMQ? Neste artigo demonstrarei o seu uso! Nossa stack será:
- Micronaut 2.4.0;
- Java 11;
- Gradle 6.8.
Fluxo do nosso exemplo
Construí uma implementação desse fluxo e o código está disponível no meu GitHub, neste artigo utilizarei como exemplo a comunicação entre ShoppingCart Microsservice e Payment Microsservice.
O que é Protobuf?
Em sua documentação oficial o Protobuf é definido como “mecanismo extensível de linguagem e plataforma neutras, utilizado para serializar dados estruturados”, ou seja, ele é agnóstico à linguagem e utiliza como base arquivos de contrato em formato próprio, que possuem a extensão .proto. Em resumo, para trabalhar com o Protobuf, precisamos seguir basicamente três etapas:
- 1º Definimos a estrutura das nossas mensagens e serviços dentro do nosso arquivo .proto;
- 2º Utilizamos o compilador do Protobuf (protoc) para gerar classes de acesso na linguagem que você desejar, por exemplo, Java, C++ etc;
- 3º A partir dessas classes preenchemos, recuperamos e serializamos as mensagens definidas no .proto.
Para uma exemplificação mais completa e detalhada do conteúdo acima, sugiro fortemente a consulta da documentação oficial do gRPC e do Protobuf, além deste artigo: Protobuf — Uma alternativa ao JSON e XML.
Bom, agora que sabemos que o Protobuf é utilizado para serialização/desserialização de dados estruturados, partiremos para a ideia central do artigo que é utilizá-lo nessa função em detrimento do Jackson.
Definindo o nosso arquivo .proto das mensagens trocadas entre os serviços ShoppingCart e Payment Microsservice
O primeiro passo para utilizar o Protobuf é definir a estrutura dos dados que queremos utilizar nas comunicações, portanto, vamos definir um arquivo .proto com a estrutura das mensagens que o ShoppingCart Microsservice deseja entregar ao RabbitMQ e que serão consumidas pelo Payment Microsservice:
syntax = "proto3";
option java_multiple_files = true;
option java_package = "br.com.tony.shoppingcart";
package br.com.tony.shoppingcart;
message PaymentRequest {
int64 orderId = 1;
int64 clientId = 2;
string clientName = 3;
string cardNumber = 4;
double amount = 5;
}
message PaymentResponse {
int64 orderId = 1;
int64 clientId = 2;
string status = 3;
}
Temos duas mensagens, o PaymentRequest que define uma solicitação de pagamento e o PaymentResponse que traz consigo o orderId e o clientId para identificar a qual cliente e pedido se refere aquele status recebido. Este arquivo .proto dará a duas classes, sendo PaymentRequest e PaymentResponse. Demonstrarei a implementação do serialização/desserialização somente da PaymentRequest pois os métodos sobrescritos são exatamente os mesmos, alterando somente o tipo do argumento da interface RabbitMessageSerDes<T> do Micronaut.
Sobrescrevendo a implementação padrão de serialização/desserialização do Micronaut
Por padrão o Micronaut oferece suporte a serialização/desserialização no formato JSON com o Jackson, mas na documentação do framework é informado que se registrarmos um bean do tipo RabbitMessageSerDes ele será utilizado em detrimento do padrão. Então, vamos implementar a nossa própria RabbitMessageSerDes que usa o Protobuf ao invés do Jackson.
Criando o nosso bean PaymentRequestSerializerDeserializer
Como o próprio nome da classe define, este bean será responsável por serializar/desserializar a nossa mensagem do tipo PaymentRequest. A implementação final é bem simples, veja:
Observe que a interface RabbitMessageSerDes<T> recebe um parâmetro do tipo genérico T, que no nosso caso serão as classes geradas pelo protoc a partir do nosso arquivo .proto, como estamos implementando o bean do PaymentRequest, passaremos esta classe como parâmetro. A interface RabbitMessageSerDes define três métodos que devemos implementar:
Primeiro método: T deserialize(RabbitConsumerState consumerState, Argument<T> argument) ;
A implementação desse método será responsável por desserializar as mensagens que o RabbitMQ obtém da fila, ele retorna um tipo T que será o que informarmos como parâmetro da interface RabbitMessageSerDes<T>. Como utilizaremos o Protobuf para serializar as mensagens, naturalmente precisamos utilizá-lo para desserializar. A classe PaymentRequest gerada pelo compilador protoc possui vários métodos estáticos padrão, dentre eles o que recebe um array de bytes e faz o parse. Como estamos no método de desseriazação, utilizaremos o parseFrom que recebe um array de bytes como parâmetro, conforme linha 19 do código da nossa implementação:
PaymentRequest.parseFrom(consumerState.getBody());
Perceba que o consumerState.getBody() faz parte da implementação do Micronaut para o RabbitMQ e retorna um array de bytes com o corpo da mensagem recebida, no nosso caso esse array de bytes é resultado da serialização que explicarei logo adiante, portanto, ao fazermos o parseFrom desses dados iremos obter uma PaymentRequest com todos aqueles campos que definimos na mensagem no arquivo .proto.
Importante: tenha em mente que o arquivo .proto é uma espécie de contrato entre os serviços, portanto, para a correta serialização/desserialização das informações, todos os serviços envolvidos na comunicação devem possuir a mesma versão do .proto, caso algum campo da mensagem seja diferente o parse irá retornar dados incorretos.
Segundo método: byte[] serialize(T data, MutableBasicProperties properties);
Este método é responsável por fazer a serialização dos dados da mensagem, ele recebe dados do tipo genérico T, que no nosso caso é uma PaymentRequest. Para convertermos esses dados em um array de bytes utilizaremos um método que a PaymentRequest herdou da superclasse AbstractMessageLite.
data.toByteArray();
Terceiro método: boolean supports(Argument<T> type);
Por último, mas não menos importante, conforme a documentação da interface este método “determina se o serdes suporta o tipo fornecido”. Mas como ele verifica isso? No nosso caso é através do método boolean isAssignableFrom(Class<?> cls); que faz parte do pacote java.lang e “determina se a classe ou interface representada por este objeto Class é igual ou é uma superclasse ou superinterface da classe ou interface representada pelo parâmetro Class especificado”. Pode parecer um pouco confuso, mas veja a implementação do método:
@Override
public boolean supports(Argument<PaymentRequest> type) {
return type.getType().isAssignableFrom(PaymentRequest.class);
}
Se lermos a implementação fica bem mais fácil entender. O método retorna true se o argumento type for igual ou superclasse/superinterface da classe PaymentRequest.class gerada pelo protoc. Isso serve para verificar se há uma implementação de serialização/desserialização capaz de trabalhar com a nossa PaymentRequest.
E como fica o nosso publisher e consumer RabbitMQ?
O Micronaut facilita muito a criação do publisher e consumer das filas RabbitMQ. No nosso caso, lembre-se que alteramos somente a forma de serialização/desserialização dos dados, portanto a única alteração é no tocante aos tipos recebidos. Por exemplo, veja como está o nosso PaymentProcessorQueuePublisher que faz parte do serviço ShoppingCart e envia a requisição de pagamento a ser consumida pelo Payment:
Observe que ele está recebendo um PaymentRequest como parâmetro de dado a ser enviado para o broker do RabbitMQ.
O mesmo ocorre com a implementação que consome as requisições de pagamento e está no Payment Microsservice. Observe:
Mais informações sobre o suporte do Micronaut ao RabbitMQ podem ser encontradas na documentação do Micronaut RabbitMQ.
Testando nossa implementação
O código do exemplo que construí está disponível no meu GitHub. No readme do repositório há um passo a passo de como executá-lo localmente e não tratarei disso aqui pois ficaria redundante. A partir de agora irei supor que você está com o exemplo do repositório em execução na sua máquina ou que simplesmente você está testando no seu próprio usecase.
Enviando uma requisição
Utilize um client gRPC como o Insomnia, importe o arquivo order-service.proto que está no diretório shopping-cart-service/src/main/proto, configure a porta gRPC como 0.0.0.0:50051 e selecione o método order:
Seguindo nosso fluxograma, ao receber uma requisição o ShoppingCart Microsservice coleta as informações pertinentes e envia ao nosso Payment Microsservice uma PaymentRequest. Portanto, vamos observar o fluxo acompanhando os logs que serão impressos nos terminais de execução desses dois serviços.
Log no terminal do ShoppingCart Microsservice ao enviar uma PaymentRequest ao Payment Microsservice:
Marquei em amarelo o log relacionado ao envio da PaymentRequest para facilitar a visualização. Observe que a requisição possui todas as informações que definimos no nosso .proto, por se tratar de um caso de estudos estou imprimindo dados sensíveis no log, mas saiba que todos são fictícios e o cardNumber, apesar de não estar impresso, está na requisição.
Log no terminal do Payment Microsservice ao receber, processar e enviar uma reposta sobre uma PaymentRequest:
O Payment Microsservice fica “escutando” a fila de solicitações de pagamento e simula um processamento ao receber uma mensagem. Na penúltima linha o log informa que uma PaymentResponse está sendo enviada para o broker para ser lida pelo nosso ShoppingCart Microsservice.
Log no terminal do ShoppingCart Microsservice ao receber uma PaymentResponse do Payment Microsservice:
Pronto! Como você pode observar o nosso bean está funcionando como esperado! Até a próxima!