Java gRPC von Grund auf neu

Lassen Sie uns untersuchen, wie gRPC in Java implementiert wird.

gRPC (Google Remote Procedure Call): gRPC ist eine Open-Source-RPC-Architektur, die von Google entwickelt wurde, um eine Hochgeschwindigkeitskommunikation zwischen Microservices zu ermöglichen. gRPC ermöglicht es Entwicklern, in verschiedenen Sprachen geschriebene Dienste zu integrieren. gRPC verwendet das Protobuf-Messaging-Format (Protocol Buffers), ein hocheffizientes, hochgepacktes Messaging-Format zum Serialisieren strukturierter Daten.

Für einige Anwendungsfälle ist die gRPC-API möglicherweise effizienter als die REST-API.

Versuchen wir, einen Server auf gRPC zu schreiben. Zuerst müssen wir mehrere .proto-Dateien schreiben, die Dienste und Modelle (DTO) beschreiben. Für einen einfachen Server verwenden wir ProfileService und ProfileDescriptor.

ProfileService sieht so 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 eine Vielzahl von Client-Server-Kommunikationsoptionen. Wir werden sie alle aufschlüsseln:

  • Normaler Serveraufruf – Anfrage/Antwort.
  • Streaming vom Client zum Server.
  • Streaming vom Server zum Client.
  • Und natürlich der bidirektionale Stream.

Der ProfileService-Dienst verwendet den ProfileDescriptor, der im Importabschnitt angegeben ist:

syntax = "proto3";
package com.deft.grpc;
message ProfileDescriptor {
  int64 profile_id = 1;
  string name = 2;
}
  • int64 ist Long für Java. Lassen Sie die Profil-ID gehören.
  • String – genau wie in Java ist dies eine String-Variable.

Sie können Gradle oder Maven verwenden, um das Projekt zu erstellen. Es ist bequemer für mich, maven zu verwenden. Und weiter wird der Code sein, der maven verwendet. Dies ist wichtig genug, um dies zu sagen, da die zukünftige Generation der .proto-Datei für Gradle etwas anders sein wird und die Build-Datei anders konfiguriert werden muss. Um einen einfachen gRPC-Server zu schreiben, benötigen wir nur eine Abhängigkeit:

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

Es ist einfach unglaublich. Dieser Starter erledigt eine enorme Menge Arbeit für uns.

Das Projekt, das wir erstellen werden, sieht in etwa so aus:

Wir benötigen GrpcServerApplication, um die Spring Boot-Anwendung zu starten. Und GrpcProfileService, das Methoden aus dem .proto-Dienst implementiert. Um protoc zu verwenden und Klassen aus geschriebenen .proto-Dateien zu generieren, fügen Sie protobuf-maven-plugin zu pom.xml hinzu. Der Build-Abschnitt sieht folgendermaßen aus:

<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 – Angabe des Verzeichnisses, in dem sich die .proto-Dateien befinden.
  • outputDirectory – wählen Sie das Verzeichnis aus, in dem die Dateien generiert werden.
  • clearOutputDirectory – ein Flag, das angibt, generierte Dateien nicht zu löschen.

In dieser Phase können Sie ein Projekt erstellen. Als nächstes müssen Sie zu dem Ordner gehen, den wir im Ausgabeverzeichnis angegeben haben. Die generierten Dateien werden dort sein. Jetzt können Sie GrpcProfileService schrittweise implementieren.

Die Klassendeklaration sieht folgendermaßen aus:

@GRpcService
public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase

GRpcService-Anmerkung – Markiert die Klasse als Grpc-Service-Bean.

Da wir unseren Dienst von ProfileServiceGrpc, ProfileServiceImplBase erben, 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 Sie die onNext-Methode auf dem übergebenen StreamObserver aufrufen. Senden Sie nach dem Senden der Antwort ein Signal an den Client, dass der Server die Arbeit anCompleted beendet hat. Beim Senden einer Anfrage an den getCurrentProfile-Server lautet die Antwort:

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

Als nächstes werfen wir einen Blick auf den Server-Stream. Bei diesem Messaging-Ansatz sendet der Client eine Anfrage an den Server, der Server antwortet dem Client mit einem Nachrichtenstrom. Beispielsweise sendet es fünf Anfragen in einer Schleife. Wenn das Senden abgeschlossen ist, sendet der Server eine Nachricht an den Client über den erfolgreichen Abschluss des Streams.

Die überschriebene Server-Stream-Methode sieht folgendermaßen 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();
    }

Somit erhält der Client fünf Nachrichten mit einer ProfileId, die der Antwortnummer entspricht.

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

Der Client-Stream ist dem Server-Stream sehr ähnlich. Erst jetzt überträgt der Client einen Strom von Nachrichten und der Server verarbeitet sie. Der Server kann Nachrichten sofort verarbeiten oder auf alle Anfragen des Clients warten und diese dann 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 müssen Sie den StreamObserver an den Client zurückgeben, an den der Server Nachrichten empfängt. Die onError-Methode wird aufgerufen, wenn im Stream ein Fehler aufgetreten ist. Beispielsweise wurde es falsch beendet.

Um einen bidirektionalen Stream zu implementieren, ist es notwendig, die Erstellung eines Streams vom Server und vom Client zu 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 die Nachricht des Clients ein Profil mit einem erhöhten pointCount zurück.

Fazit

Wir haben die grundlegenden Optionen für das Messaging zwischen einem Client und einem Server mit gRPC behandelt: implementierter Server-Stream, Client-Stream, bidirektionaler Stream.

Der Artikel wurde von Sergey Golitsyn geschrieben