Compatibilidad con WebSockets en ASP.NET Core

Por Tom Dykstra y Andrew Stanton-Nurse

En este artículo se ofrece una introducción a WebSockets en ASP.NET Core. WebSocket (RFC 6455) es un protocolo que habilita canales de comunicación bidireccional persistentes a través de conexiones TCP. Se usa en aplicaciones que sacan partido de comunicaciones rápidas y en tiempo real, como las aplicaciones de chat, panel y juegos.

Vea o descargue el código de ejemplo (cómo descargarlo). Cómo ejecutar.

SignalR

ASP.NET CoreSignalR es una biblioteca que simplifica la adición de funcionalidad web en tiempo real a las aplicaciones. Usa WebSockets siempre que sea posible.

Para la mayoría de las aplicaciones, se recomienda SignalR en lugar de WebSockets sin procesar. SignalR proporciona transporte de reserva para entornos donde WebSockets no está disponible. También proporciona un modelo básico de aplicación de llamada a procedimiento remoto. Además, en la mayoría de los escenarios, SignalR no tiene ninguna desventaja significativa de rendimiento en comparación con WebSockets sin procesar.

En algunas aplicaciones, gRPC en .NET proporciona una alternativa a WebSockets.

Requisitos previos

  • Cualquier sistema operativo que admita ASP.NET Core:
    • Windows 7/Windows Server 2008 o posterior
    • Linux
    • macOS
  • Si la aplicación se ejecuta en Windows con IIS:
  • Si la aplicación se ejecuta en HTTP.sys:
    • Windows 8/Windows Server 2012 o versiones posteriores
  • Para saber qué exploradores son compatibles, vea https://caniuse.com/#feat=websockets.

Configurar el middleware

Agregue el middleware de WebSockets al método Configure de la clase Startup:

app.UseWebSockets();

Nota

Si quiere aceptar las solicitudes de WebSocket en un controlador, la llamada a app.UseWebSockets debe producirse antes de app.UseEndpoints.

Se pueden configurar estas opciones:

  • KeepAliveInterval: la frecuencia con que se envían marcos "ping" al cliente, para asegurarse de que los servidores proxy mantienen abierta la conexión. El valor predeterminado es de dos minutos.

Se pueden configurar estas opciones:

  • KeepAliveInterval: la frecuencia con que se envían marcos "ping" al cliente, para asegurarse de que los servidores proxy mantienen abierta la conexión. El valor predeterminado es de dos minutos.
  • AllowedOrigins - Una lista de valores de encabezado de origen permitidos para las solicitudes WebSocket. De forma predeterminada, se permiten todos los orígenes. Consulte "Restricción de los orígenes de WebSocket" a continuación para obtener información detallada.
var webSocketOptions = new WebSocketOptions() 
{
    KeepAliveInterval = TimeSpan.FromSeconds(120),
};

app.UseWebSockets(webSocketOptions);

Aceptar solicitudes WebSocket

En algún momento posterior del ciclo de solicitudes (más adelante en el método Configure o en un método de acción, por ejemplo) debe comprobar si se trata de una solicitud WebSocket y aceptarla.

El siguiente ejemplo se corresponde con un momento más adelante en el método Configure:

app.Use(async (context, next) =>
{
    if (context.Request.Path == "/ws")
    {
        if (context.WebSockets.IsWebSocketRequest)
        {
            using (WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync())
            {
                await Echo(context, webSocket);
            }
        }
        else
        {
            context.Response.StatusCode = (int) HttpStatusCode.BadRequest;
        }
    }
    else
    {
        await next();
    }

});

Una solicitud WebSocket puede proceder de cualquier dirección URL, pero este código de ejemplo solo acepta solicitudes de /ws.

Se puede adoptar un enfoque similar en un método de controlador:

