Crear un Servidor de Chat Simple con gRPC en .Net Core

En este artículo, crearemos una aplicación de chat gRPC simple y concurrente. Utilizaremos .NET Core, un marco de trabajo multiplataforma, de código abierto y modular, para desarrollar nuestra aplicación de chat. Cubriremos los siguientes temas:

  • A brief introduction to gRPC
  • Configuración del entorno gRPC y definición del contrato de servicio
  • Implementación del servicio de chat y manejo de solicitudes de clientes
  • Manejo de múltiples clientes de manera concurrente mediante programación asíncrona
  • Transmisión de mensajes de chat a todos los clientes conectados en la misma sala

Al final de este tutorial, tendrás una comprensión de cómo utilizar gRPC para crear un servidor de chat.

¿Qué es gRPC?

gRPC es un acrónimo que significa Google Remote Procedure Calls. Fue desarrollado inicialmente por Google y ahora es mantenido por la Cloud Native Computing Foundation (CNCF). gRPC te permite conectar, invocar, operar y depurar aplicaciones distribuidas y heterogéneas de manera tan sencilla como realizar una llamada a una función local. 

gRPC utiliza HTTP/2 para transporte, una aproximación de desarrollo de API de primera mano, Protocol Buffers (Protobuf) como lenguaje de definición de interfaz y su formato subyacente para el intercambio de mensajes. Puede soportar cuatro tipos de API (RPC unario, RPC de streaming del servidor, RPC de streaming del cliente y RPC de streaming bidireccional ). Puedes leer más sobre gRPC aquí.

Empezando

Antes de comenzar a escribir código, es necesario realizar una instalación de .NET Core y asegurarse de tener los siguientes requisitos previos en su lugar:

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

Paso 1: Crear un Proyecto gRPC desde Visual Studio o la Línea de Comandos

  • Puedes utilizar el siguiente comando para crear un nuevo proyecto. Si tiene éxito, deberías tenerlo creado en el directorio que especifiques con el nombre ‘ChatServer.’
PowerShell

 

dotnet new grpc -n ChatServerApp

  • Abre el proyecto con tu editor elegido. Estoy usando Visual Studio para Mac.

Paso 2: Definir los Mensajes Protobuf en un Archivo Proto

Contrato Protobuf:

  1. Crea un archivo .proto llamado server.proto dentro de la carpeta protos. El archivo proto se utiliza para definir la estructura del servicio, incluyendo los tipos de mensajes y los métodos que el servicio soporta. 
ProtoBuf

 

syntax = "proto3";

option csharp_namespace = "ChatServerApp.Protos";

package chat;

service ChatServer {
  // Flujo de comunicación bidireccional entre cliente y servidor
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

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

//Mensajes del 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 el servicio principal de nuestra aplicación de chat, que incluye un único método RPC llamado HandleCommunication. Este método se utiliza para la transmisión bidireccional entre el cliente y el servidor. Recibe una secuencia de ClientMessage como entrada y devuelve una secuencia de ServerMessage como salida.
ProtoBuf

 

service ChatServer {
  // Flujo de comunicación bidireccional entre cliente y servidor
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin, que será enviado por el cliente, tiene dos campos llamados chat_room_id y user_name. Este tipo de mensaje se utiliza para enviar información de inicio de sesión desde el cliente al servidor. El campo chat_room_id especifica la sala de chat que el cliente desea unirse, mientras que el campo user_name especifica el nombre de usuario que el cliente desea utilizar en la sala de chat
ProtoBuf

 

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


  • ClientMessageChat que se utilizará para enviar mensajes de chat desde el cliente al servidor. Contiene un solo campo text.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessage define los diferentes tipos de mensajes que un cliente puede enviar al servidor. Contiene un campo oneof, lo que significa que solo se puede establecer uno de los campos a la vez. Si usas oneof, el código C# generado contendrá una enumeración que indica qué campos se han establecido. Los nombres de los campos son “login” y “chat” que corresponden a los mensajes ClientMessageLogin y ClientMessageChat respectivamente.
ProtoBuf

 

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

  • ServerMessageLoginFailure define el mensaje enviado por el servidor para indicar que un cliente no pudo iniciar sesión en la sala de chat. El campo de razón especifica la causa del fracaso.
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  •  ServerMessageLoginSuccess define el mensaje enviado por el servidor para indicar que un cliente ha iniciado sesión correctamente en la sala de chat. No contiene campos y simplemente indica que el inicio de sesión fue exitoso. Cuando un cliente envía un mensaje ClientMessageLogin, el servidor responderá con un mensaje ServerMessageLoginSuccess o un mensaje ServerMessageLoginFailure, dependiendo de si el inicio de sesión fue exitoso o no. Si el inicio de sesión fue exitoso, el cliente puede comenzar a enviar mensajes ClientMessageChat para iniciar mensajes de chat.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • Mensaje ServerMessageUserJoined define el mensaje enviado por el servidor al cliente cuando un nuevo usuario se une a la sala de chat.
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • Mensaje ServerMessageChat define el mensaje enviado por el servidor para indicar que se ha recibido un nuevo mensaje de chat. El campo text especifica el contenido del mensaje de chat, y el campo user_name especifica el nombre de usuario del usuario que envió el mensaje.
ProtoBuf

 

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

  • Mensaje ServerMessage define los diferentes tipos de mensajes que pueden enviarse desde el servidor al cliente. Contiene un campo oneof llamado contenido con múltiples opciones. Los nombres de los campos son “login_success,” “login_failure,” “user_joined,” y “chat,” que corresponden a los mensajes ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, y ServerMessageChat, respectivamente.
ProtoBuf

 

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

Paso 3: Agregar una Clase ChatService

Agregue una clase ChatService que se derive de ChatServerBase (generada a partir del archivo server.proto utilizando el generador de código gRPC protoc). Luego, sobrescribimos el método HandleCommunication. La implementación del método HandleCommunication será responsable de manejar la comunicación entre el cliente y el 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);
    }
}

