gRPC mit separaten git Repositories



gRPC ist ein Remote-Procedure-Call Framework von Google baierend auf protobuf messages.

Diese Datenstrukturen werden mit Hilfe des HTTP/2 Protocols zwischen Client und Server ausgetauscht. Im Gegensatz zu REST, bei dem der Datentransfer mit Hilfe von JSON Objekten im Textformat geschieht. Der Vorteil von gRPC gegenüber REST ist die effizientere Datenübertragung, da sie binär erfolgt, und damit kompakter und weniger redundant ist.

Vorraussetzungen

Für dieses Tutorial wird davon ausgegangen, dass bereits Vorwissen aus folgenden Bereichen vorhanden ist:

  1. gRPC und protobuf
  2. git
  3. Rust
  4. Flutter

In diesem Tutorial zeige ich, wie man einen gPRC Server in Rust entwickelt und einen gRPC Client in Flutter, wobei das Projekt in drei Teile zerfällt:

  1. Die protobuf messages
  2. Der gRPC Server in Rust
  3. Der gRPC Client in Flutter

Die Struktur

Alle diese drei Teile befinden sich in ihren eigenen git-Repositories:

  1. grpc-experiments-messages
  2. grpc-experiments-server-rs
  3. grpc-experiments-client-flutter

Um auf die Protobuf Beschreibungen zugreifen zu können, binden die beiden Code-Projekte (grpc-experiments-server-rs und grpc-experiments-client-flutter) das Protobuf-Projekt (grpc-experiments-messages) als git Sub-Module ein.

Protobuf messages und gRPC Service Definition in grpc-experiments-messages

Die Projektstruktur sieht wie folgt aus:

├── LICENSE
├── proto
│   ├── demo_request.proto
│   ├── demo_response.proto
│   └── service.proto
└── README.md

Im Verzeichnis proto befinden sich die Beschreibungsdateien für die Messages (demo_request.proto und demo_response.proto) und den Service (service.proto).

Zur besseren Strukturierung bindet die Service Beschreibung die Message mit import ein.

Anmerkung

Bei Verwendung der proto-3 Extension für Visual Studio Code muss der --proto_path in den Einstellungen in .vscode/settings.json gesetzt werden:

{
    "protoc": {
        "options": [
            "--proto_path=proto"
        ]
    }
}

Service Beschreibung

Der folgende Service enthält für jede Funktionsart, die gRPC unterstützt, eine Funktion:

syntax = "proto3";
package demo;

import "demo_request.proto";
import "demo_response.proto";

service DemoService {
  rpc Unary(DemoRequest) returns (DemoResponse);
  rpc ServerStreaming(DemoRequest) returns (stream DemoResponse);
  rpc ClientStreaming(stream DemoRequest) returns (DemoResponse);
  rpc BidriectionalStreaming(stream DemoRequest) returns (stream DemoResponse);
}

Die Messages

Zu Demonstrationszwecken sind diese Messages sehr einfach gehalten. DemoRequest dient dabei als Parameter für alle Funktionen des Service, und DemoResponse wird von allen Funktionen als Ergebnis zurückgegeben.

DemoRequest.proto

syntax = "proto3";
package demo;

message DemoRequest {
    string query = 1;
}

DemoResponse.proto

syntax = "proto3";
package demo;

message DemoResponse {
    string result = 1;
}

Der gRPC Server in Rust grpc-experiments-server-rs

Nachdem das Projekt mit cargo init . initialisiert wurde, sieht die Projektstruktur wie folgt aus:

.
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
└── src
    └── main.rs

Um nun auf die Protobuf message zugreifen zu können, müssen wir das Projekt grpc-experiments-messages als Sub-Module einbinden:

git submodule add https://github.com/Vardaleb/grpc-experiments-messages.git

Anschließend sieht die Projektstruktur folgendermaßen aus:

.
├── Cargo.lock
├── Cargo.toml
├── grpc-experiments-messages
│   ├── LICENSE
│   ├── proto
│   └── README.md
├── LICENSE
├── README.md
└── src
    └── main.rs

Dependencies

