A gRPC egyre népszerűbb választás mikroszolgáltatás-architektúrákban, és a Rust ökoszisztéma erre is ad egy remek eszközt: a tonic crate-et. Ebben a cikkben végigmegyünk azon, hogy hogyan épül fel egy egyszerű gRPC szolgáltatás Rustban, a proto fájltól a streaming végpontokig.

Mi az a gRPC és mikor érdemes REST helyett választani

A gRPC egy Google által fejlesztett RPC (Remote Procedure Call) keretrendszer, amely HTTP/2-re épül, és Protocol Buffers (protobuf) formátumban szerializálja az üzeneteket. Ez néhány dolgot jelent a gyakorlatban:

  • Bináris szerializáció: sokkal kompaktabb és gyorsabb, mint a JSON.
  • Erős típusosság: a proto fájl generálja a kliens és szerver kódot, így nincs kézzel írt (de)szerializációs logika.
  • Streaming támogatás: unary hívások mellett szerver-, kliens- és bidirekcionális streaming is natívan támogatott.
  • HTTP/2 multiplexelés: több hívás egy kapcsolaton, fejlettebb kontroll a hálózati rétegen.

Mikor érdemes gRPC-t választani REST helyett? Ha belső mikroszolgáltatások közötti kommunikációról van szó, ahol a teljesítmény és a szigorú szerződés (contract) fontos, a gRPC remek választás. Ha viszont böngészőből közvetlenül kell elérni az API-t, vagy egyszerű, ember által is olvasható végpontokra van szükség (pl. webhookok, publikus API-k), a REST/JSON valószínűleg egyszerűbb marad.

Tipp

A gRPC-Web létezik böngésző-kompatibilis kommunikációra, de ez extra proxy réteget igényel (pl. Envoy), így kezdőként inkább a szerver-szerver kommunikációra fókuszálj.

Proto fájl definiálása és a tonic-build beállítása

Kezdjük egy egyszerű proto fájllal, amely egy Greeter szolgáltatást definiál. Hozzunk létre egy proto/greeter.proto fájlt:

syntax = "proto3";

package greeter;

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

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

Figyeld meg, hogy a SayHelloStream metódus a stream kulcsszóval jelöli, hogy a válasz oldalon szerver streaming zajlik.

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 = ["macros", "rt-multi-thread"] }
futures = "0.3"

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

A build folyamathoz szükségünk van egy build.rs fájlra, amely lefordítja a proto fájlt Rust kóddá:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::compile_protos("proto/greeter.proto")?;
    Ok(())
}
Megjegyzés

A tonic-build a protoc bináris meglétét feltételezi a rendszeren. Ha nincs telepítve, a protobuf-compiler csomagot kell felraknod (pl. apt install protobuf-compiler Debian/Ubuntu alatt).

A build script futása után a generált kód a OUT_DIR alatt landol, amit a include_proto! makróval tudunk becsatolni a forráskódba.

Szerver oldali szolgáltatás implementálása Rustban

Most írjuk meg a szervert. Első lépésben becsatoljuk a generált kódot, majd implementáljuk a Greeter trait-et, amit a tonic-build generált nekünk.

use tonic::{transport::Server, Request, Response, Status};

pub mod greeter {
    tonic::include_proto!("greeter");
}

use greeter::greeter_server::{Greeter, GreeterServer};
use greeter::{HelloReply, HelloRequest};

#[derive(Debug, 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))
    }

    // A streaming metódust később implementáljuk
    type SayHelloStreamStream = std::pin::Pin<
        Box<dyn futures::Stream<Item = Result<HelloReply, Status>> + Send + 'static>,
    >;

    async fn say_hello_stream(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<Self::SayHelloStreamStream>, Status> {
        unimplemented!()
    }
}

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

    println!("Greeter szerver fut: {addr}");

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .serve(addr)
        .await?;

    Ok(())
}

Fontos részlet: mivel a tonic 0.9 még nem támogatja az async metódusokat traitekben natívan (ez majd egy későbbi Rust verzióban lesz elérhető nyelvi szinten), a generált kód a #[tonic::async_trait] makrót használja, amely a async-trait crate-en alapul, és Pin<Box<dyn Future>>-ra alakítja a metódusokat a háttérben.

