ASP.NET WebSockets

WebSockets no ASP.NET permitem criar aplicações em tempo real usando apenas os recursos nativos do .NET, sem nenhuma biblioteca externa. Neste artigo, vamos construir o menor par cliente e servidor possível, entender cada linha do código e conhecer os cuidados necessários para levar essa solução adiante.

O exemplo usa o modelo de hospedagem mínima do .NET 6, com WebApplication e top-level statements. Ainda assim, os conceitos valem para qualquer versão moderna do framework.

O que são WebSockets

WebSockets, ou carinhosamente WS, são uma tecnologia que permite manter clientes conectados aos servidores e trafegar mensagens com muito mais agilidade. O protocolo foi padronizado pela RFC 6455 e funciona sobre uma única conexão TCP.

Para entender a vantagem, vale lembrar como funciona uma requisição HTTP convencional. Ela passa por alguns passos, entre eles o handshake, ou aperto de mão, e a abertura da conexão.

Somente depois desses passos os dados trafegam e, em seguida, a conexão é encerrada. Nesse modelo, precisamos repetir esse ciclo a cada requisição ao servidor.

Nos WebSockets, por outro lado, o handshake acontece uma única vez. Uma vez conectados, cliente e servidor trafegam apenas os dados, nos dois sentidos e ao mesmo tempo.

Como funciona o handshake

A conexão começa como uma requisição HTTP comum, enviada pelo cliente com o cabeçalho Upgrade: websocket. Esse cabeçalho pede ao servidor para trocar de protocolo.

Se o servidor aceitar, ele responde com o status 101 Switching Protocols. A partir daí, a mesma conexão deixa de falar HTTP e passa a transportar frames de WebSocket.

As URLs usam os esquemas ws:// para conexões sem criptografia e wss:// para conexões seguras sobre TLS. Em produção, portanto, prefira sempre o wss://.

SignalR ou WebSockets nativos

O ASP.NET possui um pacote chamado SignalR, que encapsula os WebSockets e traz várias funcionalidades extras. Inclusive, o Blazor Server se beneficia diretamente desses recursos.

A documentação oficial de WebSockets no ASP.NET Core recomenda o SignalR para a maioria das aplicações. Isso acontece porque ele oferece fallback para outros transportes, reconexão e um modelo de chamadas de métodos.

Embora o SignalR seja um excelente pacote e minha recomendação principal no trabalho com WS, aqui vamos utilizar apenas recursos nativos do .NET. Dessa forma, fica mais fácil entender o que acontece por baixo dos panos.

Criando o servidor de WebSockets no ASP.NET

Para começar, vamos criar a aplicação web mais simples possível. Aliás, o objetivo aqui é montar o menor app cliente e servidor com WebSockets que você já escreveu.

Neste exemplo, teremos duas aplicações: uma atua como servidor, onde ficam os WebSockets, e outra como cliente, que vai consumi-los. Primeiro, vamos criar o servidor.

dotnet new web -o Server

Esse template gera um projeto vazio, com apenas o Program.cs e uma rota de exemplo. Portanto, temos um ponto de partida enxuto para adicionar o suporte ao protocolo.

Habilitando WebSockets no ASP.NET

Incrivelmente, tudo de que precisamos para habilitar WebSockets no ASP.NET é a linha app.UseWebSockets(). Além disso, essa chamada não exige nenhum pacote adicional.

var app = builder.Build();
app.UseWebSockets();

O UseWebSockets registra um middleware que identifica as requisições de upgrade e prepara a troca de protocolo. Se quiser se aprofundar nesse conceito, veja o artigo sobre middleware no ASP.NET e como criar o seu.

Agora vamos deixar o app assíncrono e mapear uma rota que vai receber as conexões.

app.Map("/", async context =>
{
    // TODO
});

await app.RunAsync();

Note que temos apenas uma rota, "/", que representa a raiz da aplicação. Ela também vai servir como endpoint de WebSocket para o nosso cliente.

Verificando a conexão

Depois de chamar o app.UseWebSockets, temos acesso à propriedade context.WebSockets. Ela permite tratar a requisição atual como um WebSocket.

Como o endpoint pode receber vários tipos de requisição, vamos tratar apenas as que fazem sentido para o nosso cenário. Para isso, verificamos se IsWebSocketRequest é verdadeiro.

if (!context.WebSockets.IsWebSocketRequest)
    context.Response.StatusCode = (int)HttpStatusCode.BadRequest;

