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.’
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:
- 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.
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 llamadoHandleCommunication
. Este método se utiliza para la transmisión bidireccional entre el cliente y el servidor. Recibe una secuencia deClientMessage
como entrada y devuelve una secuencia deServerMessage
como salida.
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 campochat_room_id
especifica la sala de chat que el cliente desea unirse, mientras que el campouser_name
especifica el nombre de usuario que el cliente desea utilizar en la sala de chat
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 campotext
.
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 usasoneof
, 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 mensajesClientMessageLogin
yClientMessageChat
respectivamente.
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.
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 mensajeClientMessageLogin
, el servidor responderá con un mensajeServerMessageLoginSuccess
o un mensajeServerMessageLoginFailure
, 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 mensajesClientMessageChat
para iniciar mensajes de chat.
message ServerMessageLoginSuccess {
}
- Mensaje
ServerMessageUserJoined
define el mensaje enviado por el servidor al cliente cuando un nuevo usuario se une a la sala de chat.
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 campotext
especifica el contenido del mensaje de chat, y el campouser_name
especifica el nombre de usuario del usuario que envió el mensaje.
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 campooneof
llamado contenido con múltiples opciones. Los nombres de los campos son “login_success
,” “login_failure
,” “user_joined
,” y “chat
,” que corresponden a los mensajesServerMessageLoginSuccess
,ServerMessageLoginFailure
,ServerMessageUserJoined
, yServerMessageChat
, respectivamente.
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.
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:
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.
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.
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