Java gRPC od podstaw

Przyjrzyjmy się, jak zaimplementować gRPC w Javie.

gRPC (Google Remote Procedure Call): gRPC to architektura RPC typu open source opracowana przez Google w celu umożliwienia szybkiej komunikacji między mikrousługami. gRPC umożliwia programistom integrację usług napisanych w różnych językach. gRPC używa formatu komunikatów Protobuf (bufory protokołów), wysoce wydajnego formatu komunikatów o dużym upakowaniu do serializacji danych strukturalnych.

W niektórych przypadkach użycia interfejs API gRPC może być bardziej wydajny niż interfejs API REST.

Spróbujmy napisać serwer na gRPC. Najpierw musimy napisać kilka plików .proto opisujących usługi i modele (DTO). W przypadku prostego serwera użyjemy ProfileService i ProfileDescriptor.

Usługa profilu wygląda 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 obsługuje różne opcje komunikacji klient-serwer. Podzielimy je wszystkie:

  • Zwykłe wywołanie serwera – żądanie/odpowiedź.
  • Przesyłanie strumieniowe z klienta na serwer.
  • Przesyłanie strumieniowe z serwera do klienta.
  • I oczywiście strumień dwukierunkowy.

Usługa ProfileService używa elementu ProfileDescriptor, który jest określony w sekcji importu:

syntax = "proto3";
package com.deft.grpc;
message ProfileDescriptor {
  int64 profile_id = 1;
  string name = 2;
}
  • int64 jest długie dla Javy. Niech identyfikator profilu należy.
  • String – podobnie jak w Javie, jest to zmienna łańcuchowa.

Możesz użyć Gradle lub maven do zbudowania projektu. Wygodniej jest mi używać maven. A dalej będzie kod używający maven. Jest to wystarczająco ważne, aby powiedzieć, ponieważ w przypadku Gradle przyszła generacja .proto będzie nieco inna, a plik kompilacji będzie musiał zostać skonfigurowany inaczej. Do napisania prostego serwera gRPC potrzebujemy tylko jednej zależności:

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

To po prostu niesamowite. Ten starter wykonuje dla nas ogromną pracę.

Projekt, który stworzymy będzie wyglądał mniej więcej tak:

Potrzebujemy GrpcServerApplication do uruchomienia aplikacji Spring Boot. Oraz GrpcProfileService, która zaimplementuje metody z usługi .proto. Aby używać protoc i generować klasy z zapisanych plików .proto, dodaj protobuf-maven-plugin do pom.xml. Sekcja kompilacji będzie wyglądać następująco:

<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 – określenie katalogu, w którym znajdują się pliki .proto.
  • outputDirectory – wybierz katalog, w którym zostaną wygenerowane pliki.
  • clearOutputDirectory – flaga informująca o nie kasowaniu wygenerowanych plików.

Na tym etapie możesz zbudować projekt. Następnie musisz przejść do folderu, który określiliśmy w katalogu wyjściowym. Wygenerowane pliki będą tam. Teraz możesz stopniowo wdrażać GrpcProfileService.

Deklaracja klasy będzie wyglądać następująco:

@GRpcService
public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase

Adnotacja GRpcService — oznacza klasę jako komponent bean grpc-service.

Ponieważ dziedziczymy naszą usługę z ProfileServiceGrpc, ProfileServiceImplBase, możemy zastąpić metody klasy nadrzędnej. Pierwszą metodą, którą zastąpimy, 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 odpowiedzieć klientowi, musisz wywołać metodę onNext na przekazanym StreamObserverze. Po wysłaniu odpowiedzi wyślij sygnał do klienta, że ​​serwer zakończył pracę naCompleted. Podczas wysyłania żądania do serwera getCurrentProfile odpowiedź będzie następująca:

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

Następnie spójrzmy na strumień serwera. W przypadku tego podejścia do przesyłania wiadomości klient wysyła żądanie do serwera, a serwer odpowiada klientowi strumieniem komunikatów. Na przykład wysyła pięć żądań w pętli. Po zakończeniu wysyłania serwer wysyła do klienta wiadomość o pomyślnym zakończeniu strumienia.

Zastąpiona metoda 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 ProfileId równym numerowi odpowiedzi.

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

Strumień klienta jest bardzo podobny do strumienia serwera. Dopiero teraz klient przesyła strumień komunikatów, a serwer je przetwarza. Serwer może przetwarzać wiadomości natychmiast lub czekać na wszystkie żądania od klienta, a następnie je przetwarzać.

    @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 strumieniu Client musisz zwrócić StreamObserver do klienta, do którego serwer będzie otrzymywał wiadomości. Metoda onError zostanie wywołana, jeśli w strumieniu wystąpił błąd. Na przykład zakończył się nieprawidłowo.

Aby zaimplementować strumień dwukierunkowy, konieczne jest połączenie tworzenia strumienia 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ść klienta serwer zwróci profil ze zwiększoną liczbą punktów.

Wniosek

Omówiliśmy podstawowe opcje przesyłania komunikatów między klientem a serwerem za pomocą gRPC: zaimplementowany strumień serwera, strumień klienta, strumień dwukierunkowy.

Artykuł został napisany przez Siergieja Golicyna