Assim, uma requisição HTTP comum, como a de um navegador acessando a URL, recebe o status 400. Somente os pedidos de upgrade seguem adiante.

Aceitando a conexão e enviando mensagens via WebSockets

O primeiro passo para manipular as requisições é obter uma instância de WebSocket. Isso é feito com o método AcceptWebSocketAsync, conforme mostrado abaixo.

using var webSocket = await context.WebSockets.AcceptWebSocketAsync();

Com a conexão em mãos, podemos enviar mensagens ao cliente usando o SendAsync. Esse método espera os seguintes parâmetros:

  • Mensagem: um array de bytes com o conteúdo.
  • Tipo da mensagem: texto, neste caso.
  • Fim da mensagem: verdadeiro, pois não enviaremos mensagens segmentadas.
  • Cancellation token: o token para cancelar a operação.
var data = Encoding.ASCII.GetBytes($".NET Rocks -> {DateTime.Now}");

await webSocket.SendAsync(
    data, // Mensagem
    WebSocketMessageType.Text, // Tipo
    true, // Fim da mensagem
    CancellationToken.None);

Para exemplificar melhor, vamos colocar esse envio dentro de um loop infinito, com intervalo de um segundo entre as mensagens. Basicamente, o servidor ficará enviando a mesma mensagem ao cliente a cada segundo.

while (true)
{
    await webSocket.SendAsync(
        Encoding.ASCII.GetBytes($".NET Rocks -> {DateTime.Now}"),
        WebSocketMessageType.Text,
        true,
        CancellationToken.None);

    await Task.Delay(1000);
}

Código completo do servidor

Acredite se quiser, mas isso é tudo de que precisamos do lado do servidor. Temos uma aplicação que usa WebSockets, e os clientes já podem se conectar a ela para receber mensagens.

Segue abaixo o código completo. Em seguida, execute a aplicação com dotnet run e anote a URL em que ela está rodando.

using System.Net;
using System.Net.WebSockets;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();
app.UseWebSockets();

app.Map("/", async context =>
{
    if (!context.WebSockets.IsWebSocketRequest)
        context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
    else
    {
        using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
        while (true)
        {
            await webSocket.SendAsync(
                Encoding.ASCII.GetBytes($".NET Rocks -> {DateTime.Now}"),
                WebSocketMessageType.Text,
                true,
                CancellationToken.None);

            await Task.Delay(1000);
        }
    }
});

await app.RunAsync();

Repare que o delegate só termina quando o loop termina. Isso é importante, porque a conexão precisa que o pipeline continue ativo durante toda a sua duração.

Criando o cliente de WebSockets

Para consumir as mensagens, vamos criar um cliente do tipo console application, que ficará lendo as mensagens indefinidamente.

dotnet new console -o Client

O cliente também é independente de qualquer framework web. Portanto, a mesma técnica funciona em um worker, em um app desktop ou em outro serviço.

Conectando ao servidor

Assim como no servidor, aqui também precisamos de suporte a WebSockets, e isso é feito sem nenhum pacote adicional. Basta usar a classe ClientWebSocket, do namespace System.Net.WebSockets.

using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("ws://localhost:5065/"), CancellationToken.None);

É importante notar que o seu servidor provavelmente vai rodar em uma porta diferente da minha, a 5065. Então, lembre-se de alterar a URL informada acima.

Consumindo as mensagens

Assim como a produção das mensagens usou um array de bytes, o consumo também vai usar. Por isso, começamos definindo um buffer.

var buffer = new byte[256];

Em seguida, ficamos ouvindo o servidor enquanto a conexão estiver aberta. Para isso, verificamos se ws.State está como Open e, enquanto isso for verdade, recebemos mensagens.

while (ws.State == WebSocketState.Open)
{
    // TODO
}

Recebendo as mensagens do WebSocket

Para receber as mensagens, usamos o método ReceiveAsync. Ele precisa do buffer definido anteriormente e de um CancellationToken, que não utilizamos neste caso.

var result = await ws.ReceiveAsync(buffer, CancellationToken.None);

Caso o tipo da mensagem seja diferente de WebSocketMessageType.Close, convertemos os bytes para string e mostramos na tela. Afinal, Close indica que o servidor pediu o encerramento da conexão.

Quando a mensagem for de encerramento, fechamos o WebSocket com CloseAsync e terminamos o processamento.

if (result.MessageType == WebSocketMessageType.Close)
    await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
