Zkoumání implementace gRPC v Javě
Podívejme se, jak lze v Javě efektivně implementovat gRPC.
gRPC (Google Remote Procedure Call): Jedná se o open-source RPC framework od společnosti Google, navržený pro rychlou komunikaci mezi mikroservisami. Umožňuje vývojářům propojovat služby napsané v různých programovacích jazycích. gRPC používá pro serializaci strukturovaných dat formát zpráv Protobuf (Protocol Buffers), který je vysoce efektivní a kompaktní.
V mnoha situacích může být gRPC API výkonnější než tradiční REST API.
Zkusme si vytvořit gRPC server. Nejprve potřebujeme definovat .proto soubory, které popisují služby a datové objekty (DTO). Pro jednoduchý server použijeme `ProfileService` a `ProfileDescriptor`.
Definice `ProfileService` vypadá takto:
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 podporuje různé způsoby komunikace klient-server. Pojďme se na ně podívat:
- Běžné volání serveru: požadavek/odpověď.
- Streamování z klienta na server.
- Streamování ze serveru na klienta.
- A také obousměrný stream.
Služba `ProfileService` využívá `ProfileDescriptor`, jehož definici najdeme v sekci importu:
syntax = "proto3"; package com.deft.grpc; message ProfileDescriptor { int64 profile_id = 1; string name = 2; }
- `int64` je v Javě ekvivalent `long`. Zde reprezentuje ID profilu.
- `string` je řetězcová proměnná, stejně jako v Javě.
Pro sestavení projektu můžeme použít Gradle nebo Maven. Preferuji Maven, a proto bude dále použit kód s Mavenem. Toto je důležité zmínit, protože konfigurace generování .proto souborů bude v Gradlu odlišná, a tedy i sestavení projektu.
Pro vytvoření jednoduchého gRPC serveru potřebujeme jedinou závislost:
<dependency> <groupId>io.github.lognet</groupId> <artifactId>grpc-spring-boot-starter</artifactId> <version>4.5.4</version> </dependency>
Tento starter nám velmi usnadní práci. Vyřeší za nás spoustu komplikací.
Struktura našeho projektu bude vypadat následovně:
K běhu Spring Boot aplikace potřebujeme `GrpcServerApplication`. Dále `GrpcProfileService`, která implementuje metody definované v .proto souboru. Pro použití protokolu a generování tříd z .proto souborů je nutné přidat do `pom.xml` plugin `protobuf-maven-plugin`. Sekce sestavení (`build`) by tedy měla vypadat takto:
<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` – určuje adresář, kde se nacházejí .proto soubory.
- `outputDirectory` – specifikuje cílový adresář pro generované soubory.
- `clearOutputDirectory` – příznak, který určuje, zda se mají generované soubory mazat.
V tomto bodě můžeme projekt vytvořit. Následně je nutné přejít do složky, kterou jsme nastavili jako `outputDirectory`. Tam najdeme vygenerované soubory. Nyní můžeme postupně implementovat `GrpcProfileService`.
Deklarace třídy bude vypadat následovně:
@GRpcService public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase
Anotace `@GRpcService` označuje třídu jako gRPC service bean.
Protože naše služba dědí z `ProfileServiceGrpc.ProfileServiceImplBase`, můžeme přepisovat metody nadřazené třídy. První metodou, kterou přepíšeme, je `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(); }
Pro zaslání odpovědi klientovi musíme zavolat metodu `onNext` předaného `StreamObserver`. Po odeslání odpovědi, pošleme klientovi signál, že server dokončil práci voláním `onCompleted`. Při odeslání požadavku na server `getCurrentProfile` bude odpověď:
{ "profile_id": "1", "name": "test" }
Nyní se podívejme na stream serveru. V tomto případě klient odešle požadavek na server, který odpoví klientovi proudem zpráv. Například odešle pět požadavků v cyklu. Po odeslání všech zpráv, server pošle klientovi zprávu o úspěšném dokončení streamu.
Metoda serverového streamu bude vypadat takto:
@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(); }
Klient tedy obdrží pět zpráv s `ProfileId`, která se rovná číslu iterace.
{ "profile_id": "0", "name": "" } { "profile_id": "1", "name": "" } … { "profile_id": "4", "name": "" }
Klientský stream funguje podobně jako serverový. Rozdíl je v tom, že tentokrát klient odesílá proud zpráv a server je zpracovává. Server může zprávy zpracovávat okamžitě nebo vyčkat, až klient odešle všechny požadavky a pak je zpracovat.
@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(); } }; }
V klientském streamu musíte klientovi vrátit `StreamObserver`, skrze který server přijímá zprávy. Pokud ve streamu dojde k chybě, bude zavolána metoda `onError`, například, když je spojení nesprávně ukončeno.
Pro implementaci obousměrného streamu je nutné propojit vytváření streamu ze serveru 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(); } }; }
V tomto příkladu, server v reakci na zprávu od klienta, vrátí profil se zvýšeným počtem bodů.
Závěr
Probrali jsme základní možnosti komunikace mezi klientem a serverem pomocí gRPC: implementovali jsme serverový stream, klientský stream i obousměrný stream.
Článek napsal Sergey Golitsyn