Costruire un Semplice Server Chat con gRPC in .Net Core

In questo articolo, creeremo una semplice applicazione server di chat gRPC concorrente. Utilizzeremo .NET Core, un framework cross-platform, open-source e modulare, per costruire la nostra applicazione server di chat. Copriremo i seguenti argomenti:

  • A brief introduction to gRPC
  • Configurazione dell’ambiente gRPC e definizione del contratto del servizio
  • Implementazione del servizio di chat e gestione delle richieste dei client
  • Gestione di più client in modo concorrente utilizzando la programmazione asincrona
  • Radiocronaca dei messaggi di chat a tutti i client connessi nella stessa stanza

Al termine di questo tutorial, avrai una comprensione di come utilizzare gRPC per costruire un server di chat.

Che cos’è gRPC?

gRPC è un acronimo che sta per Google Remote Procedure Calls. È stato originariamente sviluppato da Google ed è ora mantenuto dalla Cloud Native Computing Foundation (CNCF). gRPC ti consente di connettere, invocare, operare e svolgere il debug di applicazioni eterogenee distribuite con la stessa facilità di un chiamata di funzione locale. 

gRPC utilizza HTTP/2 per il trasporto, un approccio contract-first per lo sviluppo di API, Protocol Buffers (Protobuf) come linguaggio di definizione dell’interfaccia e formato di scambio dei messaggi sottostante. Può supportare quattro tipi di API (RPC unario, RPC a flusso server, RPC a flusso client e RPC a flusso bidirezionale streaming). Puoi leggere di più su gRPC qui.

Iniziare

Prima di iniziare a scrivere codice, è necessario installare .NET Core e assicurarsi di avere i seguenti prerequisiti:

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

Passo 1: Creare un Progetto gRPC da Visual Studio o dalla riga di comando

  • Puoi utilizzare il seguente comando per creare un nuovo progetto. Se la creazione ha successo, dovresti trovarlo nella directory specificata con il nome ‘ChatServer.’
PowerShell

 

dotnet new grpc -n ChatServerApp

  • Apri il progetto con il tuo editor preferito. Sto utilizzando Visual Studio per Mac.

Passo 2: Definire i Messaggi Protobuf in un File Proto

Contratto Protobuf:

  1. Crea un file .proto chiamato server.proto all’interno della cartella protos. Il file proto viene utilizzato per definire la struttura del servizio, compresi i tipi di messaggi e i metodi che il servizio supporta.
ProtoBuf

 

syntax = "proto3";

option csharp_namespace = "ChatServerApp.Protos";

package chat;

service ChatServer {
  // Flusso di comunicazione bidirezionale tra client e server
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

//Messaggi del 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;
}

//Messaggi del Server
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 definisce il servizio principale della nostra applicazione di chat, che include un unico metodo RPC chiamato HandleCommunication. Il metodo viene utilizzato per la trasmissione bidirezionale tra il client e il server. Prende in input una stream di ClientMessage e restituisce in output una stream di ServerMessage
ProtoBuf

 

service ChatServer {
  // Stream di comunicazione bidirezionale tra client e server
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin, che verrà inviato dal client, ha due campi chiamati chat_room_id e user_name. Questo tipo di messaggio viene utilizzato per inviare informazioni di accesso dal client al server. Il campo chat_room_id specifica la chat room che il client desidera unire, mentre il campo user_name specifica il nome utente che il client desidera utilizzare nella chat room
ProtoBuf

 

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


  • ClientMessageChat che verrà utilizzato per inviare messaggi di chat dal client al server. Contiene un solo campo text.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessage definisce i diversi tipi di messaggi che un client può inviare al server. Contiene un campo oneof, il che significa che solo uno dei campi può essere impostato alla volta. Se si utilizza oneof, il codice C# generato conterrà un’enumerazione che indica quali campi sono stati impostati. I nomi dei campi sono “login” e “chat” che corrispondono rispettivamente ai messaggi ClientMessageLogin e ClientMessageChat
ProtoBuf

 

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

  • ServerMessageLoginFailure definisce il messaggio inviato dal server per indicare che un client non è riuscito a effettuare il login nella chat room. Il campo “reason” specifica il motivo dell’insuccesso.
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  • ServerMessageLoginSuccess definisce il messaggio inviato dal server per indicare che un client ha effettuato con successo il login nella chat room. Non contiene campi e semplicemente segnala che il login è stato positivo. Quando un client invia un messaggio ClientMessageLogin, il server risponderà con un messaggio ServerMessageLoginSuccess o un messaggio ServerMessageLoginFailure, a seconda del successo o meno del login. Se il login ha successo, il client può quindi iniziare a inviare messaggi ClientMessageChat per avviare i messaggi di chat.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • Messaggio ServerMessageUserJoined definisce il messaggio inviato dal server al client quando un nuovo utente si unisce alla chat room.
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • Messaggio ServerMessageChat definisce il messaggio inviato dal server per indicare che è stato ricevuto un nuovo messaggio di chat. Il campo text specifica il contenuto del messaggio di chat, e il campo user_name specifica il nome utente dell’utente che ha inviato il messaggio.
ProtoBuf

 

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

  • Messaggio ServerMessage definisce i diversi tipi di messaggi che possono essere inviati dal server al client. Contiene un campo oneof chiamato contenuto con più opzioni. I nomi dei campi sono “login_success,” “login_failure,” “user_joined,” e “chat,” che corrispondono rispettivamente ai messaggi ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, e ServerMessageChat.
ProtoBuf

 

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

Passo 3: Aggiungi una Classe ChatService

Aggiungi una classe ChatService derivata da ChatServerBase (generata dal file server.proto utilizzando il codice di generazione gRPC protoc). Quindi sovrascrivi il metodo HandleCommunication. L’implementazione del metodo HandleCommunication sarà responsabile del trattamento della comunicazione tra il client e il server.

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);
    }
}