public class WebSocketController : ControllerBase
{
    [HttpGet("/ws")]
    public async Task Get()
    {
        if (HttpContext.WebSockets.IsWebSocketRequest)
        {
            using WebSocket webSocket = await 
                               HttpContext.WebSockets.AcceptWebSocketAsync();
            await Echo(HttpContext, webSocket);
        }
        else
        {
            HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
        }
    }

Cuando se usa un WebSocket, debe mantener la canalización de middleware en ejecución durante la duración de la conexión. Si intenta enviar o recibir un mensaje de WebSocket después de que finalice la canalización de middleware, es posible que obtenga una excepción como la siguiente:

System.Net.WebSockets.WebSocketException (0x80004005): The remote party closed the WebSocket connection without completing the close handshake. ---> System.ObjectDisposedException: Cannot write to the response body, the response has completed.
Object name: 'HttpResponseStream'.

Si utiliza un servicio en segundo plano para escribir datos en un WebSocket, asegúrese de mantener en ejecución el canal de middleware. Para ello, utilice un TaskCompletionSource<TResult>. Pase TaskCompletionSource a su servicio de segundo plano y pídale que llame a TrySetResult cuando termine con WebSocket. Después, espere (con await) la propiedad Task durante la solicitud, como se muestra en el ejemplo siguiente:

app.Use(async (context, next) =>
{
    using (WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync())
    {
        var socketFinishedTcs = new TaskCompletionSource<object>();

        BackgroundSocketProcessor.AddSocket(webSocket, socketFinishedTcs);

        await socketFinishedTcs.Task;
    }
});

La excepción de cierre de WebSocket también puede producirse si la devolución de un método de acción ocurre demasiado pronto. Al aceptar un socket en un método de acción, espere a que finalice el código que usa el socket antes de devolver el método de acción.

No use nunca Task.Wait, Task.Result ni llamadas de bloqueo similares para esperar a que se complete el socket, ya que pueden causar graves problemas de subprocesamiento. Use siempre await.

Compresión

Advertencia

La habilitación de la compresión a través de conexiones cifradas puede hacer que una aplicación esté sujeta a ataques CRIME/BREACH. Si envía información confidencial, evite habilitar la compresión o use WebSocketMessageFlags.DisableCompression al llamar a WebSocket.SendAsync. Esta recomendación se aplica a ambos lados de WebSocket. Tenga en cuenta que la API de WebSockets en el explorador no tiene configuración para deshabilitar la compresión por envío.

Si se quiere la compresión de mensajes a través de WebSockets, el código de aceptación debe especificar que permite la compresión de la siguiente manera:

using (WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync(
    new WebSocketAcceptContext() { DangerousEnableCompression = true }))
{
}

WebSocketAcceptContext.ServerMaxWindowBits y WebSocketAcceptContext.DisableServerContextTakeover son opciones avanzadas que controlan cómo funciona la compresión.

La compresión se negocia entre el cliente y el servidor al establecer por primera vez una conexión. Puede leer más sobre la negociación en Extensiones de compresión para WebSocket RFC.

Nota

Aunque el servidor o el cliente no acepten la negociación de compresión, la conexión se puede establecer. Sin embargo, la conexión no usa compresión al enviar y recibir mensajes.

Enviar y recibir mensajes

El método AcceptWebSocketAsync actualiza la conexión TCP a una conexión WebSocket y proporciona un objeto WebSocket. Use el objeto WebSocket para enviar y recibir mensajes.

El código antes mostrado que acepta la solicitud WebSocket pasa el objeto WebSocket a un método Echo. El código recibe un mensaje y devuelve inmediatamente el mismo mensaje. Los mensajes se envían y reciben en un bucle hasta que el cliente cierra la conexión:

private async Task Echo(HttpContext context, WebSocket webSocket)
{
    var buffer = new byte[1024 * 4];
    WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
    while (!result.CloseStatus.HasValue)
    {
        await webSocket.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);

        result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
    }
    await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
}

Cuando la conexión WebSocket se acepta antes de que el bucle comience, la canalización de middleware finaliza. Tras cerrar el socket, se desenreda la canalización. Es decir, la solicitud deja de avanzar en la canalización cuando WebSocket se acepta, pero cuando el bucle termina y el socket se cierra, la solicitud vuelve a recorrer la canalización.

Control de las desconexiones del cliente

No se informa automáticamente al servidor cuando el cliente se desconecta debido a la pérdida de conectividad. El servidor recibe un mensaje de desconexión solo si el cliente lo envía, acción que no se puede realizar si se pierde la conexión a Internet. Si desea realizar alguna acción cuando eso suceda, establezca un tiempo de expiración después de que no se reciba del cliente dentro de un determinado período.

Si el cliente no siempre está enviando mensajes y no quiere que se agote el tiempo de expiración solo porque la conexión está inactiva, haga que el cliente utilice un temporizador para enviar un mensaje de ping cada equis segundos. En el servidor, si aún no ha llegado un mensaje dentro de 2*X segundos después del anterior, termine la conexión e informe que ha desconectado el cliente. Espere el doble del intervalo de tiempo esperado para dejar tiempo extra para los retrasos de la red que podrían retener el mensaje de ping.