Jó tudni

A #[tonic::async_trait] attribútumot mindig oda kell írni az impl blokk elé, különben fordítási hibát kapsz – a generált trait definíció is ezt a makrót várja.

Kliens írása a generált stub-ok felhasználásával

A kliens oldal még egyszerűbb, mivel a tonic-build generál egy kész klienst is, amit csak példányosítanunk kell:

use greeter::greeter_client::GreeterClient;
use greeter::HelloRequest;

pub mod greeter {
    tonic::include_proto!("greeter");
}

#[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 GreeterClient::connect visszaad egy Result-ot, amit ?-tel kezelünk. Ha a szerver nem fut, itt kapunk hibát – ez jó hely arra, hogy let-else mintát alkalmazz, ha összetettebb hibakezelést szeretnél:

let Ok(mut client) = GreeterClient::connect("http://[::1]:50051").await else {
    eprintln!("Nem sikerült csatlakozni a szerverhez");
    return Ok(());
};

Ez a let-else szintaxis stabil Rust feature, és nagyon jól illik az ilyen "korai visszatérés hiba esetén" mintákhoz.

Streaming végpontok bemutatása egyszerű példán

Nézzük meg, hogyan implementáljuk valójában a szerver streaming végpontot, amit korábban unimplemented!()-ként hagytunk. A cél: a kérésben kapott név alapján többször köszönjünk, egy kis várakozással az üzenetek között.

use futures::Stream;
use std::pin::Pin;
use std::time::Duration;
use tokio::time::sleep;
use tokio_stream::wrappers::ReceiverStream;

#[tonic::async_trait]
impl Greeter for MyGreeter {
    // ... say_hello implementáció változatlan

    type SayHelloStreamStream =
        Pin<Box<dyn Stream<Item = Result<HelloReply, Status>> + Send + 'static>>;

    async fn say_hello_stream(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<Self::SayHelloStreamStream>, Status> {
        let name = request.into_inner().name;
        let (tx, rx) = tokio::sync::mpsc::channel(4);

        tokio::spawn(async move {
            for i in 1..=5 {
                let reply = HelloReply {
                    message: format!("Szia, {name}! ({i}. üzenet)"),
                };
                if tx.send(Ok(reply)).await.is_err() {
                    break;
                }
                sleep(Duration::from_millis(500)).await;
            }
        });

        let output_stream = ReceiverStream::new(rx);
        Ok(Response::new(
            Box::pin(output_stream) as Self::SayHelloStreamStream
        ))
    }
}

Ehhez szükséges egy tokio-stream függőség is a Cargo.toml-ban:

tokio-stream = "0.1"

A kliens oldalon a streaming válasz feldolgozása egy while let ciklussal történik:

let mut stream = client
    .say_hello_stream(tonic::Request::new(HelloRequest {
        name: "Rust fejlesztő".into(),
    }))
    .await?
    .into_inner();

while let Some(reply) = stream.message().await? {
    println!("Streamelt válasz: {}", reply.message);
}
Figyelem

Ha elfelejted .into_inner()-t hívni a Response-on, akkor nem a stream-et kapod meg, hanem a metaadatokat tartalmazó objektumot – ez gyakori kezdő hiba.

A bidirekcionális és kliens streaming végpontok hasonló elven működnek, csak a proto fájlban a stream kulcsszót a kérés oldalon (vagy mindkét oldalon) is fel kell tüntetni, és a szerver oldali implementáció egy bejövő stream-et olvas ki, miközben egy kimenő stream-et generál.

Összefoglalás

A tonic remekül integrálódik a Rust async ökoszisztémába, és a tokio runtime-mal karöltve nagyon hatékony, típusbiztos gRPC szolgáltatásokat lehet vele építeni. A proto fájlból generált kód levágja a boilerplate nagy részét, neked csak a tényleges business logikára kell koncentrálnod. Ha eddig REST API-kat írtál axum-mal, érdemes kipróbálnod a gRPC-t olyan projektekben, ahol a szigorú szerződés és a jobb teljesítmény prioritás – a tanulási görbe meglepően rövid, ha már ismered az async Rust alapjait.