A REST API-k után egyre több csapat fordul a gRPC felé, amikor teljesítmény, típusbiztonság és streaming támogatás a cél. Rustban ehhez a tonic crate a de facto standard, ami a Tokio ökoszisztémára épül, és a Protocol Buffers alapú kódgenerálást gyerekjátékká teszi. Ebben a cikkben végigmegyünk egy teljes, működő gRPC szolgáltatáson: definiálunk egy proto fájlt, generálunk hozzá Rust kódot, megírjuk a szervert és a klienst, végül belenézünk a streaming végpontokba is.
A gRPC és Protocol Buffers alapfogalmai
A gRPC egy Google által kifejlesztett RPC (Remote Procedure Call) keretrendszer, ami HTTP/2 felett működik, és bináris szerializációt használ a Protocol Buffers (protobuf) formátumban. Ez két nagy előnnyel jár a klasszikus JSON-alapú REST-hez képest:
- Kompaktabb üzenetek – a bináris kódolás sokkal kisebb payloadot eredményez.
- Erős típusosság – a
.protofájl a kontraktus, amiből mind a szerver, mind a kliens oldali kód generálódik, így elkerülhető a séma-drift.
A gRPC natívan támogatja a streaming kommunikációt is: unary (egy kérés – egy válasz), server streaming, client streaming és bidirectional streaming módokat is. Rustban ez különösen jól illik az async/await és a Stream trait világába.
A tonic a prost crate-et használja a protobuf szerializációhoz, és a tokio runtime-ra épül. Ha még nem dolgoztál Tokio-val, most jó alkalom megismerkedni vele.
Proto fájl definiálása és kódgenerálás tonic-build-del
Kezdjük egy egyszerű Greeter szolgáltatással. Hozzunk létre egy proto/greeter.proto fájlt:
syntax = "proto3";
package greeter;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc StreamNumbers (NumberRequest) returns (stream NumberReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
message NumberRequest {
uint32 count = 1;
}
message NumberReply {
uint32 value = 1;
}
A Cargo.toml-ban a következő függőségekre lesz szükségünk:
[dependencies]
tonic = "0.9"
prost = "0.11"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
futures = "0.3"
[build-dependencies]
tonic-build = "0.9"
A kódgenerálást a build.rs fájlban végezzük el, ami build time-ban lefordítja a proto fájlt Rust struct-okra és trait-ekre:
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/greeter.proto")?;
Ok(())
}
Ha lefuttatod a cargo build-et, a target/debug/build/.../out mappában (vagy a OUT_DIR alatt) megjelennek a generált fájlok. Ezeket a tonic::include_proto! makróval húzhatjuk be a saját kódunkba, nincs szükség kézi importra.
Ha több proto fájlod van, vagy külső importokat használsz (import "other.proto"), a tonic_build::configure() builder API-val finomabban is beállíthatod a generálást, például a kimeneti mappát vagy extra derive attribútumokat.
Szerver oldali szolgáltatás implementálása
A generált kód tartalmaz egy Greeter trait-et (a service definícióból), amit nekünk kell implementálnunk. Mivel a stabil Rust ezen a napon még nem támogatja natívan az async fn használatát trait-ekben, a tonic a #[tonic::async_trait] makrót adja hozzá, ami alá dolgozva boxolt future-öket generál – ez teszi lehetővé, hogy a trait metódusaink async kulcsszóval íródjanak.
use tonic::{transport::Server, Request, Response, Status};
pub mod greeter {
tonic::include_proto!("greeter");
}
use greeter::greeter_server::{Greeter, GreeterServer};
use greeter::{HelloRequest, HelloReply};
#[derive(Default)]
pub struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloReply>, Status> {
let name = request.into_inner().name;
let reply = HelloReply {
message: format!("Szia, {name}!"),
};
Ok(Response::new(reply))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse()?;
let greeter = MyGreeter::default();
Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
Ez a kód egy teljes gRPC szervert futtat a [::1]:50051 porton. A Server::builder() API kellemesen olvasható, és ha később több szolgáltatást akarsz regisztrálni, egyszerűen újabb .add_service(...) hívásokat láncolhatsz.
Ne felejtsd el, hogy a Status típus a gRPC hibakódokat (pl. Status::not_found, Status::invalid_argument) modellezi. Ha a szerver oldali logikában panic!-olsz, a kliens csak egy generikus "internal error"-t fog látni – érdemes explicit hibakezelést írni.
Kliens írása a generált stub alapján
A klienshez ugyanazt a generált modult használjuk, csak most a greeter_client modulból importáljuk a GreeterClient struct-ot. A connect metódus aszinkron, és egy Channel-t hoz létre a háttérben.
pub mod greeter {
tonic::include_proto!("greeter");
}
use greeter::greeter_client::GreeterClient;
use greeter::HelloRequest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = GreeterClient::connect("http://[::1]:50051").await?;
let request = tonic::Request::new(HelloRequest {
name: "Rust fejlesztő".into(),
});
let response = client.say_hello(request).await?;
println!("Válasz: {}", response.into_inner().message);
Ok(())
}
A generált GreeterClient minden proto service metódusra egy-egy Rust metódust ad, teljes típusossággal. Nincs kézzel írt JSON parsing, nincs elgépelt mezőnév – a fordító azonnal jelezné, ha a HelloRequest struct mezői nem egyeznének a proto definícióval.
Streaming végpontok bemutatása egyszerű példán
A proto fájlunkban már definiáltunk egy server streaming metódust is: StreamNumbers. Ez azt jelenti, hogy egy kérésre a szerver több választ küld vissza egymás után, amíg a stream lezárul. Ehhez a Greeter trait-ben egy társított típust (associated type) is meg kell adnunk, ami a válasz stream konkrét típusa.
use std::pin::Pin;
use futures::Stream;
use tokio::sync::mpsc;
use tonic::{Request, Response, Status};
use greeter::{NumberReply, NumberRequest};
use greeter::greeter_server::Greeter;
type NumberStream = Pin<Box<dyn Stream<Item = Result<NumberReply, Status>> + Send>>;
#[tonic::async_trait]
impl Greeter for MyGreeter {
type StreamNumbersStream = NumberStream;
async fn stream_numbers(
&self,
request: Request<NumberRequest>,
) -> Result<Response<Self::StreamNumbersStream>, Status> {
let count = request.into_inner().count;
let (tx, rx) = mpsc::channel(4);
tokio::spawn(async move {
for i in 0..count {
let reply = NumberReply { value: i };
if tx.send(Ok(reply)).await.is_err() {
break;
}
}
});
let output_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Response::new(Box::pin(output_stream) as Self::StreamNumbersStream))
}
}
Itt egy mpsc::channel-t használunk arra, hogy egy külön Tokio taskból küldjük a válaszokat, miközben a ReceiverStream becsomagolja azt egy Stream trait-et implementáló típusba. A kliens oldalon a fogadás egyszerű: a client.stream_numbers(request).await? egy Response<Streaming<NumberReply>>-t ad vissza, amit while let Some(item) = stream.message().await? { ... } mintával tudsz iterálni.
A Pin<Box<dyn Stream<...> + Send>> boilerplate elkerülhetetlen ezen a napon, mert a impl Trait társított típusként (GAT-okkal kombinálva) még nem terjedt el ilyen mértékben a valós crate-ekben, és a tonic API-ja is ezt a mintát várja el a generált trait-ben. Ha sokat írsz ilyen kódot, érdemes egy típus aliast bevezetni, mint fent tettük.
Összefoglalás
A tonic crate meglepően kevés boilerplate-tel teszi lehetővé egy production-ready gRPC szolgáltatás megírását Rustban. A proto fájl a közös nyelv a szerver és a kliens között, a tonic-build pedig automatikusan legenerálja a szükséges struct-okat és trait-eket. Az async_trait makró jelenleg még szükséges kiegészítő a trait-ekben lévő async fn-ekhez, de a mögötte lévő API így is teljesen kényelmesen használható. A streaming végpontok pedig remekül mutatják, hogy a Rust Stream absztrakciója és a Tokio csatornái hogyan illenek össze a gRPC streaming szemantikájával. Ha valós projektbe viszed ezt tovább, érdemes megnézni a tonic interceptor és middleware API-ját is autentikációhoz, loggoláshoz vagy metrikákhoz – de ez már egy következő cikk témája lehet.