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 .proto fá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.

Megjegyzés

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.

Tipp

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.

Figyelem

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.

Jó tudni

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.