gRPC i Java: Bygg raske & effektive mikrotjenester

Implementering av gRPC i Java: En Dybdegående Guide

La oss undersøke hvordan man kan implementere gRPC i Java-utviklingsmiljøet.

gRPC (Google Remote Procedure Call): gRPC er en RPC-arkitektur med åpen kildekode, utviklet av Google. Den er designet for å muliggjøre høyhastighetskommunikasjon mellom mikrotjenester. gRPC gir utviklere muligheten til å integrere tjenester som er skrevet i ulike programmeringsspråk. Kommunikasjonen i gRPC foregår ved hjelp av meldingsformatet Protobuf (Protocol Buffers), et meget effektivt format for serialisering av strukturert data, som sikrer minimal datastørrelse.

I visse situasjoner kan et gRPC API være mer effektivt enn et tradisjonelt REST API.

Vi skal nå se på hvordan man lager en enkel gRPC-server. Det første steget innebærer å skrive flere .proto-filer, som beskriver tjenestene og datamodellene (DTO-er). For å illustrere dette, vil vi bruke tjenesten `ProfileService` og modellen `ProfileDescriptor`.

Her er hvordan `ProfileService` ser ut:

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 tilbyr ulike kommunikasjonsmodeller mellom klient og server. Disse kan deles inn i:

  • Standard server-anrop – forespørsel og svar.
  • Streaming fra klient til server.
  • Streaming fra server til klient.
  • Toveis streaming.

`ProfileService`-tjenesten bruker `ProfileDescriptor`, som er definert i import-delen:

syntax = "proto3";
package com.deft.grpc;
message ProfileDescriptor {
  int64 profile_id = 1;
  string name = 2;
}
  • `int64` tilsvarer `long` i Java, og brukes til å representere profil-IDen.
  • `string` er en strengvariabel, tilsvarende `String` i Java.

Man kan bruke enten Gradle eller Maven for å bygge prosjektet. Vi vil bruke Maven i dette eksemplet, da det er mer praktisk i dette tilfellet. Dette er viktig å merke seg, da genereringen av .proto-filer og konfigurasjonen av byggefilen vil variere noe for Gradle. For å sette opp en enkel gRPC-server, trenger vi en avhengighet:

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

Denne starteren forenkler prosessen betydelig, og gjør mye av det nødvendige arbeidet for oss.

Strukturen til prosjektet vi skal lage vil være som følger:

Vi trenger `GrpcServerApplication` for å starte Spring Boot-applikasjonen, og `GrpcProfileService` for å implementere metodene definert i .proto-tjenesten. For å generere klasser fra .proto-filene ved hjelp av `protoc`, må vi legge til `protobuf-maven-plugin` i `pom.xml`. Bygge-delen av filen vil se slik ut:

<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` spesifiserer mappen hvor .proto-filene er plassert.
  • `outputDirectory` angir mappen der de genererte filene skal plasseres.
  • `clearOutputDirectory` indikerer om genererte filer skal slettes eller ikke.

På dette punktet kan man bygge prosjektet. Etter det, gå til mappen angitt i `outputDirectory`. Der vil de genererte filene være. Nå kan man begynne å implementere `GrpcProfileService` trinnvis.

Klassedeklarasjonen vil se slik ut:

@GRpcService
public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase

`@GRpcService`-annotasjonen markerer klassen som en gRPC-tjenestekomponent.

Siden vi arver tjenesten vår fra `ProfileServiceGrpc.ProfileServiceImplBase`, kan vi overstyre metodene til den overordnede klassen. Den første metoden vi overstyrer er `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();
    }

For å returnere et svar til klienten, må man kalle `onNext`-metoden på `StreamObserver`-objektet som er sendt inn som parameter. Etter at svaret er sendt, sendes en melding til klienten om at serveren er ferdig med å jobbe ved å kalle `onCompleted()`. Ved en forespørsel til `getCurrentProfile`-serveren vil svaret se slik ut:

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

La oss så se på server-streaming. Med denne tilnærmingen sender klienten en forespørsel til serveren, og serveren svarer med en strøm av meldinger. For eksempel sender serveren fem forespørsler i en løkke. Når sendingen er fullført, sender serveren en melding til klienten om at strømmen er fullført.

Den overstyrte `serverStream`-metoden ser slik ut:

@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();
    }

Dermed vil klienten motta fem meldinger med en `ProfileId` som tilsvarer løpenummeret:

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

Klient-streaming er veldig likt server-streaming. I dette tilfellet sender klienten en strøm av meldinger til serveren, som behandler dem. Serveren kan behandle meldinger umiddelbart, eller vente til alle forespørsler fra klienten er mottatt før de behandles.

    @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();
            }
        };
    }

I klient-streaming må man returnere en `StreamObserver` til klienten, som serveren vil motta meldinger på. `onError`-metoden vil bli kalt hvis det oppstår en feil i strømmen, for eksempel om den avsluttes feil.

For å implementere en toveis strøm, må man kombinere opprettelsen av en strøm fra serveren og klienten.

@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();
            }
        };
    } 

I dette eksemplet, som respons på klientens melding, vil serveren returnere en profil med en økende `pointCount`.

Oppsummering

Vi har gjennomgått de fundamentale meldingstilnærmingene mellom en klient og en server ved hjelp av gRPC, og vi har implementert server-streaming, klient-streaming og toveis-streaming.

Artikkelen er skrevet av Sergey Golitsyn