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.