Premiers pas avec gRPC-Rust

1. Introduction

Dans cet atelier de programmation, vous allez utiliser gRPC-Rust pour créer un client et un serveur qui constituent la base d'une application de cartographie d'itinéraire écrite en Rust.

À la fin de ce tutoriel, vous disposerez d'un client qui se connecte à un serveur distant à l'aide de gRPC pour obtenir le nom ou l'adresse postale d'un lieu situé à des coordonnées spécifiques sur une carte. Une application complète peut utiliser cette conception client-serveur pour énumérer ou résumer les points d'intérêt le long d'un itinéraire.

Le service est défini dans un fichier Protocol Buffers, qui sera utilisé pour générer du code passe-partout pour le client et le serveur afin qu'ils puissent communiquer entre eux. Vous gagnerez ainsi du temps et des efforts pour implémenter cette fonctionnalité.

Ce code généré gère non seulement les complexités de la communication entre le serveur et le client, mais aussi la sérialisation et la désérialisation des données.

Points abordés

  • Utiliser Protocol Buffers pour définir une API de service.
  • Comment créer un client et un serveur basés sur gRPC à partir d'une définition Protocol Buffers à l'aide de la génération de code automatisée.
  • Comprendre la communication client-serveur avec gRPC.

Cet atelier de programmation s'adresse aux développeurs Rust qui découvrent gRPC ou qui souhaitent se rafraîchir la mémoire sur gRPC, ou à toute personne intéressée par la création de systèmes distribués. Aucune expérience préalable avec gRPC n'est requise.

2. Avant de commencer

Prérequis

Assurez-vous d'avoir installé les éléments suivants :

  • GCC. Suivez les instructions ici.
  • Rust, version 1.89.0. Suivez les instructions d'installation ici.

Obtenir le code

Pour que vous n'ayez pas à partir de zéro, cet atelier de programmation fournit un échafaudage du code source de l'application que vous devez compléter. Les étapes suivantes vous montreront comment finaliser l'application, y compris en utilisant les plug-ins du compilateur de tampon de protocole pour générer le code gRPC standard.

Commencez par créer le répertoire de travail de l'atelier de programmation et accédez-y :

mkdir grpc-rust-getting-started && cd grpc-rust-getting-started

Téléchargez et extrayez l'atelier de programmation :

curl -sL https://github.com/grpc-ecosystem/grpc-codelabs/archive/refs/heads/v1.tar.gz \
  | tar xvz --strip-components=4 \
  grpc-codelabs-1/codelabs/grpc-rust-getting-started/start_here

Vous pouvez également télécharger le fichier .zip contenant uniquement le répertoire de l'atelier de programmation et le décompresser manuellement.

Le code source complet est disponible sur GitHub si vous ne souhaitez pas saisir d'implémentation.

3. Définir le service

La première étape consiste à définir le service gRPC de l'application, sa méthode RPC, ainsi que ses types de messages de requête et de réponse à l'aide de Protocol Buffers. Votre service fournira :

  • Méthode RPC appelée GetFeature que le serveur implémente et que le client appelle.
  • Les types de messages Point et Feature sont des structures de données échangées entre le client et le serveur lors de l'utilisation de la méthode GetFeature. Le client fournit des coordonnées cartographiques sous la forme d'un Point dans sa requête GetFeature au serveur, et le serveur répond avec un Feature correspondant qui décrit ce qui se trouve à ces coordonnées.

Cette méthode RPC et ses types de messages seront tous définis dans le fichier proto/route_guide.proto du code source fourni.

Les Protocol Buffers sont communément appelés "protobufs". Pour en savoir plus sur la terminologie gRPC, consultez Concepts fondamentaux, architecture et cycle de vie de gRPC.

Méthode de service

Définissons d'abord nos méthodes de service, puis nos types de messages Point et Feature. Le fichier proto/routeguide.proto possède une structure service nommée RouteGuide qui définit une ou plusieurs méthodes fournies par le service de l'application.

Ajoutez la méthode rpc GetFeature dans la définition RouteGuide. Comme expliqué précédemment, cette méthode recherche le nom ou l'adresse d'un lieu à partir d'un ensemble de coordonnées donné. Par conséquent, faites en sorte que GetFeature renvoie un Feature pour un Point donné :

