gRPC em Java: Guia Completo com Exemplos Práticos

Implementando gRPC em Java: Um Guia Detalhado

Vamos aprofundar na implementação do gRPC utilizando a linguagem Java.

O que é gRPC? gRPC, ou Google Remote Procedure Call, é uma estrutura de código aberto para RPC (Chamada de Procedimento Remoto) desenvolvida pelo Google. Seu propósito é facilitar a comunicação de alta performance entre microsserviços. Uma das principais vantagens do gRPC é a capacidade de permitir que serviços desenvolvidos em diferentes linguagens possam interagir entre si. Ele utiliza o formato Protobuf (Protocol Buffers) para a serialização de dados estruturados. Protobuf é um formato de mensagens binárias altamente eficiente e compacto, o que contribui para o desempenho do gRPC.

Em certos cenários, o uso da API gRPC pode oferecer melhor performance quando comparada com a API REST.

Nosso objetivo é criar um servidor gRPC. Para começar, precisamos definir arquivos .proto que descrevem nossos serviços e modelos (DTOs). Neste exemplo, utilizaremos o ProfileService e o ProfileDescriptor.

A definição do ProfileService é a seguinte:

syntax = "proto3";
package com.deft.grpc;
import "google/protobuf/empty.proto";
import "profile_descriptor.proto";
service ProfileService {
  rpc GetCurrentProfile (google.protobuf.Empty) returns (ProfileDescriptor) {}
  rpc clientStream (stream ProfileDescriptor) returns (google.protobuf.Empty) {}
  rpc serverStream (google.protobuf.Empty) returns (stream ProfileDescriptor) {}
  rpc biDirectionalStream (stream ProfileDescriptor) returns (stream 	ProfileDescriptor) {}
}

O gRPC suporta diversas modalidades de comunicação entre cliente e servidor. Vamos analisar cada uma delas:

  • Chamada normal do servidor (requisição/resposta).
  • Transmissão do cliente para o servidor (client streaming).
  • Transmissão do servidor para o cliente (server streaming).
  • Fluxo bidirecional (bidirectional streaming).

O serviço ProfileService utiliza o ProfileDescriptor, que está definido na seção de importação:

syntax = "proto3";
package com.deft.grpc;
message ProfileDescriptor {
  int64 profile_id = 1;
  string name = 2;
}
  • int64 corresponde ao tipo Long em Java. Utilizaremos para o identificador do perfil.
  • String, assim como em Java, representa uma sequência de caracteres.

Para a construção do projeto, você pode usar o Gradle ou o Maven. Neste guia, utilizaremos o Maven. É importante mencionar que a configuração para geração dos arquivos a partir de .proto é diferente no Gradle. Para um servidor gRPC simples, precisamos apenas da seguinte dependência:

<dependency>
    <groupId>io.github.lognet</groupId>
    <artifactId>grpc-spring-boot-starter</artifactId>
    <version>4.5.4</version>
</dependency>

Essa dependência simplifica bastante o processo de configuração do gRPC.

A estrutura do projeto será a seguinte:

Precisamos de um GrpcServerApplication para inicializar o aplicativo Spring Boot, e um GrpcProfileService, que implementará os métodos definidos no serviço .proto. Para utilizar o protoc e gerar as classes a partir dos arquivos .proto, adicionaremos o plugin protobuf-maven-plugin ao arquivo pom.xml. A seção de build deve ser configurada da seguinte forma:

<build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.6.2</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protoSourceRoot>${project.basedir}/src/main/proto</protoSourceRoot>
                    <outputDirectory>${basedir}/target/generated-sources/grpc-java</outputDirectory>
                    <protocArtifact>com.google.protobuf:protoc:3.12.0:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.38.0:exe:${os.detected.classifier}</pluginArtifact>
                    <clearOutputDirectory>false</clearOutputDirectory>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
  • protoSourceRoot: Define o diretório onde os arquivos .proto estão localizados.
  • outputDirectory: Define o diretório onde os arquivos gerados serão colocados.
  • clearOutputDirectory: Um indicador para não apagar os arquivos gerados.

