Einführung in gRPC-Implementierung mit Java
Lass uns gemeinsam erkunden, wie gRPC in der Programmiersprache Java realisiert wird.
Was ist gRPC? gRPC (Google Remote Procedure Call) ist eine von Google entwickelte, quelloffene RPC-Architektur, die eine besonders schnelle Kommunikation zwischen Microservices ermöglicht. Es erlaubt Entwicklern, Dienste zu integrieren, die in unterschiedlichen Programmiersprachen geschrieben wurden. gRPC nutzt das Protobuf-Messaging-Format (Protocol Buffers), ein effizientes und komprimiertes Format zur Serialisierung strukturierter Daten.
In manchen Anwendungsfällen kann die gRPC-API effizienter sein als die REST-API.
Wir werden nun einen gRPC-Server erstellen. Dazu müssen wir zunächst mehrere .proto-Dateien erstellen, die Dienste und Datenmodelle (DTOs) definieren. Für einen einfachen Server verwenden wir den ProfileService
und den ProfileDescriptor
.
Die Definition des ProfileService
sieht wie folgt aus:
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 unterstützt verschiedene Kommunikationsmuster zwischen Client und Server. Diese werden wir hier im Detail erläutern:
- Normale Serveraufrufe – Anfrage und Antwort.
- Streaming von Daten vom Client zum Server.
- Streaming von Daten vom Server zum Client.
- Bidirektionales Streaming.
Der ProfileService
verwendet den ProfileDescriptor
, der im Import-Bereich der .proto-Datei definiert ist:
syntax = "proto3"; package com.deft.grpc; message ProfileDescriptor { int64 profile_id = 1; string name = 2; }
int64
entspricht dem Datentyplong
in Java und wird für die Profil-ID verwendet.string
ist, wie in Java, eine Variable vom Typ String.
Für das Build-System des Projekts können Gradle oder Maven genutzt werden. In diesem Beispiel verwenden wir Maven. Dies ist erwähnenswert, da die generierten Klassen aus .proto-Dateien sich in Gradle leicht unterscheiden und die Build-Konfiguration entsprechend angepasst werden muss. Um einen einfachen gRPC-Server zu entwickeln, genügt eine einzige Abhängigkeit:
<dependency> <groupId>io.github.lognet</groupId> <artifactId>grpc-spring-boot-starter</artifactId> <version>4.5.4</version> </dependency>
Dieser Starter erleichtert uns einen Großteil der Arbeit.
Das Projekt, das wir entwickeln, wird wie folgt strukturiert sein:
Wir benötigen eine GrpcServerApplication
Klasse, um die Spring Boot-Anwendung zu starten, sowie eine GrpcProfileService
Klasse, welche die Methoden des .proto
-Dienstes implementiert. Um protoc
nutzen und Klassen aus den .proto
-Dateien generieren zu können, wird das protobuf-maven-plugin
zur pom.xml
hinzugefügt. Die Build-Sektion der pom.xml
sollte wie folgt aussehen:
<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
– Pfad zum Verzeichnis der.proto
-Dateien.outputDirectory
– Pfad zum Verzeichnis, in dem die generierten Dateien gespeichert werden.clearOutputDirectory
– Markierung, die verhindert, dass generierte Dateien gelöscht werden.
Nach dieser Konfiguration kann das Projekt gebaut werden. Anschließend finden sich die generierten Dateien im angegebenen Ausgabeverzeichnis. Nun können wir mit der schrittweisen Implementierung von GrpcProfileService
beginnen.
Die Klassendeklaration sieht so aus:
@GRpcService public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase
Die @GRpcService
-Annotation markiert die Klasse als eine gRPC-Service-Bean.
Da unser Dienst von ProfileServiceGrpc.ProfileServiceImplBase
erbt, können wir die Methoden der übergeordneten Klasse überschreiben. Die erste Methode, die wir überschreiben, ist 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(); }
Um dem Client zu antworten, müssen wir die onNext
-Methode auf dem StreamObserver
aufrufen. Nach dem Versenden der Antwort wird dem Client durch den Aufruf von onCompleted
signalisiert, dass die Verarbeitung abgeschlossen ist. Bei einer Anfrage an den Server lautet die Antwort:
{ "profile_id": "1", "name": "test" }
Als nächstes betrachten wir den Server-Stream. In diesem Kommunikationsmodell sendet der Client eine Anfrage an den Server, welcher dann dem Client einen Strom von Nachrichten zurücksendet. Beispielsweise sendet er in einer Schleife fünf Nachrichten. Nachdem das Senden beendet ist, informiert der Server den Client über den erfolgreichen Abschluss des Streams.
Die überschriebene Server-Stream-Methode sieht wie folgt aus:
@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(); }
Folglich empfängt der Client fünf Nachrichten, wobei die profileId
der jeweiligen Antwortnummer entspricht.
{ "profile_id": "0", "name": "" } { "profile_id": "1", "name": "" } … { "profile_id": "4", "name": "" }
Der Client-Stream ist dem Server-Stream sehr ähnlich. Hier sendet der Client einen Strom von Nachrichten, die vom Server verarbeitet werden. Der Server kann die Nachrichten sofort oder nach Empfang aller Anfragen des Clients verarbeiten.
@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(); } }; }
Im Client-Stream muss der StreamObserver
zurückgegeben werden, an den der Server die Nachrichten empfängt. Die Methode onError
wird aufgerufen, falls ein Fehler während der Übertragung aufgetreten ist, wie z.B. ein unerwarteter Abbruch.
Um einen bidirektionalen Stream zu implementieren, müssen wir die Erstellung eines Streams vom Server und vom Client kombinieren.
@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(); } }; }
In diesem Beispiel gibt der Server als Antwort auf jede Nachricht des Clients ein Profil mit einem erhöhten pointCount
zurück.
Zusammenfassung
Wir haben die grundlegenden Optionen für die Nachrichtenübermittlung zwischen Client und Server mit gRPC betrachtet: die Implementierung des Server-Streams, des Client-Streams und des bidirektionalen Streams.
Der Artikel wurde von Sergey Golitsyn verfasst.