WebSocket per la comunicazione in tempo reale in C#
Il protocollo HTTP che abbiamo usato finora segue un modello richiesta-risposta: il client chiede, il server risponde, e la comunicazione si chiude. Questo mal si adatta agli scenari in tempo reale, come chat, notifiche o aggiornamenti live, dove il server deve poter inviare dati al client spontaneamente. I WebSocket risolvono il problema stabilendo un canale bidirezionale e persistente sopra una singola connessione. In questo articolo vediamo come costruire un server WebSocket con ASP.NET Core e un client con ClientWebSocket.
Il modello dei WebSocket
Una connessione WebSocket nasce come una normale richiesta HTTP che il client "promuove" a WebSocket tramite un meccanismo chiamato handshake. Una volta completato, la connessione resta aperta e diventa full-duplex: entrambe le parti possono inviare messaggi in qualsiasi momento, senza attendere una richiesta. A differenza del semplice flusso TCP visto in precedenza, i WebSocket lavorano con messaggi delimitati, risolvendo alla radice il problema del framing.
Abilitare i WebSocket nel server
In ASP.NET Core, il supporto ai WebSocket si attiva con un middleware, aggiunto alla pipeline studiata nella quarta serie. Il middleware UseWebSockets intercetta le richieste di handshake e le prepara per l'elaborazione.
var app = builder.Build();
// Abilita il supporto ai WebSocket nella pipeline
app.UseWebSockets();
Accettare una connessione
In un endpoint si verifica se la richiesta in arrivo è una richiesta WebSocket e, in caso affermativo, la si accetta ottenendo un oggetto WebSocket che rappresenta la connessione stabilita. Le richieste HTTP ordinarie vengono invece gestite normalmente.
using System.Net.WebSockets;
using System.Text;
app.Map("/ws", async context =>
{
// Accetta solo se si tratta di una richiesta WebSocket
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
// Completa l'handshake e ottiene la connessione
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
await HandleSocketAsync(socket);
});
Il ciclo di ricezione e invio
Stabilita la connessione, il server entra in un ciclo che riceve i messaggi e vi risponde. Il metodo ReceiveAsync legge un messaggio in un buffer, indicando anche il tipo di messaggio e se la connessione è in fase di chiusura. Il metodo SendAsync invia dati al client, specificando che si tratta di un messaggio di testo completo.
static async Task HandleSocketAsync(WebSocket socket)
{
var buffer = new byte[4096];
while (socket.State == WebSocketState.Open)
{
// Riceve un messaggio dal client
WebSocketReceiveResult result =
await socket.ReceiveAsync(buffer, CancellationToken.None);
// Il client ha richiesto la chiusura della connessione
if (result.MessageType == WebSocketMessageType.Close)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure,
"Chiusura", CancellationToken.None);
break;
}
string received = Encoding.UTF8.GetString(buffer, 0, result.Count);
byte[] response = Encoding.UTF8.GetBytes($"Eco: {received}");
// Invia la risposta come messaggio di testo completo
await socket.SendAsync(response, WebSocketMessageType.Text,
endOfMessage: true, CancellationToken.None);
}
}
Un client WebSocket
Dal lato client, la classe ClientWebSocket stabilisce la connessione e comunica con lo stesso schema di invio e ricezione. Si connette all'indirizzo del server, usando lo schema ws per le connessioni non cifrate o wss per quelle sicure, di cui parleremo nell'articolo sulla sicurezza.
using System.Net.WebSockets;
using System.Text;
using var client = new ClientWebSocket();
// Connessione al server WebSocket
await client.ConnectAsync(new Uri("ws://localhost:5000/ws"), CancellationToken.None);
// Invio di un messaggio
byte[] message = Encoding.UTF8.GetBytes("Ciao dal client");
await client.SendAsync(message, WebSocketMessageType.Text,
endOfMessage: true, CancellationToken.None);
// Ricezione della risposta
var buffer = new byte[4096];
WebSocketReceiveResult result = await client.ReceiveAsync(buffer, CancellationToken.None);
string response = Encoding.UTF8.GetString(buffer, 0, result.Count);
Console.WriteLine(response);
Trasmettere a più client
Uno scenario tipico è inviare un messaggio a tutti i client connessi, come in una chat o in un sistema di notifiche. Il server deve mantenere un elenco delle connessioni attive e iterarvi per la trasmissione. Trattandosi di uno stato condiviso tra più connessioni concorrenti, va protetto con le strutture sicure viste nell'articolo sul parallelismo.
using System.Collections.Concurrent;
// Insieme delle connessioni attive, sicuro per la concorrenza
static readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
static async Task BroadcastAsync(string message)
{
byte[] data = Encoding.UTF8.GetBytes(message);
foreach (WebSocket socket in _clients.Values)
{
if (socket.State == WebSocketState.Open)
{
await socket.SendAsync(data, WebSocketMessageType.Text,
endOfMessage: true, CancellationToken.None);
}
}
}
Un cenno a SignalR
Gestire manualmente le connessioni, la trasmissione e la riconnessione diventa presto complesso. Per questo motivo l'ecosistema .NET offre SignalR, una libreria di alto livello costruita sopra i WebSocket che semplifica enormemente le applicazioni in tempo reale. SignalR gestisce automaticamente le connessioni, i gruppi di destinatari e la riconnessione, e ripiega su tecniche alternative quando i WebSocket non sono disponibili. Conoscere i WebSocket alla base, come abbiamo fatto qui, aiuta però a comprendere cosa SignalR fa dietro le quinte.
Conclusione
Abbiamo esplorato i WebSocket come soluzione per la comunicazione bidirezionale in tempo reale, superando i limiti del modello richiesta-risposta di HTTP. Abbiamo costruito un server con ASP.NET Core, accettando connessioni e gestendo il ciclo di ricezione e invio con messaggi delimitati, e un client con ClientWebSocket. Abbiamo visto come trasmettere a più client in sicurezza e accennato a SignalR come astrazione di alto livello. Nel prossimo articolo affronteremo gRPC, il framework per la comunicazione efficiente tra servizi.