Paso 4: Configurar gRPC

En el archivo program.cs:

C#

 

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

var builder = WebApplication.CreateBuilder(args);


/*
// Se requiere configuración adicional para ejecutar correctamente gRPC en macOS.
// Para instrucciones sobre cómo configurar Kestrel y clientes gRPC en 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 un punto final HTTP/2 sin TLS.
    options.ListenLocalhost(50051, o => o.Protocols =
        HttpProtocols.Http2);
});


// Agregar servicios al contenedor.
builder.Services.AddGrpc();
builder.Services.AddSingleton();

var app = builder.Build();

// Configurar la canalización de solicitud 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: El modelo de gRPC de ASP.NET Core y ejemplos utilizan TLS de forma predeterminada. Pero para fines de desarrollo, configuramos Kestrel y el cliente gRPC para usar HTTP/2 sin TLS.

Paso 5: Crear un ChatRoomService e implementar varios métodos necesarios en HandleCommunication

La clase ChatRoomService es responsable de administrar las salas de chat y los clientes, así como de manejar los mensajes enviados entre los clientes. Utiliza un ConcurrentDictionary para almacenar las salas de chat y una lista de objetos ChatClient para cada sala. El método AddClientToChatRoom agrega un nuevo cliente a una sala de chat, y el método BroadcastClientJoinedRoomMessage envía un mensaje a todos los clientes en la sala cuando un nuevo cliente se une. El método BroadcastMessageToChatRoom envía un mensaje a todos los clientes en una sala excepto al remitente del mensaje.

La clase ChatClient contiene un objeto StreamWriter para escribir mensajes al cliente, así como una propiedad UserName para identificar al 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>();

        
        Leer un solo mensaje del 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)
                {
                    Un usuario con el mismo nombre de usuario ya existe en la sala de chat
                    throw new InvalidOperationException("User with the same name already exists in the chat room");
                }
                _chatRooms[chatRoomId].Add(chatClient);
            }

            await Task.CompletedTask;
        }
        
        Anunciar el mensaje de un cliente que se unió a la sala.
        



        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 puede ser algo de identificación única para cada usuario.
                    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; }
    }
}

Paso 6: Finalmente, implementar el método gRPC HandleCommunication del Paso 3

El HandleCommunication recibe un requestStream del cliente y envía un responseStream de vuelta al cliente. El método lee un mensaje del cliente, extrae el nombre de usuario y chatRoomId, y maneja dos casos: un caso de inicio de sesión y un caso de chat.

  • En el caso de inicio de sesión, el método verifica si el nombre de usuario y chatRoomId son válidos y envía un mensaje de respuesta al cliente en consecuencia. Si el inicio de sesión es exitoso, el cliente se agrega a la sala de chat y se envía un mensaje de difusión a todos los clientes en la sala de chat. 
  • En el caso de chat, el método difunde el mensaje a todos los clientes en la 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)
             {
                //Leer un mensaje del cliente.
                var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);

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

                        var loginMessage = clientMessage.Login;
                        //Obtener el nombre de usuario y chatRoom Id del clientMessage.
                        chatRoomId = loginMessage.ChatRoomId;
                        userName = loginMessage.UserName;

                        if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
                        {
                            //Enviar un mensaje de fracaso de inicio de sesión.
                            var failureMessage = new ServerMessage
                            {
                                LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
                            };

                            await responseStream.WriteAsync(failureMessage);

                            return;
                        }

                        //Enviar mensaje de inicio de sesión exitoso al cliente
                        var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
                        await responseStream.WriteAsync(successMessage);

                        //Agregar cliente a la 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)
                        {
                            //Emitir el mensaje a la sala
                            await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
                        }

                        break;
                }
            }
        }
    }
}

Directorio del proyecto completo:

Eso es todo para parte 1. En la próxima parte 2, crearé un proyecto de cliente con la implementación del cliente para completar esta aplicación de chat.

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