else
    Console.WriteLine(Encoding.ASCII.GetString(buffer, 0, result.Count));

Usamos result.Count porque o buffer pode não estar totalmente preenchido. Dessa forma, convertemos apenas os bytes realmente recebidos.

Código completo do cliente

E isso é tudo de que precisamos do lado do cliente. Pronto, temos uma aplicação que produz mensagens e outra que consome, ambas usando apenas o que o .NET já oferece.

using System.Net.WebSockets;
using System.Text;

using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("ws://localhost:5065/"), CancellationToken.None);

var buffer = new byte[256];
while (ws.State == WebSocketState.Open)
{
    var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
    if (result.MessageType == WebSocketMessageType.Close)
        await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
    else
        Console.WriteLine(Encoding.ASCII.GetString(buffer, 0, result.Count));
}

Execute o servidor primeiro e, em outro terminal, rode o cliente com dotnet run. Em seguida, uma nova linha com a data e a hora aparece a cada segundo.

Indo além com WebSockets nativos

Até aqui, apenas o servidor enviou mensagens. Contudo, a grande vantagem dos WebSockets é a comunicação nos dois sentidos, então vale explorar esse recurso.

Enviando mensagens do cliente

O ClientWebSocket também possui o método SendAsync, com a mesma assinatura usada no servidor. Assim, o cliente pode enviar comandos ou dados a qualquer momento.

var message = Encoding.UTF8.GetBytes("Hello from the client");

await ws.SendAsync(
    message,
    WebSocketMessageType.Text,
    true,
    CancellationToken.None);

Do lado do servidor, basta chamar ReceiveAsync sobre o objeto obtido no AcceptWebSocketAsync. Esse é o princípio do clássico servidor de eco, que devolve ao cliente tudo o que recebe.

var buffer = new byte[1024 * 4];
var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);

// Repete o envio enquanto o cliente não pedir o encerramento
while (!result.CloseStatus.HasValue)
{
    await webSocket.SendAsync(
        new ArraySegment<byte>(buffer, 0, result.Count),
        result.MessageType,
        result.EndOfMessage,
        CancellationToken.None);

    result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
}

await webSocket.CloseAsync(
    result.CloseStatus.Value,
    result.CloseStatusDescription,
    CancellationToken.None);

Esse modelo é a base de chats, jogos multiplayer e painéis colaborativos. Afinal, qualquer ponta pode falar sem esperar uma requisição da outra.

Mensagens maiores que o buffer

No nosso cliente, o buffer tem apenas 256 bytes. Para mensagens curtas, isso basta, mas uma mensagem maior chega dividida em várias leituras.

O ReceiveAsync indica isso pela propriedade EndOfMessage. Enquanto ela for falsa, ainda existem partes da mesma mensagem para ler.

using var stream = new MemoryStream();
WebSocketReceiveResult result;

// Junta as partes até receber o fim da mensagem
do
{
    result = await ws.ReceiveAsync(buffer, CancellationToken.None);
    stream.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);

var text = Encoding.UTF8.GetString(stream.ToArray());
Console.WriteLine(text);

Dessa forma, o tamanho do buffer deixa de limitar o tamanho das mensagens.

O mesmo cuidado vale para o servidor, que também pode receber mensagens fragmentadas. Por isso, aplique essa lógica de leitura nas duas pontas sempre que o conteúdo puder passar de alguns poucos bytes.

Ainda assim, defina um teto máximo no seu código, para evitar que um cliente envie dados demais e esgote a memória do servidor.

Gerenciando várias conexões

No exemplo, cada cliente recebe as próprias mensagens de forma isolada. Em um chat, porém, uma mensagem precisa chegar a todos os conectados.

Para isso, você precisa guardar as conexões abertas em uma coleção segura para múltiplas threads, como um ConcurrentDictionary. Em seguida, percorre essa coleção para enviar a mensagem a cada cliente.

Além disso, é preciso remover da coleção as conexões encerradas, caso contrário o servidor tentará enviar mensagens para clientes que já saíram. Também convém tratar as exceções de envio individualmente, para que a falha de um cliente não interrompa a entrega aos demais.

Esse controle manual é exatamente o que o SignalR já resolve com hubs e grupos. Portanto, quando o cenário crescer, compare o esforço de manter essa infraestrutura com o custo de adotar a biblioteca.

Cuidados com WebSockets no ASP.NET em produção

O exemplo acima é propositalmente mínimo. Antes de levar WebSockets no ASP.NET para produção, porém, alguns pontos merecem atenção.

