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.’
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:
- 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.
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 chiamatoHandleCommunication
. Il metodo viene utilizzato per la trasmissione bidirezionale tra il client e il server. Prende in input una stream diClientMessage
e restituisce in output una stream diServerMessage
.
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 campochat_room_id
specifica la chat room che il client desidera unire, mentre il campouser_name
specifica il nome utente che il client desidera utilizzare nella chat room
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 campotext
.
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 utilizzaoneof
, 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 messaggiClientMessageLogin
eClientMessageChat
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.
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 messaggioClientMessageLogin
, il server risponderà con un messaggioServerMessageLoginSuccess
o un messaggioServerMessageLoginFailure
, a seconda del successo o meno del login. Se il login ha successo, il client può quindi iniziare a inviare messaggiClientMessageChat
per avviare i messaggi di chat.
message ServerMessageLoginSuccess {
}
- Messaggio
ServerMessageUserJoined
definisce il messaggio inviato dal server al client quando un nuovo utente si unisce alla chat room.
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 campotext
specifica il contenuto del messaggio di chat, e il campouser_name
specifica il nome utente dell’utente che ha inviato il messaggio.
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 campooneof
chiamato contenuto con più opzioni. I nomi dei campi sono “login_success
,” “login_failure
,” “user_joined
,” e “chat
,” che corrispondono rispettivamente ai messaggiServerMessageLoginSuccess
,ServerMessageLoginFailure
,ServerMessageUserJoined
, eServerMessageChat
.
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.
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:
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.
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.
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