Java gRPC od podstaw

Implementacja gRPC w Javie – przewodnik krok po kroku

Zastanówmy się, jak w praktyce można zaimplementować gRPC w środowisku Java.

gRPC, czyli Google Remote Procedure Call, to otwarta architektura RPC opracowana przez Google, która ma na celu usprawnienie komunikacji między mikrousługami. Umożliwia ona programistom łączenie usług napisanych w różnych językach programowania. gRPC wykorzystuje format Protobuf (bufory protokołów) – wysoce efektywny i kompaktowy format do serializacji danych strukturalnych.

W pewnych scenariuszach zastosowania, interfejs API gRPC może okazać się wydajniejszy w porównaniu do tradycyjnego interfejsu API REST.

Spróbujmy stworzyć prosty serwer gRPC. W pierwszej kolejności, niezbędne jest przygotowanie kilku plików z rozszerzeniem .proto, które będą opisywać usługi oraz modele (DTO). W naszym przykładzie posłużymy się usługą ProfileService oraz deskryptorem ProfileDescriptor.

Definicja usługi profilu przedstawia się następująco:

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) {}
}

gRPC wspiera różnorodne modele komunikacji między klientem a serwerem. Przeanalizujmy je szczegółowo:

  • Zwykłe wywołanie serwera (żądanie-odpowiedź).
  • Przesyłanie strumieniowe danych od klienta do serwera.
  • Przesyłanie strumieniowe danych z serwera do klienta.
  • Strumieniowanie dwukierunkowe.

Usługa ProfileService korzysta z elementu ProfileDescriptor, zdefiniowanego w sekcji importu:

syntax = "proto3";
package com.deft.grpc;
message ProfileDescriptor {
  int64 profile_id = 1;
  string name = 2;
}
  • Typ `int64` odpowiada typowi `long` w języku Java i służy jako identyfikator profilu.
  • `String` to standardowy typ łańcuchowy, podobny do tego w Javie.

Do budowy projektu można wykorzystać Gradle lub Maven. W tym artykule skupimy się na Maven, gdyż konfiguracja Gradle, a co za tym idzie, generowanie plików .proto, wygląda nieco inaczej. Dla prostego serwera gRPC potrzebujemy jednej zależności:

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

Ten starter znacznie ułatwia pracę, automatyzując wiele kroków konfiguracji.

Struktura naszego projektu będzie wyglądać następująco:

Potrzebujemy klasy GrpcServerApplication do uruchomienia aplikacji Spring Boot oraz klasy GrpcProfileService, która będzie implementować metody zdefiniowane w pliku .proto. Aby użyć protoc i wygenerować klasy na podstawie plików .proto, należy dodać wtyczkę protobuf-maven-plugin do pliku pom.xml. Sekcja build w pliku pom.xml powinna wyglądać w ten sposób:

<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` – wskazuje lokalizację plików .proto.
  • `outputDirectory` – określa katalog, w którym zostaną wygenerowane pliki.
  • `clearOutputDirectory` – flaga decydująca o tym, czy wygenerowane pliki mają być usuwane przed ponownym wygenerowaniem.

W tym momencie możemy zbudować projekt. Po zakończeniu procesu budowania, wygenerowane pliki zostaną umieszczone we wcześniej zdefiniowanym katalogu. Teraz możemy przystąpić do implementacji klasy `GrpcProfileService`.

Deklaracja naszej klasy wygląda następująco:

@GRpcService
public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase

Adnotacja `@GRpcService` oznacza, że klasa jest komponentem bean gRPC.

Dziedzicząc po klasie `ProfileServiceGrpc.ProfileServiceImplBase`, możemy nadpisywać metody zdefiniowane w klasie bazowej. Pierwszą metodą, którą zaimplementujemy, jest `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();
    }

Aby przekazać odpowiedź klientowi, należy wywołać metodę `onNext` na przekazanym obiekcie `StreamObserver`. Po przesłaniu odpowiedzi, należy poinformować klienta o zakończeniu operacji poprzez wywołanie metody `onCompleted`. Po wysłaniu żądania do serwera `getCurrentProfile`, odpowiedź będzie wyglądać tak:

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

Przejdźmy teraz do strumienia serwera. W tym przypadku klient wysyła pojedyncze żądanie, a serwer odpowiada strumieniem komunikatów. Przykładowo, serwer może wysyłać pięć komunikatów w pętli. Po zakończeniu strumieniowania, wysyła do klienta informację o sukcesie.

Implementacja metody strumienia serwera będzie wyglądać następująco:

@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();
    }

W ten sposób, klient otrzyma pięć wiadomości z atrybutem `ProfileId` równym numerowi iteracji pętli.

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

Strumień klienta jest podobny do strumienia serwera. Różnica polega na tym, że to klient wysyła strumień komunikatów, a serwer je przetwarza. Serwer może przetwarzać wiadomości na bieżąco lub po otrzymaniu całego strumienia.

    @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();
            }
        };
    }

W przypadku strumienia klienta, należy zwrócić obiekt `StreamObserver`, za pośrednictwem którego serwer będzie odbierał wiadomości. Metoda `onError` jest wywoływana w przypadku błędów w strumieniu, na przykład, gdy strumień zakończy się nieprawidłowo.

Implementacja strumienia dwukierunkowego łączy w sobie strumieniowanie z serwera i klienta.

@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();
            }
        };
    }

W tym przykładzie, w odpowiedzi na wiadomość od klienta, serwer zwraca profil ze zwiększoną liczbą punktów.

Podsumowanie

Przeanalizowaliśmy podstawowe modele komunikacji w gRPC: implementację strumienia serwera, klienta oraz strumienia dwukierunkowego.

Artykuł został opracowany na podstawie materiałów autorstwa Siergieja Golicyna.