Creando un chat en asp.net con gRPC

  • net-core
  • net
  • api
  • asp-net
  • grpc

Google Remote Procedure Call (gRPC) se introdujo para ASP.NET Core 3.1 en noviembre de 2019. Tal y como su nombre indica, es un framework de Remote Procedure Call creado por Google, de código abierto. Este framework aprovecha el protocolo HTTP/2 para transportar mensajes en formato binario. Estos mensajes se serializan y deserializan utilizando Protocol Buffers, que se utilizan para definir el contrato de los servicios para el intercambio de esos mensajes.

Puede ser un buen punto de partida ver algunas diferencias entre llamadas RPC y API REST, con el que estamos más acostumbrados a trabajar.

  • En una API REST, una entidad se considera un recurso y utiliza verbos HTTP y URL específicas para exponer operaciones sobre este recurso. La convención RESTful nos anima a usar verbos específicos en una situación concreta: GET, POST, PUT, PATCH y DELETE. De manera análoga, REST depende de los códigos de estado de HTTP. Por ejemplo, si tiene éxito, una operación GET devuelve el estado HTTP 200 OK o HTTP 404 NotFound si no se ha encontrado el recurso.

  • En una API RPC, la característica de una entidad se expone a través de un procedimiento, en el que se especifica, para cada función, qué parámetros tiene de entrada y qué resultado devuelve. No existe una dependencia significativa de los verbos HTTP y las URL.

A continuación vamos a ver algunos de los conceptos más importantes de gRPC, que tenemos que tener en cuenta para montar nuestras aplicaciones, un chat para este ejemplo.

Protocol buffers

Los protocol buffers son descriptores de lenguaje, muy parecidos a WSDL utilizados en servicios SOAP. Se almacenan en ficheros .proto, que son compartidos entre cliente y servidor, con lo que el cliente conoce todas las funciones disponibles y sus parámetros de entrada y salida, conocidos como mensajes.

La sintaxis de estos protobuf, daría para otro post, pero a continuación enumero todo lo que vamos a necesitar para crear nuestro chat de ejemplo.

  • syntax: Se tiene que incluir siempre, ya que se usa para definir la versión de la sintaxis protobuf que desea utilizar. En el momento de escribir este post, existen 3 versiones, proto3, proto2 y proto1, estando ésta última deprecada. En nuestro ejemplo, utilizaremos proto3.

  • import: Al igual que en otros lenguajes, se utiliza para importar definiciones de otro protobuf. En nuestro ejemplo, importamos google/protobuf/empty.proto para usar el tipo google.protobuf.Empty.

  • package: Se utiliza para generar namespaces.

  • message: podría definirse como los DTOs que reciben/devuelven los servicios. Estos mensajes tienen propiedades, que pueden tener distintos tipos.

  • service: se utiliza para declarar las distintas funciones. Cada función está precedida por la palabra clave rpc y seguida por la firma del mensaje. Esta firma consta de un nombre y unos parámetros de entrada y salida. Aunque en nuestro ejemplo, solo tenemos un servicio, que contiene 3 funciones, podríamos tener tantos servicios como deseemos. A la hora de implementar estos servicios, tendremos que implementarlos en C# sobre escribiendo las funciones declaradas.

Channel

Cualquier petición a un servidor gRPC se realiza a través de un canal, permitiendo llamadas a procedimientos remotos. Para iniciar un canal se requiere la dirección del servidor, el puerto y los credenciales. Los tipos de conexión soportados son SSL/TLS, ALTS y autenticación basada en token.

Tipos de servicios

A diferencia de un API REST, que solo soporta un tipo de comunicación unary (el cliente manda una petición y el servidor la contesta), gRPC soporta cuatro tipos de comunicaciones entre cliente y servidor:

  • Unary: El cliente inicia la llamada y el servidor devuelve una respuesta que contiene el estado, y el mensaje de la respuesta. Es el tipo análogo al usado en API REST.

  • Server-streaming: El cliente inicia la llamada y recibe un stream a través del que recibe información (mensajes) del servidor.

  • Client-streaming: El cliente inicia la llamada y recibe un stream a través del que puede enviar información al servidor.

  • Bidirectional: El cliente inicia la llamada y, a través del stream, tanto cliente como servidor pueden enviar información.

Para nuestro ejemplo utilizaremos el primero y el último

Estados

El estado en la respuesta de una solicitud gRPC es obligatorio. Indica si la solicitud tuvo éxito o no y, en caso de no tenerlo, por qué. Los códigos de respuesta que podemos encontrar son los siguientes:

  • ok

  • cancelled

  • unknown

  • invalid_argument

  • deadline_exceeded

  • not_found

  • already_exists

  • permission_denied

  • resource_exhausted

  • failed_precondition

  • aborted

  • out_of_range

  • unimplemented

  • internal

  • unavailable

  • data_loss

  • unauthenticated

