Construire un serveur de chat simple avec gRPC dans .Net Core

Dans cet article, nous allons créer une application de serveur de chat gRPC concurrentiel simple. Nous utiliserons .NET Core, un framework open source, cross-plateforme et modulaire, pour construire notre application de serveur de chat. Nous aborderons les sujets suivants :

  • A brief introduction to gRPC
  • Configuration de l’environnement gRPC et définition du contrat de service
  • Mise en œuvre du service de chat et gestion des requêtes des clients
  • Gestion de plusieurs clients de manière concurrentielle à l’aide de la programmation asynchrone
  • Diffusion des messages de chat à tous les clients connectés dans la même salle

À la fin de ce tutoriel, vous comprendrez comment utiliser gRPC pour construire un serveur de chat.

Qu’est-ce que gRPC?

gRPC est un acronyme qui signifie Google Remote Procedure Calls. Il a été initialement développé par Google et est maintenant maintenu par la Cloud Native Computing Foundation (CNCF). gRPC vous permet de connecter, d’invoquer, d’opérer et de déboguer des applications distribuées hétérogènes aussi facilement qu’en appelant une fonction locale.

gRPC utilise HTTP/2 pour la transmission, une approche de développement d’API axée sur le contrat, Protocol Buffers (Protobuf) en tant que langage de définition d’interface ainsi que son format de communication de messages sous-jacent. Il peut prendre en charge quatre types d’API (RPC Unary, Server streaming RPC, Client streaming RPC et Bidirectional streaming RPC). Vous pouvez en savoir plus sur gRPC ici.

Premières étapes

Avant de commencer à écrire du code, une installation de .NET Core doit être effectuée, et assurez-vous d’avoir les prérequis suivants en place:

  • Visual Studio Code, Visual Studio ou JetBrains Rider IDE
  • .NET Core
  • gRPC .NET
  • Protobuf

Étape 1 : Créer un projet gRPC à partir de Visual Studio ou de la ligne de commande

  • Vous pouvez utiliser la commande suivante pour créer un nouveau projet. Si elle réussit, vous devriez le voir créé dans le répertoire spécifié avec le nom ‘ChatServer.’
PowerShell

 

dotnet new grpc -n ChatServerApp

  • Ouvrez le projet avec l’éditeur de votre choix. J’utilise Visual Studio pour Mac.

Étape 2 : Définir les messages Protobuf dans un fichier Proto

Contrat Protobuf:

  1. Créez un fichier .proto nommé server.proto dans le dossier protos. Le fichier proto est utilisé pour définir la structure du service, y compris les types de messages et les méthodes que le service prend en charge. 
ProtoBuf

 

syntax = "proto3";

option csharp_namespace = "ChatServerApp.Protos";

package chat;