Für die gRPC Implementierung verwende ich tonic. Ein gutes Tutorial dafür findet sich bei Thorsten Hans

Die Cargo.toml muss diese Dependencies enthalten:

[dependencies]
tonic = "0.11"
prost = "0.12"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
tokio-stream = "0.1"
async-stream = "0.3"

[build-dependencies]
tonic-build = "0.11"

Die Abhängigkeiten im Detail

Crate Beschreibung
tonic Das gRPC Framework
prost protobuf compiler
tokio Eine Runtime zum Ausführen asynchroner Funtionen
tokio-stream Funktionen für Streams
async-stream Macros zum Erzeugen asynchroner Streams

Die Abhängigkeit zu tonic-build wird benötigt, damit aus den Protobuf Dateien die gRPC Stubs für Rust generiert werden können. Dazu erstellen wir im Root-Verzeichnis des Projekts eine build.rs Datei:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure().compile(
        &["grpc-experiments-messages/proto/service.proto"],
        &["grpc-experiments-messages/proto"],
    )?;
    Ok(())
}

Serverimplementierung

Dadurch, dass die Generierung der protobuf-Stubs bei jedem Build automatisch erzeugt werden, müssen die erzeugten Rust-Sourcen nicht explizit im Projekt vorhanden sein. Allerdings sind die erzeugten Dateien tief im target Verzeichnis vergraben und erschweren so das Untersuchen der Dateien. Im Folgenden ist aufgeführt, nach welchem Schema tonic die Messages und Service-Definitionen in Rust umsetzt.

Zur Implementierung des gRPC-Backends benötigen wir die Schnittsstelle, die wir implementieren müssen. Wie zu erwarten ist das für Rust ein trait, der von tonic erzeugt wird.

Damit wir Zugriff auf die generierten Stubs erhalten, müssen wir ein Modul erzeugen, in dem die generierten Artefakte bereitgestellt werden. Der Name des Moduls kann frei gewählt werden, aber um Verwirrung zu vermeiden, sollte der Name des Moduls immer dem Namen des package aus der .proto Datei entsprechen. Also erzeugen wir für unser Beispiel eine Datei src/demo.rs und weisen tonic an, das Package demo einzubinden:

tonic::include_proto!("demo");

Unsere Serverimplementierung aus src/server.rs kann dann alle generierten Stubs mit use einbinden:

pub mod demo;
use demo::{
    demo_service_server::{DemoService, DemoServiceServer},
    DemoRequest, DemoResponse,
};

Der Namespace, den tonic für Services generiert, folgt folgendem Schema: Alle Namen werden in Kleinbuchstaben umgewandelt und CamelCase wird durch snake_case ersetzt. Aus dem DemoService und den importierten Protobuf-Dateien aus der service.proto erzeugt tonic folgendes:

  • Die Namespaces demo_service_server und demo_service_client
  • Den Trait demo_service_server::DemoService
  • Den gRPC Server demo_service_server::DemoServiceServer
  • Den gRPC Client demo_service_client::DemoServiceClient
  • Die Messages DemoRequest und DemoResponse

Service Implementierung

Um den generierten Trait DemoService zu implementieren, erstellen wir ein struct und nennen es DemoServiceImpl:

#[derive(Debug, Default)]
pub struct DemoServiceImpl {}

Für diesen struct implementieren wir dann die vier Funktionen des DemoService Traits:

#[tonic::async_trait]
impl DemoService for DemoServiceImpl {
    /// A unary gRPC function.
    ///
    /// From the service defintion:
    /// ```proto
    /// service DemoService {
    ///   rpc Unary(DemoRequest) returns (DemoResponse);
    /// }
    /// ```
    async fn unary(
      &self,
      request: Request<DemoRequest>)
      -> Result<Response<DemoResponse>, Status> {
      // for implementation, go to https://github.com/Vardaleb/grpc-experiments-server-rs/blob/main/src/server.rs
    }

