Construa um Servidor de Chat Simples com gRPC no .Net Core

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.’
PowerShell

 

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:

  1. 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. 
ProtoBuf

 

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 chamado HandleCommunication. O método é utilizado para streaming bidirecional entre o cliente e o servidor. Ele recebe um fluxo de ClientMessage como entrada e retorna um fluxo de ServerMessage como saída. 
ProtoBuf

 

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 campo chat_room_id especifica o chat room que o cliente deseja ingressar, enquanto o campo user_name especifica o nome de usuário que o cliente deseja utilizar no chat room
ProtoBuf

 

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 campo text.
ProtoBuf

 

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ê usar oneof, 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 mensagens ClientMessageLogin e ClientMessageChat respectivamente.
ProtoBuf

 

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.
ProtoBuf

 

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 mensagem ClientMessageLogin, o servidor responderá com uma mensagem ServerMessageLoginSuccess ou ServerMessageLoginFailure, 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 mensagens ClientMessageChat para iniciar as mensagens de chat.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • Mensagem ServerMessageUserJoined define a mensagem enviada pelo servidor ao cliente quando um novo usuário entra no chat.
ProtoBuf

 

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 campo text especifica o conteúdo da mensagem de chat, e o campo user_name especifica o nome de usuário do usuário que enviou a mensagem.
ProtoBuf

 

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 um oneof 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 mensagens ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, e ServerMessageChat, respectivamente.
ProtoBuf

 

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.

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: Configurar gRPC

No arquivo program.cs:

C#

 

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.

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

        
        /// 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. 
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)
             {
                //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