Skip to content

Latest commit

 

History

History
364 lines (276 loc) · 11.2 KB

File metadata and controls

364 lines (276 loc) · 11.2 KB

AlexandreHtrb.WebSocketExtensions

Read in english

Este pacote é uma camada de abstração construída em cima das implementações-padrão do System.Net.WebSockets, para lidar com o ciclo de vida dos WebSockets e para parsear e converter suas mensagens de byte arrays. Estas abstrações são tanto para cliente como para servidor (ASP.NET).

Ele é compatível com compilação NativeAOT e trimming.

Instalação

Adicionar pacote NuGet ao arquivo do projeto:

<ItemGroup>
    <PackageReference Include="AlexandreHtrb.WebSocketExtensions" Version="1.2.0" />
</ItemGroup>

Como usar

A utilização é bem simples. As classes WebSocketServerSideConnector e WebSocketClientSideConnector recebem objetos WebSocket nativos do .NET e cuidam de todo o ciclo de vida dos WebSockets, conexão e desconexão, recebimento e envio de mensagens, e conversão de/para bytes.

Dentro de cada conector há um ExchangedMessagesCollector, que coleta as mensagens enviadas e recebidas, e as disponibiliza em um IAsyncEnumerable. Assim, a conversa entre cliente e servidor entra em um loop await foreach (assíncrono). Quando uma das partes se desconecta, o loop é encerrado.

Antes de entrar no loop da conversa, uma das partes deve ter a iniciativa de mandar uma mensagem.

Código de exemplo, servidor

WebSocketServerSideConnector wsc = new(ws, collectOnlyClientSideMessages: true);

await foreach (var msg in wsc.ExchangedMessagesCollector!.ReadAllAsync())
{
    await wsc.SendMessageAsync(WebSocketMessageType.Text, msg.ReadAsUtf8Text() switch
    {
        "Olá!" => "Oi!",
        "Que horas são?" => "Agora são " + DateTime.Now.TimeOfDay,
        "Obrigado!" => "De nada!",
        _ => "Não entendi sua mensagem!"
    }, false);
}

Código de exemplo, cliente

using var cws = MakeClientWebSocket();
using var hc = MakeHttpClient(disableSslVerification: true);
Uri uri = new("ws://localhost:5000/test/http1websocket");
WebSocketClientSideConnector wsc = new(collectOnlyServerSideMessages: true);

// Conectando
await wsc.ConnectAsync(cws, hc, uri, cancellationToken);

// Envio da primeira mensagem
await wsc.SendMessageAsync(WebSocketMessageType.Text, "Olá!", false);

// Loop da conversa
await foreach (var msg in wsc.ExchangedMessagesCollector!.ReadAllAsync())
{
    string? replyTxt = msg.ReadAsUtf8Text() switch
    {
        "Oi!" => "Que horas são?",
        string s when s.StartsWith("Agora são") => "Obrigado!",
        _ => null
    };
    
    if (replyTxt != null)
        await wsc.SendMessageAsync(WebSocketMessageType.Text, replyTxt, false);
}

Com o código acima, a conversa se desenrolará assim:

Cliente: Olá!
Servidor: Oi!
Cliente: Que horas são?
Servidor: Agora são 11:54:53
Cliente: Obrigado!
Servidor: De nada!

Configuração de WebSockets no ASP.NET

  1. Habilitar app.UseWebSockets():
private static IApplicationBuilder ConfigureApp(this WebApplication app) =>
    app.MapTestEndpoints()
       .UseWebSockets(new()
       {
           KeepAliveInterval = TimeSpan.FromMinutes(2)
       });
  1. Mapear o endpoint para comunicação WebSocket:
public static WebApplication MapTestEndpoints(this WebApplication app)
{
    app.MapGet("test/http1websocket", TestHttp1WebSocket);
    return app;
}

private static async Task TestHttp1WebSocket(HttpContext httpCtx, ILogger<BackgroundWebSocketsProcessor> logger)
{
    if (!httpCtx.WebSockets.IsWebSocketRequest)
    {
        byte[] txtBytes = Encoding.UTF8.GetBytes("Only WebSockets requests are accepted here!");
        httpCtx.Response.StatusCode = (int)HttpStatusCode.BadRequest;
        await httpCtx.Response.BodyWriter.WriteAsync(txtBytes);
    }
    else
    {
        using var webSocket = await httpCtx.WebSockets.AcceptWebSocketAsync();
        TaskCompletionSource<object> socketFinishedTcs = new();

        await BackgroundWebSocketsProcessor.RegisterAndProcessAsync(logger, webSocket, socketFinishedTcs);
        await socketFinishedTcs.Task;
    }
}
  1. Criar um processador para o WebSocket:
using AlexandreHtrb.WebSocketExtensions;
using System.Net.WebSockets;

public static class BackgroundWebSocketsProcessor
{
    public static async Task RegisterAndProcessAsync(ILogger<BackgroundWebSocketsProcessor> logger, WebSocket ws, TaskCompletionSource<object> socketFinishedTcs)
    {
        WebSocketServerSideConnector wsc = new(ws, collectOnlyClientSideMessages: true);

        int msgCount = 0;
        await foreach (var msg in wsc.ExchangedMessagesCollector!.ReadAllAsync())
        {
            logger.LogInformation("Message {msgCount}, {direction}: {msgText}", ++msgCount, msg.Direction, msg.FormatForLogging());

            // tratamento das mensagens aqui
        }
        
        socketFinishedTcs.SetResult(true); // fim da conversa
    }
}

Dicas

Monitorar estado da conexão