    /// A server streaming gRPC function.
    ///
    /// From the service defintion:
    /// ```proto
    /// service DemoService {
    ///   rpc ServerStreaming(DemoRequest) returns (stream DemoResponse);
    /// }
    /// ```
    type ServerStreamingStream = ReceiverStream<Result<DemoResponse, Status>>;
    async fn server_streaming(
        &self,
        request: Request<DemoRequest>,
    ) -> Result<Response<Self::ServerStreamingStream>, Status> {
      // for implementation, go to https://github.com/Vardaleb/grpc-experiments-server-rs/blob/main/src/server.rs
    }

    /// A client streaming gRPC function.
    ///
    /// From the service defintion:
    /// ```proto
    /// service DemoService {
    ///   rpc ClientStreaming(stream DemoRequest) returns (DemoResponse);
    /// }
    /// ```
    async fn client_streaming(
        &self,
        request: Request<Streaming<DemoRequest>>,
    ) -> Result<Response<DemoResponse>, Status> {
      // for implementation, go to https://github.com/Vardaleb/grpc-experiments-server-rs/blob/main/src/server.rs
    }

    /// A bidirectional streaming gRPC function.
    ///
    /// From the service defintion:
    /// ```proto
    /// service DemoService {
    ///   rpc BidirectionalStreaming(stream DemoRequest) returns (stream DemoResponse);
    /// }
    /// ```
    type BidirectionalStreamingStream =
        Pin<Box<dyn Stream<Item = Result<DemoResponse, Status>> + Send + 'static>>;
    async fn bidirectional_streaming(
        &self,
        request: Request<Streaming<DemoRequest>>,
    ) -> Result<Response<Self::BidirectionalStreamingStream>, Status> {
      // for implementation, go to https://github.com/Vardaleb/grpc-experiments-server-rs/blob/main/src/server.rs
    }
}

Hauptfunktion

Um den gRPC Server zu starten, ereugen wir eine asynchrone tokio Runtime für die Hauptfunktion und starten darin unseren DemoServiceServer:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse()?;
    let demoserviceimpl = DemoServiceImpl::default();

    Server::builder()
        .add_service(DemoServiceServer::new(demoserviceimpl))
        .serve(addr)
        .await?;

    Ok(())
}

Der gRPC Client in Flutter grpc-experiments-client-flutter

Nachdem das Projekt mit flutter create client erzeugt wurde, binden wir das Projekt grpc-experiments-messages mit den Protobuf Definitionen als Submodule ein:

git submodule add https://github.com/Vardaleb/grpc-experiments-messages.git

Das Flutter Projekt befindet sich im Verzeichnis client, dort können wir die Stubs aus den protobuf-Dateien generieren. Dazu habe ich das Shell-Skript generate_stubs.sh erstellt:

#/bin/sh

# Generated protobuf/gRPC stubs
echo "Generating stubs from protobuf..."
SRC=../grpc-experiments-messages/proto
DST=lib/src/generated
if [ ! -d $DST ]; then
  mkdir -p $DST
fi