Restricción de los orígenes de WebSocket

Las protecciones proporcionadas por CORS no se aplican a WebSockets. Los exploradores no hacen lo siguiente:

  • Efectúan solicitudes preparatorias CORS.
  • Respetan las restricciones especificadas en los encabezados Access-Control al efectuar solicitudes de WebSocket.

En cambio, sí que envían el encabezado Origin al emitir solicitudes de WebSocket. Las aplicaciones deben configurarse para validar estos encabezados a fin de garantizar que solo se permitan los WebSockets procedentes de los orígenes esperados.

Si hospeda su servidor en "https://server.com" y su cliente en "https://client.com", agregue "https://client.com" a la lista AllowedOrigins de WebSockets para efectuar la comprobación.

var webSocketOptions = new WebSocketOptions()
{
    KeepAliveInterval = TimeSpan.FromSeconds(120),
};
webSocketOptions.AllowedOrigins.Add("https://client.com");
webSocketOptions.AllowedOrigins.Add("https://www.client.com");

app.UseWebSockets(webSocketOptions);

Nota

El encabezado Origin está controlado por el cliente y, al igual que el encabezado Referer, se puede falsificar. No use estos encabezados como mecanismo de autenticación.

Compatibilidad con IIS/IIS Express

El protocolo WebSocket se puede usar en Windows Server 2012 o posterior, y en Windows 8 o posterior con IIS o IIS Express 8 o posterior.

Nota

WebSocket siempre está habilitado cuando se usa IIS Express.

Habilitación de WebSocket en IIS

Para habilitar la compatibilidad con el protocolo WebSocket en Windows Server 2012 o posterior:

Nota

Estos pasos no son necesarios cuando se usa IIS Express

  1. Use el asistente Agregar roles y características del menú Administrar o el vínculo de Administrador del servidor.
  2. Seleccione Instalación basada en características o en roles. Seleccione Siguiente.
  3. Seleccione el servidor que corresponda (el servidor local está seleccionado de forma predeterminada). Seleccione Siguiente.
  4. Expanda Servidor web (IIS) en el árbol Roles, expanda Servidor web y, por último, expanda Desarrollo de aplicaciones.
  5. Seleccione Protocolo WebSocket. Seleccione Siguiente.
  6. Si no necesita más características, haga clic en Siguiente.
  7. Haga clic en Instalar.
  8. Cuando la instalación finalice, haga clic en Cerrar para salir del asistente.

Para habilitar la compatibilidad con el protocolo WebSocket en Windows Server 8 o posterior:

Nota

Estos pasos no son necesarios cuando se usa IIS Express

  1. Vaya a Panel de control > Programas > Programas y características > Activar o desactivar las características de Windows (lado izquierdo de la pantalla).
  2. Abra los nodos siguientes: Internet Information Services > Servicios World Wide Web > Características de desarrollo de aplicaciones.
  3. Seleccione la característica Protocolo WebSocket. Seleccione Aceptar.

Deshabilitar WebSocket al usar socket.io en Node.js

Si usa la compatibilidad de WebSocket en socket.io en Node.js, deshabilite el módulo IIS WebSocket predeterminado, usando para ello el elemento webSocket de web.config o de applicationHost.config. Si este paso no se lleva a cabo, el módulo IIS WebSocket intenta controlar la comunicación de WebSocket en lugar de Node.js y la aplicación.

<system.webServer>
  <webSocket enabled="false" />
</system.webServer>

Aplicación de ejemplo

La aplicación de ejemplo que acompaña a este artículo es una aplicación de eco. Tiene una página web que realiza las conexiones de WebSocket y el servidor reenvía de vuelta al cliente todos los mensajes que recibe. La aplicación de ejemplo no está configurada para ejecutarse desde Visual Studio con IIS Express, por lo que debe ejecutarla en un shell de comandos con dotnet run e ir a http://localhost:5000 en un explorador. La página web muestra el estado de la conexión:

Estado inicial de la página web antes de la conexión de WebSockets

Seleccione Connect (Conectar) para enviar una solicitud WebSocket para la URL mostrada. Escriba un mensaje de prueba y seleccione Send (Enviar). Cuando haya terminado, seleccione Close Socket (Cerrar socket). Los informes de la sección Communication Log (Registro de comunicación) informan de cada acción de abrir, enviar y cerrar a medida que se producen.

Estado final de la página web después de enviar y recibir mensajes de prueba y conexiones de WebSockets