Java gRPC från Scratch

By rik

Implementera gRPC i Java: En Djupdykning

Låt oss utforska processen att implementera gRPC (Google Remote Procedure Call) i Java. Vi ska granska de grundläggande koncepten och steg för steg skapa en enkel gRPC-server.

Vad är gRPC?

gRPC, utvecklat av Google, är ett ramverk för fjärrproceduranrop med öppen källkod. Det är utformat för att möjliggöra snabb och effektiv kommunikation mellan mikrotjänster. En av de stora fördelarna med gRPC är möjligheten att integrera tjänster skrivna i olika programmeringsspråk. gRPC använder Protobuf (Protocol Buffers) som sitt meddelandeformat, vilket är ett högeffektivt och kompakt sätt att serialisera strukturerad data.

För vissa typer av applikationer och användningsfall kan gRPC-baserade API:er erbjuda betydande prestandafördelar jämfört med traditionella REST API:er.

Skapa en gRPC-server

Låt oss börja med att skapa en enkel gRPC-server. Först behöver vi definiera våra tjänster och datamodeller (DTO) i .proto-filer. I detta exempel kommer vi att använda en `ProfileService` och en `ProfileDescriptor`.

Definition av ProfileService

Vår `ProfileService` ser ut som följer:

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 stöder flera olika kommunikationsmönster mellan klient och server. Dessa inkluderar:

  • Normalt serveranrop (begäran/svar)
  • Klientströmmar (klienten skickar en ström av meddelanden till servern)
  • Serverströmmar (servern skickar en ström av meddelanden till klienten)
  • Dubbelriktade strömmar (både klient och server kan skicka strömmar av meddelanden)

Tjänsten `ProfileService` använder `ProfileDescriptor`, som definieras i importsektionen.

Definition av ProfileDescriptor

Här är hur `ProfileDescriptor` ser ut:

syntax = "proto3";
package com.deft.grpc;

message ProfileDescriptor {
  int64 profile_id = 1;
  string name = 2;
}
     
  • `int64` motsvarar `Long` i Java, och representerar profilens ID.
  • `String` är, precis som i Java, en strängvariabel och används för namnet.

Konfiguration med Maven

För att bygga projektet kan du använda antingen Gradle eller Maven. I det här exemplet använder vi Maven. Det är viktigt att notera att konfigurationen kan skilja sig något om du använder Gradle, speciellt vid genereringen av .proto-filer.

För att skapa en enkel gRPC-server behöver vi följande beroende:

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

Denna starter gör mycket av arbetet bakom kulisserna åt oss och förenklar utvecklingen.

Projektstruktur

Projektstrukturen ser ungefär ut så här:

src/main/java Plats för Java-källkod.
src/main/proto Plats för .proto-filer.

Vi behöver en `GrpcServerApplication` för att starta Spring Boot-applikationen, och en `GrpcProfileService` som implementerar metoderna som definierats i .proto-filen. För att använda protoc och generera klasser från .proto-filer, lägger vi till `protobuf-maven-plugin` till `pom.xml`. Byggsektionen i `pom.xml` ser ut som följer:

<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` – anger katalogen där .proto-filerna finns.
  • `outputDirectory` – anger katalogen där de genererade filerna kommer att placeras.
  • `clearOutputDirectory` – är en flagga som indikerar om man ska rensa genererade filer. I detta fallet ska den inte rensa.

Nu kan projektet byggas. Efter bygget hittar du de genererade filerna i den katalogen som du angav. Därefter kan du implementera `GrpcProfileService`.

Implementera GrpcProfileService

Klassdeklarationen för `GrpcProfileService` ser ut så här:

@GRpcService
public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase
    

`@GRpcService`-annotationen markerar klassen som en gRPC-serviceböna. Genom att ärva från `ProfileServiceGrpc.ProfileServiceImplBase`, kan vi åsidosätta metoderna från den överordnade klassen. Låt oss börja med att åsidosätta `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();
    }
    

För att skicka ett svar till klienten anropar vi `onNext`-metoden på den medskickade `StreamObservern`. När svaret har skickats meddelas klienten att servern är klar genom `onCompleted`. Ett anrop till `getCurrentProfile`-servern resulterar i följande svar:

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

Serverström

Med serverström skickar klienten en förfrågan till servern och servern svarar med en ström av meddelanden. I vårt exempel skickar vi fem meddelanden i en loop. När strömmen är klar, meddelar servern klienten.

Den åsidosatta `serverStream`-metoden ser ut så här:

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

Klienten kommer därmed att ta emot fem meddelanden med olika `profileId`:

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

Klientström

Klientström är snarlik serverström, med den skillnaden att klienten skickar en ström av meddelanden och servern bearbetar dem. Servern kan behandla meddelanden direkt eller vänta på alla meddelanden innan bearbetning.

    @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 klientströmmen returnerar servern en `StreamObserver` till klienten, som tar emot meddelanden. Metoden `onError` anropas om något fel uppstår, till exempel om strömmen avslutas felaktigt.

Dubbelriktad ström

För att implementera dubbelriktad ström kombineras skapandet av strömmar från både server och klient.

@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 detta exempel svarar servern med en profil med ett ökat ID-nummer som svar på varje meddelande från klienten.

Slutsats

Vi har nu gått igenom de grundläggande koncepten och alternativen för meddelandehantering mellan klient och server med gRPC, inklusive implementation av serverström, klientström och dubbelriktad ström.

Artikeln skriven av Sergey Golitsyn