service RouteGuide {
  // Definition of the service goes here

  // Obtains the feature at a given position.
  rpc GetFeature(Point) returns (Feature) {}
}

Il s'agit d'une méthode RPC unaire : un RPC simple où le client envoie une requête au serveur et attend une réponse, comme pour un appel de fonction local.

Types de messages

Dans le fichier proto/route_guide.proto du code source, définissez d'abord le type de message Point. Un Point représente une paire de coordonnées (latitude et longitude) sur une carte. Pour cet atelier de programmation, utilisez des nombres entiers pour les coordonnées :

message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

Les nombres 1 et 2 sont des ID uniques pour chacun des champs de la structure message.

Ensuite, définissez le type de message Feature. Un Feature utilise un champ string pour le nom ou l'adresse postale d'un élément à un emplacement spécifié par un Point :

message Feature {
  // The name or address of the feature.
  string name = 1;

  // The point where the feature is located.
  Point location = 2;
}

4. Générer le code client et serveur

Nous vous avons déjà fourni le code généré à partir du fichier .proto dans le répertoire généré.

Comme pour tout projet, nous devons réfléchir aux dépendances nécessaires à notre code. Pour les projets Rust, les dépendances se trouveront dans Cargo.toml. Nous avons déjà listé les dépendances nécessaires dans le fichier Cargo.toml.

Si vous souhaitez apprendre à générer du code à partir du fichier .proto, consultez ces instructions.

Le code généré contient les éléments suivants :

  • Définitions de structure pour les types de messages Point et Feature.
  • Un trait de service que nous devrons implémenter : route_guide_server::RouteGuide.
  • Type de client que nous utiliserons pour appeler le serveur : route_guide_client::RouteGuideClient<T>.

Ensuite, nous allons implémenter la méthode GetFeature côté serveur, afin que lorsque le client envoie une requête, le serveur puisse y répondre.

5. Implémenter le service

Dans src/server/server.rs, nous pouvons mettre le code généré dans le champ d'application à l'aide de la macro include_generated_proto! de gRPC et importer le trait RouteGuide et Point.

mod grpc_pb {
    grpc::include_generated_proto!("generated", "routeguide");
}

pub use grpc_pb::{
    route_guide_server::{RouteGuideServer, RouteGuide},
    Point, Feature,
};

Nous pouvons commencer par définir une struct pour représenter notre service. Pour l'instant, nous pouvons le faire sur src/server/server.rs :

#[derive(Debug)]
pub struct RouteGuideService {
    features: Vec<Feature>,
}

Nous devons maintenant implémenter le trait route_guide_server::RouteGuide à partir du code généré.

RPC unaire

RouteGuideService implémente toutes nos méthodes de service. La fonction get_feature côté serveur est l'endroit où le gros du travail est effectué : elle prend un message Point du client et renvoie dans un message Feature les informations de localisation correspondantes à partir d'une liste de lieux connus. Voici l'implémentation de la fonction dans src/server/server.rs :

#[tonic::async_trait]
impl RouteGuide for RouteGuideService {
    async fn get_feature(&self, request: Request<Point>) -> Result<Response<Feature>, Status> {
        println!("GetFeature = {:?}", request);
        let requested_point = request.get_ref();
        for feature in self.features.iter() {
            if feature.location().latitude() == requested_point.latitude() {
                if feature.location().longitude() == requested_point.longitude(){
                    return Ok(Response::new(feature.clone()))
                };
            };    
        }
        Ok(Response::new(Feature::default()))
    }
}

Dans la méthode, remplissez un objet Feature avec les informations appropriées pour le Point donné, puis renvoyez-le.

Une fois cette méthode implémentée, nous devons également démarrer un serveur gRPC pour que les clients puissent réellement utiliser notre service. Remplacez main() par le code suivant :

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:10000".parse().unwrap();
    println!("RouteGuideServer listening on: {addr}");
    let route_guide = RouteGuideService {
        features: load(),
    };
    let svc = RouteGuideServer::new(route_guide);
    Server::builder().add_service(svc).serve(addr).await?;
    Ok(())
}