Encerrando o loop do servidor

O loop while (true) nunca verifica se o cliente ainda está conectado. Assim, quando o cliente fecha o console, a próxima chamada de SendAsync lança uma exceção.

Uma alternativa simples é condicionar o loop ao estado da conexão e ao cancelamento da requisição. Veja a versão ajustada abaixo.

using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var cancellationToken = context.RequestAborted;

// Envia enquanto a conexão estiver aberta e a requisição não for cancelada
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
    await webSocket.SendAsync(
        Encoding.UTF8.GetBytes($".NET Rocks -> {DateTime.Now}"),
        WebSocketMessageType.Text,
        true,
        cancellationToken);

    await Task.Delay(1000, cancellationToken);
}

Repare também na troca de Encoding.ASCII por Encoding.UTF8. Com isso, mensagens com acentos chegam corretamente ao cliente, desde que ele também decodifique em UTF8.

Segundo a documentação, o servidor não percebe automaticamente quando o cliente perde a conectividade. Por isso, aplicações reais costumam combinar pings periódicos com um tempo limite para detectar conexões mortas.

Configurando as opções do middleware

O UseWebSockets aceita um objeto WebSocketOptions com ajustes importantes. Entre eles, o KeepAliveInterval define a frequência dos frames de ping que mantêm proxies com a conexão aberta, e o valor padrão é de dois minutos.

var webSocketOptions = new WebSocketOptions
{
    KeepAliveInterval = TimeSpan.FromMinutes(2)
};

// Aceita conexões apenas das origens conhecidas
webSocketOptions.AllowedOrigins.Add("https://client.com");

app.UseWebSockets(webSocketOptions);

Já o AllowedOrigins merece atenção especial. Segundo a documentação, as proteções do CORS não se aplicam a WebSockets, então o servidor precisa validar o cabeçalho Origin por conta própria.

Sem essa validação, uma página maliciosa pode abrir conexões em nome do usuário. Para conhecer outras medidas de proteção, confira o artigo sobre segurança em APIs ASP.NET Core.

Hospedagem e limites

No Kestrel, o suporte funciona sem configuração adicional. Já no IIS, é preciso habilitar o recurso de WebSocket Protocol no servidor Windows, enquanto o IIS Express já vem com ele ativo.

Além disso, cada conexão aberta consome recursos do servidor por tempo indeterminado. Portanto, planeje a capacidade considerando o número de clientes simultâneos, e não apenas o volume de requisições.

Vale mencionar também que, a partir do .NET 7, o Kestrel passou a aceitar WebSockets sobre HTTP/2. Como o exemplo deste artigo usa o .NET 6, a conexão acontece pelo HTTP/1.1 tradicional.

Conclusão

Utilizar WebSockets no ASP.NET é uma tarefa simples, que não exige sequer bibliotecas externas. Com o UseWebSockets no servidor e o ClientWebSocket no cliente, você já tem comunicação em tempo real funcionando.

Ainda assim, cenários com muitos clientes, grupos, reconexão e fallback ficam bem mais fáceis com o SignalR. Por isso, use os WebSockets nativos quando precisar de controle total e o SignalR quando quiser produtividade.

Em qualquer caso, entender o protocolo por baixo dos panos ajuda a diagnosticar problemas e a tomar decisões melhores de arquitetura.

Agora é com você: rode os dois projetos, altere as mensagens e experimente enviar dados do cliente para o servidor.

FAQ

Preciso instalar algum pacote para usar WebSockets no ASP.NET?

Não, o suporte faz parte do próprio framework. Basta chamar app.UseWebSockets() no servidor e usar ClientWebSocket no cliente.

Qual a diferença entre WebSockets e SignalR?

WebSockets são o protocolo de comunicação em si. O SignalR é uma biblioteca que usa WebSockets quando possível e adiciona fallback, reconexão e chamadas de métodos.

Por que o navegador recebe status 400 ao acessar a URL do servidor?

Porque o endpoint só aceita requisições de upgrade para WebSocket. Uma requisição HTTP comum cai na verificação de IsWebSocketRequest e recebe Bad Request.

O CORS protege minhas conexões WebSocket?

Não, os navegadores não aplicam as regras de CORS a WebSockets. Use o AllowedOrigins para validar o cabeçalho Origin das conexões.

Qual esquema de URL devo usar em produção?

Use wss://, que criptografa a conexão com TLS. O ws:// serve apenas para testes locais.