Creando nuestro ejemplo

Nuestro ejemplo va a constar de dos aplicaciones. Una aplicación asp.net y una aplicación de consola, que hará las veces de cliente.

Para mantener el ejemplo sencillo, desde el cliente se solicitará un nombre de usuario y nombre del usuario con el que se desea hablar. Una vez hecho esto, se empezarán a mandar y recibir mensajes.

A continuación, se muestran los detalles para implementar las dos aplicaciones.

Implementando parte servidora

Lo primero que tenemos que hacer es añadir los nuget que vamos a necesitar para trabajar con gRPC:

  • Google.Protobuf

  • Grpc.AspNetCore.Server.ClientFactory

  • Grpc.Tools

Una vez instalados estos nugets, tendríamos que añadir los protobuf que definan nuestro servicio. Al ser una funcionalidad sencilla, solo tendremos un protobuf con un servicio que tendrá tres funciones:

  • Connect: Función a la que se llamará cuando entremos al chat, especificando el nombre de usuario. Es de tipo unary.

  • Disconnect: Función a la que se llamará cuando se salga del chat. Al igual que la función anterior, es de tipo unary.

  • SendChatMessage: Función bidireccional para el envío y recepción de mensajes.

Con todo lo anterior, el protobuf, al que he llamado Chat.proto, quedaría de la siguiente manera.

syntax = "proto3";
import "google/protobuf/empty.proto";

package chat;

message ConnectRequest {
    string user_id = 1;
}

message DisconnectRequest {
    string user_id = 1;
}

message ChatMessageRequest{
    string sender_id = 1;
    string receiver_id = 2;
    string message = 3;
}

message ChatMessageResponse {
    string sender_id = 1;
    string message = 2;
}

service ChatService {
    rpc Connect(ConnectRequest) returns (google.protobuf.Empty) {}
    rpc Disconnect (DisconnectRequest) returns (google.protobuf.Empty) {}
    rpc SendChatMessage(stream ChatMessageRequest) returns (stream ChatMessageResponse) {}
}

El siguiente paso, es compilar, para que se generen las clases base que nos permiten implementar el servicio de gRPC. Al hacerse la compilación, estamos en condiciones de implementar el servicio heredando de ChatService.ChatServiceBase y sobre escribiendo los métodos, habiendo un método por cada función del servicio definido en el archivo Chat.proto anterior.

Antes de proceder con esa implementación, es necesario apuntar que he creado una clase en la que apoyarme, que será un singlenton desde el que se gestionan los usuarios conectados y el envío y recepción de mensajes, tal y como se muestra a continuación.

public interface IChatDataProvider
{
    void ConnectUser(string userId);
    void SaveUserInfo(string userId, IServerStreamWriter<ChatMessageResponse> CustomerResponseStream);
    void SendAsync(string receiverId, string message, string senderId);
    void DisconnectUser(string userId);
}
internal class ChatDataProvider : IChatDataProvider
{
    private readonly Dictionary<string, IServerStreamWriter<ChatMessageResponse>> _streamDic = new();

    public void ConnectUser(string userId)
    {
        if (_streamDic.ContainsKey(userId))
            return;

        _streamDic.Add(userId, null);
    }

    public void SaveUserInfo(string userId, IServerStreamWriter<ChatMessageResponse> CustomerResponseStream)
    {
        if (!_streamDic.ContainsKey(userId))
            throw new RpcException(new Status(StatusCode.InvalidArgument, "Could not find user."));

        _streamDic[userId] = CustomerResponseStream;
    }

    public async void SendAsync(string receiverId, string message, string senderId)
    {
        if (!_streamDic.TryGetValue(receiverId, out var stream))
            return;

        if (stream == null)
            return;

        await stream.WriteAsync(new ChatMessageResponse
        {
            Message = message,
            SenderId = senderId
        });
    }

    public void DisconnectUser(string userId)
    {
        if (_streamDic.ContainsKey(userId))
            _streamDic.Remove(userId);
    }
}

Este servicio almacena las distintas conexiones en el diccionario _streamDic, en el que la clave es el nombre del usuario y el valor es el stream que permite hacer llamadas desde el servidor al cliente. Los método que tenemos son sencillos, uno para añadir un usuario al diccionario, otro para eliminarlo, otro para vincular el stream a un usuario y el ultimo para enviar un mensaje a un usuario a través de su stream asociado.

Una vez creado ChatDataProvider, ahora sí, procedemos a la implementación de la clase ChatService.ChatServiceBase, generada a partir de Chat.proto.

public class GrpcChatService: ChatService.ChatServiceBase
{
    private readonly IChatDataProvider _chatDataProvider;

