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.