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:
- gRPC und protobuf
- git
- Rust
- 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:
- Die protobuf messages
- Der gRPC Server in Rust
- Der gRPC Client in Flutter
Die Struktur
Alle diese drei Teile befinden sich in ihren eigenen git-Repositories:
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
unddemo_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
undDemoResponse
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