    public GrpcChatService(IChatDataProvider chatDataProvider)
    {
        _chatDataProvider = chatDataProvider;
    }

    public override Task<Empty> Connect(ConnectRequest request, ServerCallContext context)
    {
        _chatDataProvider.ConnectUser(request.UserId);
        return Task.FromResult(new Empty());
    }

    public override Task<Empty> Disconnect(DisconnectRequest request, ServerCallContext context)
    {
        _chatDataProvider.DisconnectUser(request.UserId);
        return Task.FromResult(new Empty());
    }

    public override async Task SendChatMessage(IAsyncStreamReader<ChatMessageRequest> requestStream, IServerStreamWriter<ChatMessageResponse> responseStream, ServerCallContext context)
    {
        if (!await requestStream.MoveNext())
            return;

        var senderId = requestStream.Current.SenderId;
        _chatDataProvider.SaveUserInfo(senderId, responseStream);

        do
        {
            var chatMessage = requestStream.Current.Message;
            if (string.IsNullOrEmpty(chatMessage))
                continue;

            if (string.Equals(chatMessage, "qw!", StringComparison.OrdinalIgnoreCase))
                break;

            _chatDataProvider.SendAsync(requestStream.Current.ReceiverId, chatMessage, senderId);
        } while (await requestStream.MoveNext());
    }
}

Como podemos apreciar, la implementación se apoya en el servicio expuesto anteriormente, de esta manera, al conectarse y desconectarse, se llama a los métodos pertinentes que añaden o eliminan el usuario de nuestra estructura de datos (un diccionario en este ejemplo).

El método que cabe destacar es SendChatMessage. En este método se reciben dos streams. Uno será el utilizado por el cliente para enviarnos mensajes, por lo que el servidor tiene que estar a la escucha para procesarlos. Este es requestStream. El otro (responseStream) es el que utiliza el servidor para enviar mensajes al cliente, por lo que, es éste stream el que queda asociado al usuario indicado para enviarle mensajes cuando otro usuario así lo solicite.

Por último, en nuestro Program.cs tenemos que registrar nuestro singleton y la implementación del servicio gRPC. Además, como en mi caso estoy usando kestrel, le indico que se quede a la escucha de peticiones HTTP2 en el puerto 5002, que es el protocolo utilizado por gRPC

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options => options.ListenLocalhost(5002, o => o.Protocols = HttpProtocols.Http2));
...
builder.Services.AddSingleton<IChatDataProvider, ChatDataProvider>();
...
builder.Services.AddGrpc();
...
app.MapControllers();
app.MapGrpcService<GrpcChatService>();

app.Run();

Implementando cliente

La aplicación cliente es una aplicación de consola, tal y como he indicado anteriormente. Toda la implementación se ha hecho en el Program.cs. A continuación muestro todo el código para después entrar en algunos detalles.

