CS Bachelor Journey
LabsUQAR LabsSemester 2INF26207 — Téléinformatique

Labo 05

Laboratoire sur le protocole de communication gRPC avec Python et ASP.net

À propos de gRPC

gRPC est un protocole de communication permettant l'appel de procédures distantes (Remote Procedure Call ou RPC) à travers le réseau. À la différence du protocole HTTP où les méthodes sont standardisées (e.g. GET, POST, PUT...), gRPC permet de définir des protocoles de communication personnalisés. Alors que dans le protocole HTTP, le transfert des données est au premier plan, dans gRPC, ce sont les procédures qui sont au centre du protocole de communication. Les données y sont passées et retournées vers/des procédures au moyen de messages qui sont aussi définis de manière personnalisée pour chaque application. gRPC permet également la communication bi-directionnelle client -> serveur et serveur -> client. Les services gRPC peuvent être développés dans différents langages et il est possible à un client et un serveur développés dans des langages différents de communiquer sans nécessiter le développement d'une couche de compatibilité.

Solution - GitHub

Principe et fonctionnement

Comme mentionné précédemment, la communication au moyen de gRPC permet au développeur de définir les procédures et messages qui seront utilisés lors des communications. Ces éléments constituent le contrat d'implémentation qui doit être respecté lors du développement du client ou du serveur et qui rendra la communication possible entre les parties. Ce contrat d'implémentation est défini dans un fichier selon un format appelé Protocol Buffers https://protobuf.dev/. Ce format permet de définir des messages utilisés pour la communication entre le serveur et le client ainsi que les méthodes qui seront implémentées par le serveur et qui pourront être invoquées par le client. Le client et le serveur peuvent ensuite échanger des messages selon différents modes:

  • Messages uniques
  • Streaming côté serveur
  • Streaming côté client
  • Streaming client et serveur

