WebSocket per la comunicazione in tempo reale in C#

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.