Neste ponto, já podemos construir o projeto. Após a compilação, podemos encontrar os arquivos gerados no diretório especificado. Agora, podemos implementar o GrpcProfileService.

A declaração da classe será a seguinte:

@GRpcService
public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase

A anotação @GRpcService marca a classe como um bean do gRPC.

Como nossa classe herda de ProfileServiceGrpc.ProfileServiceImplBase, podemos sobrescrever os métodos da classe pai. O primeiro método que iremos sobrescrever é o getCurrentProfile:

    @Override
    public void getCurrentProfile(Empty request, StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) {
        System.out.println("getCurrentProfile");
        responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor
                .newBuilder()
                .setProfileId(1)
                .setName("test")
                .build());
        responseObserver.onCompleted();
    }

Para responder ao cliente, devemos chamar o método onNext no StreamObserver que foi passado como argumento. Após enviar a resposta, devemos enviar um sinal para o cliente indicando que o servidor terminou de processar a requisição através de onCompleted. Ao enviar uma requisição para o servidor para o método getCurrentProfile, a resposta será:

{
  "profile_id": "1",
  "name": "test"
}

Agora, vamos analisar o server stream. Nesta forma de comunicação, o cliente envia uma solicitação ao servidor, e o servidor responde com um fluxo de mensagens. Por exemplo, o servidor pode enviar cinco mensagens em um loop. Quando o envio é finalizado, o servidor envia uma mensagem para o cliente informando que o fluxo foi concluído com sucesso.

O método sobrescrito para server stream será assim:

@Override
    public void serverStream(Empty request, StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) {
        for (int i = 0; i < 5; i++) {
            responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor
                    .newBuilder()
                    .setProfileId(i)
                    .build());
        }
        responseObserver.onCompleted();
    }

Dessa forma, o cliente receberá cinco mensagens com um profileId igual ao número da resposta.

{
  "profile_id": "0",
  "name": ""
}
{
  "profile_id": "1",
  "name": ""
}
…
{
  "profile_id": "4",
  "name": ""
}

O client stream é similar ao server stream. A diferença é que, neste caso, o cliente envia um fluxo de mensagens e o servidor as processa. O servidor pode processar as mensagens de forma imediata ou aguardar todas as requisições do cliente antes de realizar o processamento.

    @Override
    public StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> clientStream(StreamObserver<Empty> responseObserver) {
        return new StreamObserver<>() {

            @Override
            public void onNext(ProfileDescriptorOuterClass.ProfileDescriptor profileDescriptor) {
                log.info("ProfileDescriptor from client. Profile id: {}", profileDescriptor.getProfileId());
            }

            @Override
            public void onError(Throwable throwable) {

            }

            @Override
            public void onCompleted() {
                responseObserver.onCompleted();
            }
        };
    }

No client stream, precisamos retornar um StreamObserver para o cliente, por onde o servidor receberá as mensagens. O método onError será chamado se ocorrer algum erro no stream. Por exemplo, se o stream for finalizado de forma incorreta.

Para implementar um fluxo bidirecional, é necessário combinar os conceitos de server stream e client stream.

@Override
    public StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> biDirectionalStream(
            StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) {

        return new StreamObserver<>() {
            int pointCount = 0;
            @Override
            public void onNext(ProfileDescriptorOuterClass.ProfileDescriptor profileDescriptor) {
                log.info("biDirectionalStream, pointCount {}", pointCount);
                responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor
                        .newBuilder()
                        .setProfileId(pointCount++)
                        .build());
            }

            @Override
            public void onError(Throwable throwable) {

            }

            @Override
            public void onCompleted() {
                responseObserver.onCompleted();
            }
        };
    }

Neste exemplo, em resposta a cada mensagem do cliente, o servidor retornará um perfil com um pointCount incrementado.

Conclusão

Neste guia, abordamos as formas básicas de comunicação entre um cliente e um servidor utilizando gRPC: server stream, client stream e fluxo bidirecional.

Artigo escrito por Sergey Golitsyn