La communication avec messages unique est la plus simple: le client envoie une requête composée d'un message d'un type donné. Le serveur traite la requête et répond avec un message unique (d'un type pouvant être le même ou un autre que celui envoyé par le client)

Pour le streaming côté serveur, le client initie la communication en envoyant un message unique au serveur. Le serveur répond par un flux de messages qui sont transmises au client.

Dans le cas du streaming côté client, c'est le client qui envoit un flux de messages au serveur. Ce dernier répond par un message unique.

Finalement, pour le streaming bi-directionnel, le client et le serveurs échangent des flux de messages.

Syntaxe de base

L'exemple ci-dessous illustre la définition d'un message. On commence par utiliser le mot clé message suivi par des . À l'intérieur de celles-ci, on définit les différents champs composant le message. Chaque champ est composé d'un type, d'un nom et d'un indice suivi d'un ;. L'indice est utilisé lors de la sérialisation/dé-sérialisation pour permettre de connaitre l'ordre des champs étant donné que le paquet transmis est simplement un ensemble d'octets.

message SearchRequest {
    string query = 1;
    int32 page_number = 2;
    int32 results_per_page = 3;
}

Les messages sont utilisés pour transmettre des données aux procédures ainsi que pour retourner des données aux clients. Le contrat d'implémentation doit aussi définir les méthodes (ou procédures) qui devront être implémentées par le serveur gRPC. Un exemple de défnition de méthodes est illustré ci-dessous. Les procédures sont contenues à l'intérieur d'un service qui, si on voulait faire un parallèle avec le paradigme de programmation orientée objet, correspondrait à une classe abstraite ou une interface. À l'intérieur du service, on trouve les déclarations des différentes procédures rpc. Les procédures sont seulement déclarées, on ne retrouve pas définition à cette étape (comme c'est le cas pour une interface). La définition des procédures sera faite lors de l'implémentation du service dans un langage donné. Dans l'exemple suivant, nous incluons quatre déclarations de procédures illustrant les quatre modes de communication mentionnés précedemment (échange unique, streaming serveur, streaming client et streaming bi-directionnel).

service SearchService {
rpc Search(SearchRequest) returns (SearchResponse);
rpc ServerSideStream(ClientRequest) returns (stream ServerResponse);
rpc ClientSideStream(stream ClientRequest) returns (ServerSummary);
rpc ClientServerStream(stream ClientRequest) returns (stream ServerResponse);
}

Une fois le contrat d'implémentation défini, il est nécessaire de concrétiser son implémentation dans un langage de programmation donnée afin de pouvoir utiliser le protocole de communication. Typiquement, en utilisant l'un des langages de programmation supportés, on peut compiler le fichier Protocol Buffers générant ainsi automatiquement les objets de base nécessaires à l'implémentation du service. On obtient ainsi un squelette d'application auquel on doit ajouter la logique applicative nécessaire pour concrétiser les différentes procédures.

Comparaison avec REST

REST utilise les méthodes HTTP et le format json pour transmettre des données entre le client et le serveur. Dans une API REST, la communication est toujours initiée par le client à l'aide d'une des méthodes supportées (e.g. GET, POST, PUT, DELETE...) pour un point de terminaison donné. Le serveur envoie une réponse comportant un code de statut (HTTP Status, e.g. 200, 404, 500...) et le corps de la réponse sous format json.

Dans gRPC, c'est au développeur d'implémenter les procédures définies dans le contrat (protocole de communication). Les données sont sérialisées sous forme binaire avant leur transmission, ce qui permet une meilleure performance au niveau du transport des données. La réponse est automatiquement dé-sérialisée et présentée à sa réception comme un objet natif dans le langage du destinataire. Il n'est donc pas nécessaire de dé-sérialiser explicitement comme avec le format json. En ce qui concerne les codes d'erreur, gRPC définit un ensemble de codes d'erreurs qui couvrent les scénarios typiques.

Applications

gRPC est, de par son approche orientée vers les procédures, bien adaptée pour les système distribués. Le protocole peut permettre à différents sous-systèmes de communiquer sans qu'on ait à se soucier des détails d'implémentation. Tant que le contrat défini dans le protocole est respecté, les procédures distantes pourront être appelées de la même façon qu'une procédure locale native. Cela peut être utile par exemple dans une infrastructure de microservices où différents composants plus ou moins indépendants les uns des autres sont appelés à interagir de manière cohérente.

On peut également utiliser gRPC pour supporter la communication entre clients et serveur dans des applications web ou mobile. Le protocole est particulièrement intéressant pour les applications qui requièrent des interactions en temps réel et de la communication bi-directionnelle car il supporte le streaming bi-directionnel. Notez par contre que gRPC n'est pas conçu pour le streaming de données multimedia comme de l'audio ou de la vidéo. Pour ce genre de contenu, un protocole tel WebRTC est plus adapté.

Marche à suivre pour le laboratoire

Dans le cadre de ce laboratoire, nous allons développer un client et un serveur pour un service gRPC simple illustrant les concepts d'appel de procédures distantes client - serveur incluant du streaming. Le laboratoire se divise en deux parties. Dans la première partie, vous suivrez étape par étape un guide qui vous amènera à développer un client et un serveur dans le langage Python.

Dans la seconde partie, on vous demande de remplacer le serveur Python par un serveur écrit en C# à l'aide d'ASP.net illustrant par la même occasion que le protocole gRPC permet la communication entre des applications développées dans différents langages.

Avant de commencer le développement à proprement parler, la première étape est de créer un dépôt git. Vous l'utiliserez pour déposer votre code du projet et c'est à partir de celui-ci que la correction de votre remise sera effectuée. Assurez-vous de créer un dépôt privé et de donner accès aux auxiliaires François Gosselin et Dorra Lamouchi qui feront la correction du travail. Assurez-vous de bien synchroniser votre dépôt avec votre copie de travail locale en utilisant notamment les commandes git pull et git push fréquemment.

Pour la partie Python, il vous est demandé d'utiliser conda comme gestionnaire d'environnement plutôt que venv comme c'est mentionné dans l'exemple en référence. Vous créerez donc un environnement virtuel en utilisant la commande (en supposant que vous souhaitiez nommer votre environnement grpc):

# Créer un environnement appelé grpc

conda create --name grpc

# Activer l'environnement

conda activate grpc

# Installer les librairies dans l'environnement

pip install grpcio
pip install grpcio-tools

# Exporter l'environnment dans le fichier environment.yaml

conda env export > environment.yaml

# Assurez-vous également d'initialiser votre dépôt git:

# Créer le répertoire racine

mkdir labo_grpc

# Entrer dans le répertoire racine

cd labo_grpc

# Initialiser le dépôt git

git init .

# Créer un fichier README, replacez <Votre nom> par votre nom

echo "# Laboratoire gRPC <Votre nom>" > README.md

# Liez votre dépôt local au dépôt distant

git remote add origin <url de votre dépôt distant>

# Ajoutez tous les fichiers à la liste à suivre

git add -A

# Faire un premier commit

git commit -m"création du dépôt"

# Synchronisation du dépôt distant avec la copie locale

git push origin master

# Créer un répertoire pour contenir le service grpc Python

mkdir grpc_python

# Entrer dans le répertoire créé

cd grpc_python

Développement du client et du serveur en Python

Le service à développer est celui décrit dans l'exemple RouteGuide que vous trouverez en suivant le lien suivant https://grpc.io/docs/languages/python/basics/. La mise en place du service est décrite étape par étape. Suivez les étapes données dans le guide et faites l'implémentation du client et du serveur.

Une fois l'implémentation effectuée, il devrait être possible de faire fonctionner le service en lançant la commande suivante dans un terminal (supposant que vous avez nommé votre fichier serveur server.py, ajustez en au besoin):

(grpc)$ python server.py

Notez que (grpc)$ ne fait pas partie de la commande et sert à indiquer que votre environnement virtuel contenant les librairies grpc doit être activé

Dans un second terminal, exécutez (supposant que votre client se nomme client.py):

(grpc)$ python client.py

Le programme d'exemple d'utilisation du service devrait maintenant s'exécuter.

Remplacement du serveur Python par un serveur C# ASP.net Avant de commencer à développer le server C#, vous devez créer un nouveau sous-répertoire dans le dossier racine afin de garder votre service C# séparé de votre service Python. Remontez donc à la racine de votre projet, soit le répertoire labo_grpc. Ensuite, créez le nouveau sous-répertoire:

# Création du sous répertoire pour le service C#

mkdir grpc_csharp

# Entrer dans le nouveau répertoire

cd grpc_csharp

Maintenant que nous avons une première implémentation du service gRPC RouteGuide, voyons comment on peut remplacer le serveur Python par un serveur programmé dans autre langage. Nous utiliserons ici le langage C# puisque plusieurs d'entre vous sont déjà familiers avec ce langage qui est utilisé dans d'autres cours du programme, mais nous aurions pu utiliser n'importe lequel des langages supportés.

Pour créer un service GRPC en C#, on peut soit utiliser l'interface de Visual Studio et créer un nouveau projet en choissant gRPC dans la liste des types de projets. On peut également créer le projet directement dans le terminal avec la commande:

# Création d'un nouveau projet grpc

dotnet new grpc

# Ajout des outils de génération grpc

dotnet package add grpc.tools

Le projet dotnet gRPC vient avec par défaut un service Hello World. Nous n'aurons pas besoin de ce service. Nous pouvons donc le retirer. Pour ce faire, nous pouvons retirer le fichier GreeterService.cs dans le répertoire Services.

Dans le répertoire Protos, retirez le fichier Protocol Buffers par défaut et remplacez-le par le fichier protocole que vous avez utilisé pour créer le service Python.

Il faut également modifier le fichier csproj pour inclure le fichier protocole dans le projet. Modifiez la partie suivante du fichier:

<!-- grpc_csharp.csproj -->
<ItemGroup>
    <Protobuf Include="Protos\route_guide.proto" GrpcServices="Server" />
</ItemGroup>

Il faut donc donner le chemin du fichier protocole et spécifier l'option Server pour s'assurer que nous allons bien générer un serveur gRPC.

Dans le fichier route_guide.proto, il faut aussi spécifier l'espace de nom à utiliser pour les classes qui seront générées.

option csharp_namespace = "csharp_grpc";

Finalement, dans le fichier Program.cs, il faut changer le nom du service gRPC en modifiant la ligne suivante:

//app.MapGrpcService<GreeterService>();
//Remplacé par
app.MapGrpcService<RouteGuideService>();

Lors de la compilation, le compilateur générera automatiquement un classe abstraite contenant la déclaration des méthodes définies dans le protocole d'implémentation du fichier Protocol Buffers. Pour avoir une application fonctionnelle, il faut programmer une implémentation concrète de cette classe. Cette implémentation concrète sera faite dans la classe RouteGuideService que nous avons invoqué ci-dessus.

Cette classe n'existe pas encore, il nous faut la créer:

#Entrer dans le répertoire Services
cd Services

#Créer le fichier RouteGuideService.cs
touch RouteGuideService.cs
Dans ce fichier, ajoutez l'implémentation de base suivante:

//RouteGuideService.cs

using Grpc.Core;
using csharp_grpc;
using System.Reflection;
using System.Diagnostics;

namespace csharp_grpc.Services;

public class RouteGuideService: RouteGuide.RouteGuideBase{
public List<Feature> FeatureList = new List<Feature>();

    public override async Task ListFeatures(Rectangle request, IServerStreamWriter<Feature> responseStream, ServerCallContext context){
        foreach (var feature in FeatureList){
            await responseStream.WriteAsync(feature);
        }
    }

    public override async Task<RouteSummary> RecordRoute(IAsyncStreamReader<Point> requestStream, ServerCallContext context){
        List<Point> points = new List<Point>();
        await foreach(var request in requestStream.ReadAllAsync()){

        }
        return new RouteSummary(){
            PointCount = 0,
            FeatureCount = 0,
            Distance = 0,
            ElapsedTime = 0
        };
    }

    public override async Task RouteChat(IAsyncStreamReader<RouteNote> requestStream, IServerStreamWriter<RouteNote> responseStream, ServerCallContext context){
        await foreach(RouteNote note in requestStream.ReadAllAsync()){
            await responseStream.WriteAsync(note);
        }
    }

}

Vous devriez maintenant être capables de compiler le projet. Quelques observations par rapport au code: 1. On voit que la classe RouteGuideService hérite de RouteGuide.RouteGuideBase. Cette dernière correspond à la classe abstraite qui est générée automatiquement à la compilation. 2. Cette implémentation sert de base pour programmer votre service. En elle-même elle ne remplit pas les fonctionnalités attendues du service. C'est à vous de modifier les méthodes pour implémenter le fonctionnalités requises. 3. La liste des Feature doit être chargée comme c'était le cas dans le service Python. Vous pouvez utiliser le fichier json qui était fourni avec l'exemple Python. Vous aurez besoin d'utiliser les fonctionnalités de System.Text.Json pour importer les données. Attention aux différences de casse! Vous pouvez également utiliser un fichier json pour stocker l'information de configuration de votre application (chemin du fichier de Feature, port à utiliser, etc.). C'est mieux que de stocker cette information directement dans le code source.

// Importe les fonctionnalités json de la librairie System
using System.Text.Json;

// Ignore la casse des noms de propriétés
var options = new JsonSerializerOptions{
PropertyNameCaseInsensitive = true
};


// Convertit la chaine de caractère json en une liste de Feature
List<Feature>? dbData = JsonSerializer.Deserialize<List<Feature>>(jsonText, options);

Également, dans la dernière méthode, on souhaite pouvoir envoyer des notes au serveur, mais également recevoir des notes envoyées par d'autres utilisateur. Cela suppose que l'on ait une persistence des données d'une requête à l'autre. Une manière d'implémenter la persistence serait bien entendu de sauvegarder les données dans une base de données. Pour limiter la portée du laboratoire et prendre avantage des fonctionnalités offertes par ASP.net, nous allons prendre une autre approche. ASP.net offre la possibilité d'utiliser le principe d'injection des dépendances pour rendre disponible automatiquement les objets ciblés aux endroits appropriés dans notre projet. Il y a différentes options d'injection de dépendances selon la portée souhaitée pour les objets créés. Dans notre cas, on souhaite faire persister les données donc avoir une seule instance d'objet partagée entre toutes les requêtes. Cela correspond à une entité de type Singleton. Nous allons donc créer une nouvelle classe que nous pourrons injecter à l'intérieur de RouteGuideService et qui contiendra les données communes aux requêtes de l'application (ici les notes des utilisateurs).

Dans le dossier Services, nous créerons un fichier appelé RouteGuideHelper.cs

Dans ce fichier, nous créerons une interface IRouteGuideHelper et son implémentation RouteGuideHelper. L'inteface est définie comme suit:

//RouteGuideHelper.cs

public interface IRouteGuideHelper {
public List<RouteNote> RouteNoteList {set; get;}
public List<Feature> FeatureList {set; get;}
/* Facultatif:
public List<ConnectedClient<RouteNote>> ConnectedClients {set; get;}
public void BroadcastRouteNote(RouteNote routeNote);
*/
}

La méthode BroadcastRouteNote est facultative et sert à envoyer une note à tous les clients connectés. La liste des clients connectés est utilisée par BroadcastRouteNote pour faire l'envoi à tous les clients. Il faut maitenant faire l'implémentation de la classe RouteGuideHelper qui hérite de cette interface:

//RouteGuideHelper.cs

public class RouteGuideHelper: IRouteGuideHelper {
/*
implémenter les propriétés et méthodes requises ici.
*/
}

Une fois l'implémentation complétée, on utilise l'injection de dépendances pour utiliser la classe dans notre application:

//Program.cs
builder.Services.AddSingleton<IRouteGuideHelper, RouteGuideHelper>();

On modifie également notre fichier RouteGuideService.cs pour injecter la dépendance créée:

//RouteGuideService.cs

public readonly IRouteGuideHelper _routeGuideHandler;

public RouteGuideService(IRouteGuideHelper routeGuideHelper){
_routeGuideHandler = routeGuideHelper;
}

On peut maintenant utiliser la dépendance à l'intérieur de notre service.

Afin que notre client et notre serveur puissent communiquer, il est nécessaire que les deux utilisent le même port pour envoyer/recevoir les données. ASP.net utilise Kestrel par défaut comme serveur web. Si on ne spécifie pas le port que l'on souhaite utiliser pour notre application, le service nous en attribuera un automatiquement. Dans notre cas, cette option n'est pas satisfaisante puisqu'on veut établir le port lors de la configuration du service. Autrement, on devra modifier le port du client après qu'on ait lancé le serveur et découvert quel sur quel port il écoute. Aussi, rien ne nous assure que ce port sera le même dans une future exécution du service ou sur un autre système. Pour ces raisons, nous spécifierons le port lors de la configuration de l'application:

//Program.cs

builder.WebHost.ConfigureKestrel(serverOptions => {
serverOptions.Listen(System.Net.IPAddress.Loopback, 50051);
});

On this page