service ChatServer {
  // Flux de communication bidirectionnelle entre le client et le serveur
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

//Messages du client:
message ClientMessage {
  oneof content {
	ClientMessageLogin login = 1;
	ClientMessageChat chat = 2;
  }
}

message ClientMessageLogin {
  string chat_room_id = 1;
  string user_name = 2;
}


message ClientMessageChat {
  string text = 1;
}

//Messages du serveur
message ServerMessage {
  oneof content {
	ServerMessageLoginSuccess login_success = 1;
	ServerMessageLoginFailure login_failure = 2;
	ServerMessageUserJoined user_joined = 3;
	ServerMessageChat chat = 4;
  }
}

message ServerMessageLoginFailure {
  string reason = 1;
}

message ServerMessageLoginSuccess {
}

message ServerMessageUserJoined {
  string user_name = 1;
}

message ServerMessageChat {
  string text = 1;
  string user_name = 2;
}

  • ChatServer définit le service principal de notre application de chat, qui comprend une seule méthode RPC appelée HandleCommunication. La méthode est utilisée pour la communication bidirectionnelle en continu entre le client et le serveur. Elle prend en entrée un flux de ClientMessage et renvoie en sortie un flux de ServerMessage
ProtoBuf

 

service ChatServer {
  // Flux de communication bidirectionnel entre le client et le serveur
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin, qui sera envoyé par le client, a deux champs appelés chat_room_id et user_name. Ce type de message est utilisé pour envoyer des informations de connexion du client au serveur. Le champ chat_room_id spécifie la salle de chat que le client souhaite rejoindre, tandis que le champ user_name spécifie le nom d’utilisateur que le client souhaite utiliser dans la salle de chat
ProtoBuf

 

message ClientMessageLogin {
  string chat_room_id = 1;
  string user_name = 2;
}


  • ClientMessageChat qui sera utilisé pour envoyer des messages de chat du client au serveur. Il contient un seul champ text.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessage définit les différents types de messages qu’un client peut envoyer au serveur. Il contient un champ oneof, ce qui signifie qu’un seul des champs peut être défini à la fois. Si vous utilisez oneof, le code C# généré contiendra une énumération indiquant quels champs ont été définis. Les noms de champ sont « login » et « chat » qui correspondent respectivement aux messages ClientMessageLogin et ClientMessageChat
ProtoBuf

 

message ClientMessage {
  oneof content {
	ClientMessageLogin login = 1;
	ClientMessageChat chat = 2;
  }
}

  • ServerMessageLoginFailure définit le message envoyé par le serveur pour indiquer qu’un client n’a pas réussi à se connecter à la salle de chat. Le champ « raison » spécifie la raison de l’échec.
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  •  ServerMessageLoginSuccess définit le message envoyé par le serveur pour indiquer qu’un client a réussi à se connecter à la salle de chat. Il ne contient aucun champ et signale simplement que la connexion a été réussie. Lorsqu’un client envoie un message ClientMessageLogin, le serveur répondra avec un message ServerMessageLoginSuccess ou un message ServerMessageLoginFailure, selon que la connexion a été réussie ou non. Si la connexion a été réussie, le client peut alors commencer à envoyer des messages ClientMessageChat pour démarrer les messages de chat.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • Message ServerMessageUserJoined définit le message envoyé par le serveur au client lorsqu’un nouvel utilisateur rejoint la salle de chat.
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • Message ServerMessageChat définit le message envoyé par le serveur pour indiquer qu’un nouveau message de chat a été reçu. Le champ texte spécifie le contenu du message de chat, et le champ nom_utilisateur spécifie le nom d’utilisateur de l’utilisateur qui a envoyé le message.
ProtoBuf

 

message ServerMessageChat {
  string text = 1;
  string user_name = 2;
}

  • Message ServerMessage définit les différents types de messages pouvant être envoyés du serveur au client. Il contient un champ oneof nommé content avec plusieurs options. Les noms de champs sont « login_success, » « login_failure, » « user_joined, » et « chat, » qui correspondent respectivement aux messages ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, et ServerMessageChat.
ProtoBuf

 

message ServerMessage {
  oneof content {
	ServerMessageLoginSuccess login_success = 1;
	ServerMessageLoginFailure login_failure = 2;
	ServerMessageUserJoined user_joined = 3;
	ServerMessageChat chat = 4;
  }
}

Étape 3: Ajouter une classe ChatService

Ajoutez une classe ChatService dérivée de ChatServerBase (générée à partir du fichier server.proto en utilisant le générateur gRPC protoc). Nous devons ensuite surcharger la méthode HandleCommunication. L’implémentation de la méthode HandleCommunication sera responsable du traitement de la communication entre le client et le serveur.

C#

 

public class ChatService : ChatServerBase
{
    private readonly ILogger<ChatService> _logger;

    public ChatService(ILogger<ChatService> logger)
    {
        _logger = logger;
    }

    public override Task HandleCommunication(IAsyncStreamReader<ClientMessage> requestStream, IServerStreamWriter<ServerMessage> responseStream, ServerCallContext context)
    {
        return base.HandleCommunication(requestStream, responseStream, context);
    }
}

Étape 4: Configurer gRPC

Dans le fichier program.cs :

C#

 

using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);


/*
// Une configuration supplémentaire est requise pour exécuter gRPC avec succès sur macOS.
// Pour des instructions sur la façon de configurer Kestrel et les clients gRPC sur macOS,
// visitez https://go.microsoft.com/fwlink/?linkid=2099682

   To avoid missing ALPN support issue on Mac. To work around this issue, configure Kestrel and the gRPC client to use HTTP/2 without TLS.
   You should only do this during development. Not using TLS will result in gRPC messages being sent without encryption.
   
   https://learn.microsoft.com/en-us/aspnet/core/grpc/troubleshoot?view=aspnetcore-7.0
*/
builder.WebHost.ConfigureKestrel(options =>
{
    // Configurer un point de terminaison HTTP/2 sans TLS.
    options.ListenLocalhost(50051, o => o.Protocols =
        HttpProtocols.Http2);
});


// Ajouter des services au conteneur.
builder.Services.AddGrpc();
builder.Services.AddSingleton();

var app = builder.Build();

// Configurer le pipeline de demande HTTP.
app.MapGrpcService();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");

Console.WriteLine($"gRPC server about to listening on port:50051");

app.Run();

Note: ASP.NET Core gRPC template et exemples utilisent par défaut TLS. Mais pour des fins de développement, nous configurons Kestrel et le client gRPC pour utiliser HTTP/2 sans TLS.

Étape 5: Créer un ChatRoomService et mettre en œuvre les différentes méthodes nécessaires dans HandleCommunication

La classe ChatRoomService est responsable de la gestion des salles de chat et des clients, ainsi que du traitement des messages échangés entre les clients. Elle utilise un ConcurrentDictionary pour stocker les salles de chat et une liste d’objets ChatClient pour chaque salle. La méthode AddClientToChatRoom ajoute un nouveau client à une salle de chat, et la méthode BroadcastClientJoinedRoomMessage envoie un message à tous les clients de la salle lorsqu’un nouveau client rejoint. La méthode BroadcastMessageToChatRoom envoie un message à tous les clients d’une salle à l’exception de l’expéditeur du message. 

La classe ChatClient contient un objet StreamWriter pour écrire des messages au client, ainsi qu’une propriété UserName pour identifier le client.

C#

 

using System;
using ChatServer;
using Grpc.Core;
using System.Collections.Concurrent;

namespace ChatServer.Services
{
    public class ChatRoomService
    {
        private static readonly ConcurrentDictionary> _chatRooms = new ConcurrentDictionary>();

        
        Lire un message unique provenant du client.
        


        public async Task ReadMessageWithTimeoutAsync(IAsyncStreamReader requestStream, TimeSpan timeout)
        {
            CancellationTokenSource cancellationTokenSource = new();

            cancellationTokenSource.CancelAfter(timeout);

            try
            {
                bool moveNext = await requestStream.MoveNext(cancellationTokenSource.Token);

                if (moveNext == false)
                {
                    throw new Exception("connection dropped exception");
                }

                return requestStream.Current;
            }
            catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
            {
                throw new TimeoutException();
            }
        }

        






        public async Task AddClientToChatRoom(string chatRoomId, ChatClient chatClient)
        {
            if (!_chatRooms.ContainsKey(chatRoomId))
            {
                _chatRooms[chatRoomId] = new List { chatClient };
            }
            else
            {
                var existingUser = _chatRooms[chatRoomId].FirstOrDefault(c => c.UserName == chatClient.UserName);
                if (existingUser != null)
                {
                    Un utilisateur avec le même nom d'utilisateur existe déjà dans le salon de discussion
                    throw new InvalidOperationException("User with the same name already exists in the chat room");
                }
                _chatRooms[chatRoomId].Add(chatClient);
            }

            await Task.CompletedTask;
        }
        
        Diffuser le message "client a rejoint le salon".
        



        public async Task BroadcastClientJoinedRoomMessage(string userName, string chatRoomId)
        {
            if (_chatRooms.ContainsKey(chatRoomId))
            {
                var message = new ServerMessage { UserJoined = new ServerMessageUserJoined { UserName = userName } };

                var tasks = new List();

                foreach (var stream in _chatRooms[chatRoomId])
                {
                    if (stream != null && stream != default)
                    {
                        tasks.Add(stream.StreamWriter.WriteAsync(message));
                    }
                }

                await Task.WhenAll(tasks);
            }
        }

        





        public async Task BroadcastMessageToChatRoom(string chatRoomId, string senderName, string text)
        {
            if (_chatRooms.ContainsKey(chatRoomId))
            {
                var message = new ServerMessage { Chat = new ServerMessageChat { UserName = senderName, Text = text } };

                var tasks = new List();
                var streamList = _chatRooms[chatRoomId];
                foreach (var stream in _chatRooms[chatRoomId])
                {
                    Ce senderName peut être quelque chose d'identifiant unique pour chaque utilisateur.
                    if (stream != null && stream != default && stream.UserName != senderName)
                    {
                        tasks.Add(stream.StreamWriter.WriteAsync(message));
                    }
                }

                await Task.WhenAll(tasks);
            }
        }
    }

    public class ChatClient
    {
        public IServerStreamWriter StreamWriter { get; set; }
        public string UserName { get; set; }
    }
}

Étape 6 : Enfin, implémentez la méthode gRPC HandleCommunication de l’étape 3

La méthode HandleCommunication reçoit un requestStream du client et envoie un responseStream en retour au client. La méthode lit un message du client, extrait le nom d’utilisateur et le chatRoomId, et gère deux cas : un cas de connexion et un cas de discussion.

  • Dans le cas de la connexion, la méthode vérifie si le nom d’utilisateur et chatRoomId sont valides et envoie un message de réponse au client en conséquence. Si la connexion est réussie, le client est ajouté à la salle de chat, et un message de diffusion est envoyé à tous les clients de la salle de chat. 
  • Dans le cas de la messagerie, la méthode diffuse le message à tous les clients de la salle de chat. 
C#

 

using System;
using ChatServer;
using Grpc.Core;

namespace ChatServer.Services
{
    public class ChatService : ChatServer.ChatServerBase
    {
        private readonly ILogger _logger;
        private readonly ChatRoomService _chatRoomService;

        public ChatService(ChatRoomService chatRoomService, ILogger logger)
        {
            _chatRoomService = chatRoomService;
            _logger = logger;
        }

        public override async Task HandleCommunication(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context)
        {
            var userName = string.Empty;
            var chatRoomId = string.Empty;
            
            while (true)
             {
                //Lire un message du client.
                var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);

                switch (clientMessage.ContentCase)
                {
                    case ClientMessage.ContentOneofCase.Login:

                        var loginMessage = clientMessage.Login;
                        //Obtenir le nom d'utilisateur et l'ID de la salle de chat à partir du message client.
                        chatRoomId = loginMessage.ChatRoomId;
                        userName = loginMessage.UserName;

                        if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
                        {
                            //Envoyer un message de connexion échec.
                            var failureMessage = new ServerMessage
                            {
                                LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
                            };

                            await responseStream.WriteAsync(failureMessage);

                            return;
                        }

                        //Envoyer un message de connexion réussie au client
                        var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
                        await responseStream.WriteAsync(successMessage);

                        //Ajouter le client à la salle de chat.
                        await _chatRoomService.AddClientToChatRoom(chatRoomId, new ChatClient
                        {
                            StreamWriter = responseStream,
                            UserName = userName
                        });

                        break;

                    case ClientMessage.ContentOneofCase.Chat:

                        var chatMessage = clientMessage.Chat;

                        if (userName is not null && chatRoomId is not null)
                        {
                            //Diffuser le message à la salle
                            await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
                        }

                        break;
                }
            }
        }
    }
}

Répertoire du projet complet:

C’est tout pour partie 1. Dans la prochaine partie 2, je vais créer un projet client avec la mise en œuvre du client pour compléter cette application de chat.

Source:
https://dzone.com/articles/create-a-concurrent-grpc-chat-messaging-in-net-7