internal class Program
    {
        private static GrpcChannel channel;
        private static ChatService.ChatServiceClient chatClient;
        private static AsyncDuplexStreamingCall<ChatMessageRequest, ChatMessageResponse> sendChatMessageStream;
        private static string senderId;
        private static string receiverId;

        static async Task Main(string[] args)
        {
            SetupChannel();
            await ConnectAsync();
            await ProcessReceivedMessagesAsync();

            if (await SendMessageAsync())
                return;

            await DisconnectAsync();
        }

        private static void SetupChannel()
        {
            var loggerFactory = LoggerFactory.Create(logging =>
            {
                logging.AddConsole();
                logging.SetMinimumLevel(LogLevel.Trace);
            });

            var handler = new SocketsHttpHandler
            {
                KeepAlivePingDelay = TimeSpan.FromSeconds(15), //Ping to server to keep the connection alive
                KeepAlivePingTimeout = TimeSpan.FromSeconds(5), //Timeout for the ping to avoid pings flood the server
                PooledConnectionIdleTimeout = TimeSpan.FromSeconds(10),
                UseProxy = false,
            };

            channel = GrpcChannel.ForAddress("http://localhost:5002", new GrpcChannelOptions
            {
                LoggerFactory = loggerFactory,
                Credentials = ChannelCredentials.Insecure,
                HttpHandler = handler
            });

            chatClient = new ChatService.ChatServiceClient(channel);
        }

        private static async Task ConnectAsync()
        {
            senderId = GetUserId();
            receiverId = GetReceiverId();
            ConnectRequest connectRequest = new ConnectRequest { UserId = senderId };
            AsyncUnaryCall<Empty> connectCall = chatClient.ConnectAsync(connectRequest);
            await connectCall.ResponseAsync;
        }

        private static string GetUserId()
        {
            Console.WriteLine("Please enter your name:");
            string userId = Console.ReadLine();
            while (string.IsNullOrEmpty(userId))
            {
                Console.WriteLine("Please enter a valid name. Cannot be null or empty.");
                userId = Console.ReadLine();
            }

            return userId;
        }

        private static string GetReceiverId()
        {
            Console.WriteLine("Please enter the name of the person you want to talk to:");
            string userId = Console.ReadLine();
            while (string.IsNullOrEmpty(userId))
            {
                Console.WriteLine("Please enter a valid name. Cannot be null or empty.");
                userId = Console.ReadLine();
            }

            return userId;
        }

        private static async Task ProcessReceivedMessagesAsync()
        {
            Console.ForegroundColor = ConsoleColor.Green;
            sendChatMessageStream = chatClient.SendChatMessage();
            await sendChatMessageStream.RequestStream.WriteAsync(new ChatMessageRequest
            {
                SenderId = senderId,
                ReceiverId = string.Empty,
                Message = string.Empty,
            });

            _ = Task.Run(async () =>
            {
                // Here are process all the message the customer received from the server
                while (await sendChatMessageStream.ResponseStream.MoveNext())
                    ShowReceivedMessage(sendChatMessageStream.ResponseStream.Current);
            });
        }

        private static void ShowReceivedMessage(ChatMessageResponse chatMessage)
        {
            Console.ForegroundColor = ConsoleColor.White;
            Console.WriteLine($"{chatMessage.SenderId}: {chatMessage.Message}");
            Console.ForegroundColor = ConsoleColor.Green;
        }

        private static async Task<bool> SendMessageAsync()
        {
            var message = Console.ReadLine();
            ShowSentMessage(message);
            while (!string.Equals(message, "qw!", StringComparison.OrdinalIgnoreCase))
            {
                await SendMessageAsync(message);
                message = Console.ReadLine();
                ShowSentMessage(message);
            }

            return false;
        }

        private static async Task SendMessageAsync(string message)
        {
            await sendChatMessageStream.RequestStream.WriteAsync(new ChatMessageRequest
            {
                SenderId = senderId,
                ReceiverId = receiverId,
                Message = message,
            });
        }

        private static void ShowSentMessage(string message)
        {
            if (Console.CursorTop == 0)
                return;

            Console.SetCursorPosition(0, Console.CursorTop - 1);

            if (!string.IsNullOrEmpty(message))
                Console.WriteLine($"You: {message}");
        }

        private static async Task DisconnectAsync()
        {
            await sendChatMessageStream.RequestStream.CompleteAsync();
            AsyncUnaryCall<Empty> disconnectCall = chatClient.DisconnectAsync(new DisconnectRequest { UserId = senderId });
            await disconnectCall.ResponseAsync;
            Console.ReadKey();

            channel.Dispose();
            await channel.ShutdownAsync();
        }
    }
  • **SetupChannel**: Configura el canal gRPC que se utilizará para comunicarse con el servidor. Para esta configuración se necesita la url, como mínimo. Para este ejemplo también se han establecido otras opciones, como el manejo de pings para mantener la conexión viva y un logger personalizado para mostrar las trazas por consola.

  • **ConnectAsync**: Solicita al usuario que introduzca su nombre y el del usuario con el que desea hablar. Tras esto, ejecuta se envía la solicitud de conexión al servidor.

  • **ProcessReceivedMessagesAsync**: Aquí se procesan todos los mensajes recibidos del servidor.

  • **DisconnectAsync**: Este método se desconecta del servidor de chat. Envía una solicitud de desconexión al servidor y luego cierra el canal gRPC.

Hay más métodos, que no entro a detallar, ya que simplemente son métodos de utilidad para recoger datos de la consola o mostrarlos, para interactuar con el usuario.

Beneficios de usar gRPC

Los mensajes gRPC se serializan en binario, lo que le permite un muy buen rendimiento, utilizando menos memoria que la serialización / deserialización en JSON.
Además, nos da la opción de implementar streaming bidireccional, ya que lo soporta y lo ofrece sobre HTTP/2.

Desventajas

Al utilizar HTTP/2, hace que no sea totalmente compatible con los navegadores actuales porque no pueden interpretar datos binarios. Los datos binarios que renderiza gRPC también hacen que la depuración sea más complicada porque dichos binarios son difíciles de decodificar sin conocer el esquema.

Por otro lado, gRPC no es cacheable (REST sí que lo es), lo cual es una clara desventaja. Sin embargo, hay maneras de implementar el almacenamiento en caché, como utilizar una caché en memoria en lugar de una caché HTTP.

Talk is cheap, show me the code

Aunque he intentado explicarlo lo mejor posible, a continuación dejo un enlace al repo en el que está el proyecto, para que podáis descargarlo y hacer las pruebas y cambios que necesitéis https://github.com/jorgediegocrespo/grpcChat/

Foto de Jason Leung en Unsplash