1. Slides

2. gRPC in Quarkus

2.1. Dependencies

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-grpc</artifactId>
</dependency>

2.2. Protobuf

In den Folder proto werden alle benötigten proto Dateien abgelegt.

file structure

Mit dem shell command

mvn compile

werden alle benötigten Dateien generiert.

Für dieses Beispiel wird ein Service mit folgenden Methoden definiert.

syntax = "proto3";

service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply) {}
  rpc StreamHello(stream HelloRequest) returns (stream HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

2.3. Einen Stub verwenden

Um einen Stub in einem Quarkus Projekt zu verwenden, muss nur der Name des Services "Injected" werden.

@GrpcClient//(client-name) (1)
Greeter greeter;
1 Wenn der client-name des Stubs nicht angegeben wird, wird stattdessen automatisch der Name der Variable benutzt. In diesem Fall wäre dieser greeter.

Dann können alle Methoden, die in den Protofiles definiert worden sind, aufgerufen werden.

2.3.1. Konfiguration

Der Port und Host des gRPC Service kann mit folgender Konfiguration in den application.properties definiert werden.

quarkus.grpc.clients.<client-name>.host=<host>
quarkus.grpc.clients.<client-name>.port=<port>

Der Host ist dabei per Default "localhost" und der Port ist per Default 9000.

Der client-name wäre in unserem Fall greeter also würde die Konfiguration folgendermaßen aussehen.

quarkus.grpc.clients.greeter.host=localhost
quarkus.grpc.clients.greeter.port=8080

2.4. Einen Service Implementieren

2.4.1. Konfiguration

Falls man den gRPC Service nicht auf einem separaten Server laufen lassen möchte, muss man folgendes in den application.properties eintragen.

quarkus.grpc.server.use-separate-server=false

2.4.2. Implementierung

Um unseren GreeterService nun zu implementieren, müssen wir eine Klasse GreeterService erstellen die unseren gRPC Service implementiert.

@GrpcService
public class GreeterService implements Greeter {

}

Nun müssen die definierten Methoden ausprogrammiert werden.

SayHello

Die Methode SayHello soll eine Begrüßung für einen mitgegebenen Namen generieren und zurückschicken.

    @Override
    public Uni<HelloReply> sayHello(HelloRequest request) {
        return Uni.createFrom()
            .item(
                HelloReply.newBuilder() (1)
                    .setMessage(greet(request.getName())) (2)
                    .build() (3)
            );
    }

In diesem Beispiel müssen wir drei Sachen machen, um unser gRPC Objekt zu kreieren:

1 Einen neuen Builder des HelloReply typen erstellen
2 Unsere Begrüßung in die Eigenschaft message speichern
3 Das Objekt bauen
StreamHello

Die Methode StreamHello soll jede Sekunde eine generierte Begrüßung schicken. Es wird immer der zuletzt geschickte Name verwendet.

    public Multi<HelloReply> streamHello(Multi<HelloRequest> incomingStream) {
        AtomicReference<String> name = new AtomicReference<>(""); (1)
        incomingStream (2)
            .subscribe()
            .with(request -> {
                name.set(request.getName());
            });
        return Multi.createFrom() (3)
            .ticks()
            .every(Duration.ofSeconds(1))
            .map(ignored ->
                HelloReply.newBuilder().setMessage(greet(name.get())).build()
            );
    }

Hier gibt es 3 grobe Abschnitte

1 Eine Referenz auf einen String erstellen, auf die man aus mehreren Threads zugreifen kann
2 Diese Referenz immer dann neu setzten, wenn der Stub/Client einen neuen Namen schickt
3 Alle Sekunden eine Begrüßung mit dem Momentan gesetzten Namen an den Stub/Client schicken

3. Chat Stub

In diesem Abschnitt wir ein Stub für ein bereits implementiertes Backend geschrieben.

Der vorgezeigte Programmflow ist folgender:

client flow

3.1. Skelett

Ein Skelett für diesen Stub kann hier heruntergeladen werden:

3.2. Dependencies

<dependencies>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-grpc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jline</groupId>
        <artifactId>jline-reader</artifactId>
        <version>3.29.0</version>
    </dependency>
</dependencies>

Neben der Quarkus gRPC Dependency brauchen wir auch JLine.

JLine ermöglicht die OS-spezifische Interaktion mit Konsolen.

3.3. Konfiguration

quarkus.package.jar.type=uber-jar

quarkus.grpc.clients.chat.host=winnie.at
quarkus.grpc.clients.chat.port=80

Für den Stub (chat) muss sowohl der Port als auch der Host des Backends spezifiziert werden.

3.4. Protocol Buffer

syntax = "proto3";

option go_package = "/proto";
option java_package = "at.htl.grpc";

service Chat {
  rpc ClaimName(ClaimNameRequest) returns (ClaimNameResponse) {} (1)
  rpc Connect(stream OutgoingMessage) returns (stream IncomingMessage) {} (2)
}

message ClaimNameRequest {
  string name = 1;
}

message ClaimNameResponse {
  string token = 1;
}

message OutgoingMessage {
  string message = 1;
}

message IncomingMessage {
  string name = 1;
  string response = 2;
}

In diesem Proto file sind 2 Prozeduren definiert:

1 Einen Namen reservieren ⇒ JWT als response
2 JWT im Header ⇒ Am Chat teilnehmen

3.5. Implementierung

3.5.1. Statische Message

Da wir in mit mehreren Threads arbeiten, die zugleich auf die Variable message zugreifen, müssen wir diese Variable in dem Globalen Scope definieren

    private static final StringBuilder message = new StringBuilder();

3.5.2. Stub Injection

In unserem Programm muss als erstes der gRPC Stub Injected werden.

    // Inject gRPC stub
    @GrpcClient
    Chat chat;

3.5.3. JWT Abfragen

Dann muss ein Name reserviert werden.

    @Override
    public int run(String... args) {
        // claim name from server
        String token = chat
            .claimName(
                ClaimNameRequest.newBuilder()
                    .setName(String.join(" ", args))
                    .build()
            )
            .await()
            .indefinitely()
            .getToken();

3.5.4. JWT in den Header

Um den JWT dem gRPC konform zu übermitteln, muss er in den Header gespeichert werden.

        // append token in header
        Metadata headers = new Metadata();
        headers.put(
            Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), (1)
            String.format("Bearer %s", token) (2)
        );
1 Den Header Key spezifizieren
2 Den Token als Value angeben

Dieser Header muss nun an den Stub gebunden werden.

        // attach headers to stub
        Chat authorizedStub = GrpcClientUtils.attachHeaders(chat, headers);

Von nun an wird nur mehr der authorizedStub verwendet, da der normale Stub keine Authentifizierung hat.

3.5.5. Output Stream erstellen

Um die Verbindung zu öffnen, muss ein Multi erstellt werden.

        // create output stream
        Multi<OutgoingMessage> outgoingStream = Multi.createFrom()
            .<OutgoingMessage>emitter(ChatMain::sendMessages);

Dieser Multi wird von unserer Methode SendMessages befüllt (seeded).

3.5.6. Verbinden

        // connect to service and start printing incoming messages
        printMessages(authorizedStub.connect(outgoingStream));

Nun wird sich mit dem gRPC Service verbunden und der Stream mit den empfangenen Nachrichten wird direkt an die Methode PrintMessages weitergegeben.

3.5.7. Nachrichten Ausgeben

Da der User auf der Konsole seine eigene Nachricht eingibt, soll nicht über die Nachricht des Users mit anderen Nachrichten geschrieben werden.

    /**
     * Print incoming messages without overwriting the current input line.
     *
     * @param incomingStream stream with incoming messages
     */
    public static void printMessages(Multi<IncomingMessage> incomingStream) {
        incomingStream
            .subscribe()
            .with(incomingMessage ->
                System.out.printf(
                    "\033[2K\r%s: %s\n\rWrite message: %s",
                    incomingMessage.getName(),
                    incomingMessage.getResponse(),
                    message
                )
            );
    }

Wenn eine Nachricht empfangen wird, wird zuerst die momentane Zeile komplett gelöscht. Dann wird die empfangene Nachricht ausgegeben und schlussendlich wird in der nächsten Zeile der Prompt an den User wieder hergestellt.

3.5.8. Nachrichten Senden

    /**
     * Read input from CLI and send it to service.
     *
     * @param emitter the emitter that sends messages back to the service
     */
    public static void sendMessages(
        MultiEmitter<? super OutgoingMessage> emitter
    ) {
        try (Terminal terminal = TerminalBuilder.terminal()) {
            // strip flags from terminal
            terminal.enterRawMode(); (1)

            while (true) {
                try {
                    // fill the message with user input
                    readMessage(terminal); (2)
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }

                // send the message to service
                emitter.emit(
                    (3)
                    OutgoingMessage.newBuilder()
                        .setMessage(message.toString())
                        .build()
                );

                // print finished message line
                System.out.printf("\033[2K\rYou wrote: %s\n\r", message); (4)

                // empty the message
                message.setLength(0); (5)
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
1 Bestimmte Flags werden vom Terminal entfernt, sodass der Input, der in die Konsole geschrieben wird:
  1. sofort freigegeben wird und nicht erst nach einem Enter.

  2. nicht angezeigt wird, so können non-printable Characters auf der Konsole umgangen werden.

2 Als Erstes wird unsere statische message Variable befüllt.
3 Mit dieser Variable wird dann durch den Emitter unsere Nachricht an den gRPC Service geschickt.
4 Die input Zeile wird nun gelöscht und anstatt von `Write Message: ` wird die Zeile mit `You wrote: ` befüllt.
5 Zuletzt wird unsere Variable message wieder zurückgesetzt.

3.5.9. Nachrichten Einlesen

Implementierung
    /**
     * Read string from CLI without control characters.
     *
     * @throws IOException if the connection to the terminal cannot be established
     */
    private static void readMessage(Terminal terminal) throws IOException {
        System.out.print("Write message: ");

        int controlCharCounter = 0;

        while (true) {
            // read one character from the console
            char character = (char) terminal.reader().read();

            if (
                (controlCharCounter == 2 && character == '[') ||
                controlCharCounter == 1
            ) {
                controlCharCounter--;
                continue;
            }

            // stop reading and return
            if (character == '\n' || character == '\r') {
                return;
            }

            // delete single character
            if (character == 127 && !message.isEmpty()) {
                System.out.print("\b \b");
                message.deleteCharAt(message.length() - 1);
            }

            controlCharCounter = 0;

            if (character == 27) {
                controlCharCounter = 2;
            }

            // do not display/send control characters
            if (Character.isISOControl(character)) {
                continue;
            }

            // print character to console and append it to message
            System.out.print(character);

            message.append(character);
        }
    }

In dieser Methode wird jeweils eine Nachricht eingelesen. Die eingabe wird mit Enter beendet.

Der Input des Users gelesen und in die Variable message gespeichert.

Bei einem Enter wird die aus dieser Methode returned.

3.5.10. Ausführung

Da die Applikation stdin blockiert kann sie nicht im dev mode gestartet werden.

Quarkus-CLI
quarkus build --clean && java -jar target/client-1.0-SNAPSHOT-runner.jar <name>
Maven
mvn clean package && java -jar target/client-1.0-SNAPSHOT-runner.jar <name>

4. Quellen