Passo 4: Configura gRPC

Nel file program.cs:

C#

 

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

var builder = WebApplication.CreateBuilder(args);


/*
// È necessaria una configurazione aggiuntiva per eseguire con successo gRPC su macOS.
// Per istruzioni su come configurare Kestrel e client gRPC su macOS,
// visita 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 =>
{
    // Configura un endpoint HTTP/2 senza TLS.
    options.ListenLocalhost(50051, o => o.Protocols =
        HttpProtocols.Http2);
});


// Aggiungi servizi al contenitore.
builder.Services.AddGrpc();
builder.Services.AddSingleton();

var app = builder.Build();

// Configura il flusso di richieste 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();

Nota: Il template e gli esempi di gRPC di ASP.NET Core utilizzano TLS per impostazione predefinita. Ma per scopi di sviluppo, configuriamo Kestrel e il client gRPC per utilizzare HTTP/2 senza TLS.

Passo 5: Creare un ChatRoomService e implementare vari metodi necessari in HandleCommunication

La classe ChatRoomService è responsabile della gestione delle stanze di chat e dei client, nonché del trattamento dei messaggi inviati tra i client. Utilizza un ConcurrentDictionary per memorizzare le stanze di chat e una lista di oggetti ChatClient per ogni stanza. Il metodo AddClientToChatRoom aggiunge un nuovo client a una stanza di chat, e il metodo BroadcastClientJoinedRoomMessage invia un messaggio a tutti i client nella stanza quando un nuovo client si unisce. Il metodo BroadcastMessageToChatRoom invia un messaggio a tutti i client in una stanza ad eccezione del mittente del messaggio. 

La classe ChatClient contiene un oggetto StreamWriter per scrivere messaggi al client, nonché una proprietà UserName per identificare il 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>();

        
        Leggi un singolo messaggio dal 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 utente con lo stesso nome utente esiste già nella stanza di chat
                    throw new InvalidOperationException("User with the same name already exists in the chat room");
                }
                _chatRooms[chatRoomId].Add(chatClient);
            }

            await Task.CompletedTask;
        }
        
        Diffonde il messaggio di un client che si è unito alla stanza.
        



        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])
                {
                    Questo senderName può essere una sorta di Id unico per ogni utente.
                    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; }
    }
}

Passo 6: Infine, implementa il metodo gRPC HandleCommunication nel Passo 3

Il metodo HandleCommunication riceve uno requestStream dal client e invia un responseStream al client. Il metodo legge un messaggio dal client, estrae il nome utente e chatRoomId, e gestisce due casi: un caso di accesso e un caso di chat.

  • Nel caso di login, il metodo verifica se il nome utente e chatRoomId sono validi e invia un messaggio di risposta al client di conseguenza. Se il login ha successo, il client viene aggiunto alla chat room e viene inviato un messaggio di broadcast a tutti i client nella chat room. 
  • Nel caso di chat, il metodo trasmette il messaggio a tutti i client nella chat room. 
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)
             {
                //Leggi un messaggio dal client.
                var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);

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

                        var loginMessage = clientMessage.Login;
                        //Ottieni username e chatRoom Id dal clientMessage.
                        chatRoomId = loginMessage.ChatRoomId;
                        userName = loginMessage.UserName;

                        if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
                        {
                            //Invia un messaggio di fallimento del login.
                            var failureMessage = new ServerMessage
                            {
                                LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
                            };

                            await responseStream.WriteAsync(failureMessage);

                            return;
                        }

                        //Invia messaggio di successo del login al client
                        var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
                        await responseStream.WriteAsync(successMessage);

                        //Aggiungi il client alla chat room.
                        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)
                        {
                            //Trasmetti il messaggio alla stanza
                            await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
                        }

                        break;
                }
            }
        }
    }
}

Directory del progetto completo:

Questo è tutto per parte 1. Nella prossima parte 2, creerò un progetto client con l’implementazione client per completare questa applicazione di chat.

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