Neste artigo, criaremos um simples aplicativo de servidor de chat gRPC concorrente. Utilizaremos o .NET Core, uma plataforma cruzada, de código aberto e modular, para desenvolver nosso aplicativo de chat. Abordaremos os seguintes tópicos:
- A brief introduction to gRPC
- Configuração do ambiente gRPC e definição do contrato de serviço
- Implementação do serviço de chat e tratamento de solicitações de clientes
- Tratamento de vários clientes de forma concorrente usando programação assíncrona
- Transmissão de mensagens de chat para todos os clientes conectados na mesma sala
Ao final deste tutorial, você terá uma compreensão de como usar o gRPC para construir um servidor de chat.
O que é gRPC?
gRPC é um acrônimo que significa Google Remote Procedure Calls. Foi desenvolvido inicialmente pelo Google e agora é mantido pela Cloud Native Computing Foundation (CNCF). O gRPC permite conectar, invocar, operar e depurar aplicativos distribuídos e heterogêneos tão facilmente quanto fazer uma chamada de função local.
O gRPC utiliza o HTTP/2 para transporte, uma abordagem de desenvolvimento de API baseada em contratos, Protocol Buffers (Protobuf) como linguagem de definição de interface e formato subjacente de intercâmbio de mensagens. Ele suporta quatro tipos de API (RPC unário, RPC de streaming do servidor, RPC de streaming do cliente e RPC de streaming bidirecional streaming). Você pode ler mais sobre gRPC aqui.
Começando
Antes de começarmos a escrever código, é necessário realizar a instalação do .NET Core e garantir que você possui os seguintes pré-requisitos:
- Visual Studio Code, Visual Studio ou JetBrains Rider IDE
- .NET Core
- gRPC .NET
- Protobuf
Passo 1: Criar um Projeto gRPC a partir do Visual Studio ou Linha de Comando
- Você pode usar o seguinte comando para criar um novo projeto. Se tiver êxito, você deverá tê-lo criado no diretório especificado com o nome ‘ChatServer.’
dotnet new grpc -n ChatServerApp
- Abra o projeto com o editor de sua escolha. Estou usando o Visual Studio para Mac.
Passo 2: Definir as Mensagens Protobuf em um Arquivo Proto
Contrato Protobuf:
- Crie um arquivo .proto chamado server.proto dentro da pasta protos. O arquivo proto é usado para definir a estrutura do serviço, incluindo os tipos de mensagens e os métodos que o serviço suporta.
syntax = "proto3";
option csharp_namespace = "ChatServerApp.Protos";
package chat;
service ChatServer {
// Fluxo de comunicação bidirecional entre cliente e servidor
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
//Mensagens do Cliente:
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;
}
//Mensagens do Servidor
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
define o principal serviço de nossa aplicação de chat, que inclui um único método RPC chamadoHandleCommunication
. O método é utilizado para streaming bidirecional entre o cliente e o servidor. Ele recebe um fluxo deClientMessage
como entrada e retorna um fluxo deServerMessage
como saída.
service ChatServer {
// Fluxo de comunicação bidirecional entre cliente e servidor
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
ClientMessageLogin
, que será enviado pelo cliente, possui dois campos chamados chat_room_id e user_name. Este tipo de mensagem é usado para enviar informações de login do cliente para o servidor. O campochat_room_id
especifica o chat room que o cliente deseja ingressar, enquanto o campouser_name
especifica o nome de usuário que o cliente deseja utilizar no chat room
message ClientMessageLogin {
string chat_room_id = 1;
string user_name = 2;
}
ClientMessageChat
que será usado para enviar mensagens de chat do cliente para o servidor. Ele contém um único campotext
.
message ClientMessageChat {
string text = 1;
}
ClientMessage
define os diferentes tipos de mensagens que um cliente pode enviar para o servidor. Ele contém um oneof campo, o que significa que apenas um dos campos pode ser definido ao mesmo tempo. Se você usaroneof
, o código C# gerado conterá uma enumeração indicando quais campos foram definidos. Os nomes dos campos são “login
” e “chat
” que correspondem às mensagensClientMessageLogin
eClientMessageChat
respectivamente.
message ClientMessage {
oneof content {
ClientMessageLogin login = 1;
ClientMessageChat chat = 2;
}
}
ServerMessageLoginFailure
define a mensagem enviada pelo servidor para indicar que um cliente não conseguiu fazer login no chat. O campo de razão especifica o motivo do fracasso.
message ServerMessageLoginFailure {
string reason = 1;
}
-
ServerMessageLoginSuccess
define a mensagem enviada pelo servidor para indicar que um cliente logou com sucesso no chat. Não contém campos e simplesmente indica que o login foi bem-sucedido. Quando um cliente envia uma mensagemClientMessageLogin
, o servidor responderá com uma mensagemServerMessageLoginSuccess
ouServerMessageLoginFailure
, dependendo de o login ter sido bem-sucedido ou não. Se o login for bem-sucedido, o cliente poderá então começar a enviar mensagensClientMessageChat
para iniciar as mensagens de chat.
message ServerMessageLoginSuccess {
}
- Mensagem
ServerMessageUserJoined
define a mensagem enviada pelo servidor ao cliente quando um novo usuário entra no chat.
message ServerMessageUserJoined {
string user_name = 1;
}
- Mensagem
ServerMessageChat
define a mensagem enviada pelo servidor para indicar que uma nova mensagem de chat foi recebida. O campotext
especifica o conteúdo da mensagem de chat, e o campouser_name
especifica o nome de usuário do usuário que enviou a mensagem.
message ServerMessageChat {
string text = 1;
string user_name = 2;
}
- Mensagem
ServerMessage
define os diferentes tipos de mensagens que podem ser enviadas do servidor para o cliente. Contém umoneof
campo chamado conteúdo com várias opções. Os nomes dos campos são “login_success
,” “login_failure
,” “user_joined
,” e “chat
,” que correspondem às mensagensServerMessageLoginSuccess
,ServerMessageLoginFailure
,ServerMessageUserJoined
, eServerMessageChat
, respectivamente.
message ServerMessage {
oneof content {
ServerMessageLoginSuccess login_success = 1;
ServerMessageLoginFailure login_failure = 2;
ServerMessageUserJoined user_joined = 3;
ServerMessageChat chat = 4;
}
}
Passo 3: Adicionar uma Classe ChatService
Adicione uma classe ChatService
derivada de ChatServerBase
(gerada a partir do arquivo server.proto usando o gerador de código gRPC protoc). Em seguida, sobrescrevemos o método HandleCommunication
. A implementação do método HandleCommunication
será responsável por gerenciar a comunicação entre o cliente e o servidor.
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: Configurar gRPC
No arquivo program.cs:
using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
/*
// É necessária uma configuração adicional para executar com sucesso gRPC no macOS.
// Para instruções sobre como configurar o Kestrel e os clientes gRPC no macOS,
// visite 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 =>
{
// Configurar um ponto de extremidade HTTP/2 sem TLS.
options.ListenLocalhost(50051, o => o.Protocols =
HttpProtocols.Http2);
});
// Adicionar serviços ao contêiner.
builder.Services.AddGrpc();
builder.Services.AddSingleton();
var app = builder.Build();
// Configurar o pipeline de solicitações 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: O modelo e exemplos de gRPC do ASP.NET Core utilizam o TLS por padrão. Mas para fins de desenvolvimento, configuramos o Kestrel e o cliente gRPC para usar o HTTP/2 sem TLS.
Passo 5: Crie um ChatRoomService
e implemente os vários métodos necessários em HandleCommunication
A classe ChatRoomService
é responsável por gerenciar salas de chat e clientes, bem como lidar com mensagens enviadas entre os clientes. Ela usa um ConcurrentDictionary
para armazenar salas de chat e uma lista de objetos ChatClient
para cada sala. O método AddClientToChatRoom
adiciona um novo cliente a uma sala de chat, e o método BroadcastClientJoinedRoomMessage
envia uma mensagem a todos os clientes na sala quando um novo cliente se junta. O método BroadcastMessageToChatRoom
envia uma mensagem a todos os clientes em uma sala, exceto ao remetente da mensagem.
A classe ChatClient
contém um objeto StreamWriter
para escrever mensagens para o cliente, bem como uma propriedade UserName para identificar o cliente.
using System;
using ChatServer;
using Grpc.Core;
using System.Collections.Concurrent;
namespace ChatServer.Services
{
public class ChatRoomService
{
private static readonly ConcurrentDictionary> _chatRooms = new ConcurrentDictionary>();
/// Leia uma única mensagem do cliente.
///
///
///
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)
{
// Já existe um usuário com o mesmo nome de usuário na sala de chat
throw new InvalidOperationException("User with the same name already exists in the chat room");
}
_chatRooms[chatRoomId].Add(chatClient);
}
await Task.CompletedTask;
}
/// Mensagem de entrada da sala de broadcast do cliente.
///
///
///
///
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])
{
//Este senderName pode ser algo de ID único para cada usuário.
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; }
}
}
Etapa 6: Finalmente, implemente o método gRPC HandleCommunication
na Etapa 3
O HandleCommunication
recebe um requestStream
do cliente e envia um responseStream
de volta para o cliente. O método lê uma mensagem do cliente, extrai o nome de usuário e chatRoomId
, e lida com dois casos: um caso de login e um caso de chat.
- No caso de login, o método verifica se o nome de usuário e
chatRoomId
são válidos e envia uma mensagem de resposta ao cliente de acordo. Se o login for bem-sucedido, o cliente é adicionado à sala de chat e uma mensagem de transmissão é enviada a todos os clientes na sala de chat. - No caso de chat, o método transmite a mensagem a todos os clientes na sala de chat.
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)
{
//Ler uma mensagem do cliente.
var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);
switch (clientMessage.ContentCase)
{
case ClientMessage.ContentOneofCase.Login:
var loginMessage = clientMessage.Login;
//obter nome de usuário e chatRoom Id do clientMessage.
chatRoomId = loginMessage.ChatRoomId;
userName = loginMessage.UserName;
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
{
//Enviar uma mensagem de falha no login.
var failureMessage = new ServerMessage
{
LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
};
await responseStream.WriteAsync(failureMessage);
return;
}
//Enviar mensagem de sucesso no login ao cliente
var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
await responseStream.WriteAsync(successMessage);
//Adicionar cliente à sala 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)
{
//transmitir a mensagem para o quarto
await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
}
break;
}
}
}
}
}
Diretório do projeto completo:
Isso é tudo para parte 1. Na próxima parte 2, vou criar um projeto de cliente com a implementação do cliente para completar este aplicativo de chat.
Source:
https://dzone.com/articles/create-a-concurrent-grpc-chat-messaging-in-net-7