wsc.OnConnectionChanged = (state, exception) =>
{
    logger.LogInformation("Connection state: {state}", state);
    logger.LogError(exception, "Connection exception");
};

Aqui é possível colocar tentativas de reconexão.

Keep-Alive

Keep-Alive é o mecanismo para manter a conexão WebSocket viva, sem que proxies ou outros elementos no meio do caminho a cortem.

Em .NET, podemos configurar um intervalo de freqüência, em que a parte envia um frame ping, e um período de timeout, que após o envio do ping, a parte espera o recebimento de um frame pong. Se não receber o pong, então entende-se que a conexão morreu.

Esse tópico é abordado com mais detalhes nesta página do WebSocket.org.

Do lado do cliente

ClientWebSocket cws = new();
cws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
#if NET10_0_OR_GREATER
cws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(45);
#endif

await wsc.ConnectAsync(cws, hc, uri, default);

Do lado do servidor

private static IApplicationBuilder ConfigureApp(this WebApplication app) =>
    app.MapTestEndpoints()
       .UseWebSockets(new()
       {
           KeepAliveInterval = TimeSpan.FromSeconds(30),
#if NET10_0_OR_GREATER
           KeepAliveTimeout = TimeSpan.FromSeconds(45)
#endif
       });

Coletar mensagens enviadas

WebSocketServerSideConnector wsc = new(ws, collectOnlyClientSideMessages: false);

WebSocketClientSideConnector wsc = new(collectOnlyServerSideMessages: false);

Os booleanos controlam se apenas as mensagens vindas do lado oposto serão coletadas. Coletar mensagens do próprio lado pode ser interessante para logs.

Enviar mensagem periodicamente

while (!cancellationToken.IsCancellationRequested)
{
    await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken);
    await wsc.SendMessageAsync(WebSocketMessageType.Text, "Alô", false);
}

Encerrar conversa após determinado tempo

_ = Task.Run(async () =>
{
    await Task.Delay(maximumLifetimePeriod, cancellationToken);
    await wsc.DisconnectAsync();
});

Envio de arquivos

// não use 'using'
FileStream fs = new("C:\\Files\my_image.jpg", FileMode.Open);
await wsc.SendMessageAsync(WebSocketMessageType.Binary, fs, false);

Ao usar Streams para enviar mensagens, não usar a palavra-chave using. O Stream será descartado (.Dispose()) dentro do conector.

Além disso, ao enviar arquivos, pode ser interessante ter um tamanho maior de buffer no WebSocketConnector. Quando buffers maiores são usados, menos idas-e-voltas são necessárias para ler um Stream, o que pode acarretar em transmissões mais rápidas.

WebSocketServerSideConnector wsc = new(ws, true, bufferSize: 65_536);

WebSocketClientSideConnector wsc = new(bufferSize: 65_536);

Pegar HTTP status code e headers de resposta

ClientWebSocket cws = new();
cws.Options.CollectHttpResponseDetails = true;

await wsc.ConnectAsync(cws, hc, uri, cancellationToken);

var wsHttpStatusCode = wsc.ConnectionHttpStatusCode;
var wsResponseHeaders = wsc.ConnectionHttpHeaders;

Autenticação e headers de requisição

ClientWebSocket cws = new();
cws.Options.SetRequestHeader("Authorization", "Bearer my_token");
cws.Options.SetRequestHeader("Header1", "Value1");

await wsc.ConnectAsync(cws, hc, uri, cancellationToken);

Subprotocolos

Do lado do cliente

ClientWebSocket cws = new();
cws.Options.AddSubProtocol("subprotocol1");

await wsc.ConnectAsync(cws, hc, uri, cancellationToken);

Do lado do servidor

private static async Task TestHttp1WebSocket(HttpContext httpCtx, ILogger<BackgroundWebSocketsProcessor> logger)
{
    if (!httpCtx.WebSockets.IsWebSocketRequest)
    {
        // ...
    }
    else
    {
        using var webSocket = await httpCtx.WebSockets.AcceptWebSocketAsync();
        TaskCompletionSource<object> socketFinishedTcs = new();
+       string? subprotocol = webSocket.SubProtocol ?? httpCtx.WebSockets.WebSocketRequestedProtocols.FirstOrDefault();

        await BackgroundWebSocketsProcessor.RegisterAndProcessAsync(logger, webSocket, subprotocol, socketFinishedTcs);
        await socketFinishedTcs.Task;
    }
}

Compressão de mensagens

ClientWebSocket cws = new();
cws.Options.DangerousDeflateOptions = new()
{
    ClientContextTakeover = true,
    ClientMaxWindowBits = 14,
    ServerContextTakeover = true,
    ServerMaxWindowBits = 14
};

await wsc.ConnectAsync(cws, hc, uri, cancellationToken);

Importante: Não se deve passar segredos e mensagens criptografadas ao mesmo tempo em que se usa compressão, pois há risco de ataques BREACH e CRIME. Nesses casos, deve-se desabilitar a compressão nessas mensagens:

await wsc.SendMessageAsync(
    WebSocketMessageType.Text,
    $"Token criptografado {token}",
    disableCompression: true);

WebSockets em HTTP/2

Do lado do cliente

ClientWebSocket cws = new();
cws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionExact;
cws.Options.HttpVersion = new(2,0);

await wsc.ConnectAsync(cws, hc, uri, cancellationToken);

Do lado do servidor

Em WebSockets HTTP/2, o método HTTP CONNECT é usado, ao invés de GET.

public static WebApplication MapTestEndpoints(this WebApplication app)
{
    app.MapMethods("test/http2websocket", new[] { HttpMethods.Connect }, TestHttp2WebSocket);
    return app;
}