protoc --dart_out=grpc:lib/src/generated -I$SRC \
	$SRC/*.proto

Nachdem wir die Stubs mit sh generate_stubs.sh generiert haben, sieht die Projektstruktur wie folgt aus (Auschnitt):

.
├── client
│   ├── build.sh
│   ├── lib
│   │   ├── main.dart
│   │   └── src
│   │       ├── demo_service.dart
│   │       ├── generated
│   │       │   ├── demo_request.pb.dart
│   │       │   ├── demo_request.pbenum.dart
│   │       │   ├── demo_request.pbjson.dart
│   │       │   ├── demo_response.pb.dart
│   │       │   ├── demo_response.pbenum.dart
│   │       │   ├── demo_response.pbjson.dart
│   │       │   ├── service.pb.dart
│   │       │   ├── service.pbenum.dart
│   │       │   ├── service.pbgrpc.dart
│   │       │   └── service.pbjson.dart
│   │       ├── log.dart
│   │       └── ui
│   │           ├── client.dart
│   │           ├── client_home_page.dart
│   │           ├── output_area.dart
│   │           └── sidebar.dart
│   ├── pubspec.lock
│   ├── pubspec.yaml
│   ├── README.md
│   ├── test
│   │   └── widget_test.dart
├── grpc-experiments-messages
│   ├── LICENSE
│   ├── proto
│   │   ├── demo_request.proto
│   │   ├── demo_response.proto
│   │   └── service.proto
│   └── README.md
├── LICENSE
└── README.md

Dependencies

Die pubspec.yaml muss diese Dependencies (neben weiteren) enthalten:

dependencies:
  grpc: ^3.2.4
  protobuf: ^3.1.0

Die Abhängikeiten im Detail

Pakage Beschreibung
grpc Das gRPC Framework
protobuf protobuf Unterstützung

Clientimplementierung

Zu einfacheren Anwendung der gRPC Funktionen, habe ich eine Singleton Klasse DemoService entwickelt:

import 'package:client/src/generated/demo_request.pb.dart';
import 'package:client/src/generated/service.pbgrpc.dart';
import 'package:client/src/log.dart';
import 'package:grpc/grpc.dart';

class DemoService {
  static final DemoService _instance = DemoService._internal();
  late ClientChannel _channel;
  late DemoServiceClient _stub;

  DemoService._internal() {
    _init();
  }

  String _host = 'localhost';
  int _port = 50051;
  void _init() {
    _channel = ClientChannel(_host,
        port: _port,
        options:
            const ChannelOptions(credentials: ChannelCredentials.insecure()));
    _stub = DemoServiceClient(_channel);
  }

  factory DemoService() => _instance;

  static DemoService get instance => _instance;
  DemoServiceClient get stub => _stub;

  set host(String value) => _host = value;
  set port(int value) => _port = value;

  Future<void> reconnect() async {
    await _channel.shutdown();
    _init();
  }

  Future<String> unary() async {
    String result = "";
    try {
      var time = DateTime.now().toString();
      var response = await stub.unary(DemoRequest()..query = time);
      result = "-> $time\n<- ${response.result}";
    } catch (e) {
      Log.instance.e(e);
    }
    return result;
  }

  serverStreaming() async {
    String result = "";
    try {
      var time = DateTime.now().toString();
      var responses = stub.serverStreaming(DemoRequest()..query = time);
      result = "-> $time\n";
      await for (var response in responses) {
        result = "$result<-${response.result}\n";
      }
    } catch (e) {
      Log.instance.e(e);
    }
    return result;
  }

  Future<String> clientStreaming() async {
    String result = "";
    try {
      Stream<DemoRequest> createRequest() async* {
        var letters = ["A", "B", "C"];
        for (var letter in letters) {
          var query = letter;
          result = "$result->$query\n";
          yield DemoRequest(query: query);
        }
      }

      var response = await stub.clientStreaming(createRequest());
      result = "$result\n<-${response.result}";
    } catch (e) {
      Log.instance.e(e);
    }
    return result;
  }

  Future<String> bidirectionalStreaming() async {
    String result = "";
    try {
      Stream<DemoRequest> createRequest() async* {
        var letters = ["x", "y", "z"];
        for (var letter in letters) {
          var query = letter;
          result = "$result->$query\n";
          yield DemoRequest(query: query);
        }
      }

      var responses = stub.bidirectionalStreaming(createRequest());
      await for (var response in responses) {
        result = "$result\n<-${response.result}";
      }
    } catch (e) {
      Log.instance.e(e);
    }
    return result;
  }
}

Die Aufrufe der Service Funktionen sehen z.B. wie folgt aus:

print(await DemoService.instance.unary());
print(await DemoService.instance.serverStreaming());
print(await DemoService.instance.clientStreaming());
print(await DemoService.instance.bidirectionalStreaming());

Zusammenfassung

Um die Entwicklung von Protobuf Messages und Service Definitionen sauber von der Implementierung des Servers und der Clients zu trennen, sollten die Beschreibungsdateien in einem separaten git Repository vorgehalten, und in den Implementierungsprojekten als Sub-Module eingebunden werden.

Zum Ausführen rufen wir folgende Befehle in den jeweiligen Verzeichnissen auf:

$ grpc-experiments-server-rs > cargo run --bin server
$ grpc-experiments-client-flutter/client > flutter run -d linux
rust  grpc  flutter