Voici ce qui se passe dans main(), étape par étape :

  1. Spécifiez le port que nous souhaitons utiliser pour écouter les requêtes du client.
  2. Créez un RouteGuideService avec des fonctionnalités chargées en appelant la fonction d'assistance load().
  3. Créez une instance du serveur gRPC à l'aide de RouteGuideServer::new() et du service que nous avons créé.
  4. Enregistrez l'implémentation de notre service auprès du serveur gRPC.
  5. Appelez serve() sur le serveur avec les détails de notre port pour effectuer une attente bloquante jusqu'à ce que le processus soit arrêté.

6. Créer le client

Dans cette section, nous allons voir comment créer un client Rust pour notre service RouteGuide dans src/client/client.rs.

Comme dans src/server/server.rs, nous pouvons mettre le code généré dans le champ d'application à l'aide de la macro include_generated_code! de gRPC et importer le type RouteGuideClient.

mod grpc_pb {
    grpc::include_generated_proto!("generated", "routeguide");
}

use grpc_pb::{
    route_guide_client::RouteGuideClient,
    Point,
};

Appeler les méthodes de service

Dans gRPC-Rust, les RPC fonctionnent en mode bloquant/synchrone, ce qui signifie que l'appel RPC attend la réponse du serveur et renvoie une réponse ou une erreur.

Pour appeler des méthodes de service, nous devons d'abord créer un canal pour communiquer avec le serveur. Pour ce faire, nous créons d'abord un point de terminaison, nous nous y connectons et nous transmettons le canal créé lors de la connexion à RouteGuideClient::new() comme suit :

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create endpoint to connect to
    let endpoint = Endpoint::new("http://[::1]:10000")?; 
    let channel = endpoint.connect().await?;             

    // Create a new client
    let mut client = RouteGuideClient::new(channel);
    Ok(())
}

Dans cette fonction, lorsque nous créons le client, nous encapsulons le canal générique créé ci-dessus avec le stub de code généré qui implémente les méthodes spécifiques définies dans le service .proto.

RPC simple

L'appel du RPC simple GetFeature est presque aussi simple que l'appel d'une méthode locale. Ajoutez-le dans main().

println!("*** SIMPLE RPC ***");
let point = proto!(Point{
    latitude: 409_146_138,
    longitude: -746_188_906
});
let response = client
    .get_feature(Request::new(point))
    .await?.into_inner();
Ok(())

Comme vous pouvez le voir, nous appelons la méthode sur le stub que nous avons obtenu précédemment. Dans les paramètres de notre méthode, nous créons et remplissons un objet de tampon de protocole de requête (dans notre cas, Point). Si l'appel ne renvoie pas d'erreur, nous pouvons lire les informations de réponse du serveur à partir de la première valeur renvoyée.

println!("Response = Name = \"{}\", Latitude = {}, Longitude = {}",
    response.name(),
    response.location().latitude(),
    response.location().longitude());

Au total, la fonction main() du client devrait se présenter comme suit :

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    //Create endpoint to connect to
    let endpoint = Endpoint::new("http://[::1]:10000")?; 
    let channel = endpoint.connect().await?;             

    // Create a new client
    let mut client = RouteGuideClient::new(channel); 

    println!("*** SIMPLE RPC ***");
    let point = proto!(Point{
        latitude: 409_146_138,
        longitude: -746_188_906
    });
    let response = client
        .get_feature(Request::new(point))
        .await?.into_inner();

    println!("Response = Name = \"{}\", Latitude = {}, Longitude = {}",
        response.name(),
        response.location().latitude(),
        response.location().longitude());
    Ok(())
}

7. Essayer

Tout d'abord, pour exécuter notre client et notre serveur, ajoutons-les en tant que cibles binaires à notre crate. Nous devons modifier notre Cargo.toml en conséquence et ajouter les éléments suivants :

[[bin]]
name = "routeguide-server"
path = "src/server/server.rs"

[[bin]]
name = "routeguide-client"
path = "src/client/client.rs"

Exécutez ensuite les commandes suivantes à partir de notre répertoire de travail :

  1. Exécutez le serveur dans un terminal :
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-server 
  1. Exécutez le client à partir d'un autre terminal :
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-client

Un résultat semblable à celui-ci s'affiche (les codes temporels sont omis pour plus de clarté) :

*** SIMPLE RPC ***

FEATURE: Name = "Berkshire Valley Management Area Trail, Jefferson, NJ, USA", Lat = 409146138, Lon = -746188906

8. Étape suivante