Java gRPC von Grund auf neu

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 Datentyp long 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.