Programmazione con socket TCP in C#
Finora abbiamo lavorato con astrazioni di alto livello come HttpClient, che nascondono i dettagli della rete. Scendiamo ora al livello del trasporto, lavorando direttamente con i socket TCP. Questo consente di costruire protocolli personalizzati e di comprendere cosa avviene sotto le astrazioni. TCP, come visto nell'articolo dei fondamenti, offre una comunicazione affidabile e orientata alla connessione. In questo articolo costruiamo un server e un client TCP usando le classi TcpListener e TcpClient.
Il modello client-server
La comunicazione TCP segue il modello client-server. Il server si mette in ascolto su una porta e attende connessioni in arrivo; il client si connette all'indirizzo e alla porta del server. Una volta stabilita la connessione, entrambi possono inviare e ricevere dati attraverso un flusso bidirezionale. La classe TcpListener incarna il ruolo del server, mentre TcpClient quello del client, e la comunicazione avviene tramite un NetworkStream.
Un server TCP essenziale
Il server crea un TcpListener legato a un indirizzo e a una porta, avvia l'ascolto e attende un client. Il metodo AcceptTcpClientAsync si sospende, senza bloccare il thread, finché un client non si connette, restituendo il TcpClient che rappresenta quella connessione.
using System.Net;
using System.Net.Sockets;
using System.Text;
// Il server ascolta sulla porta 5000, su tutte le interfacce
var listener = new TcpListener(IPAddress.Any, 5000);
listener.Start();
Console.WriteLine("Server in ascolto sulla porta 5000");
// Attende una connessione in arrivo
using TcpClient client = await listener.AcceptTcpClientAsync();
Console.WriteLine("Client connesso");
// Il NetworkStream e il canale di comunicazione bidirezionale
using NetworkStream stream = client.GetStream();
Leggere e scrivere sul flusso
Il NetworkStream è uno stream come quelli visti nell'articolo sull'input/output, ma i dati fluiscono sulla rete anziché sul disco. I dati viaggiano come sequenze di byte, quindi il testo va codificato in byte prima dell'invio e decodificato in ricezione, tipicamente in UTF-8.
// Lettura dei dati inviati dal client in un buffer
var buffer = new byte[1024];
int bytesRead = await stream.ReadAsync(buffer);
// Decodifica dei byte ricevuti in testo
string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"Ricevuto: {received}");
// Codifica e invio di una risposta
byte[] responseBytes = Encoding.UTF8.GetBytes($"Eco: {received}");
await stream.WriteAsync(responseBytes);
Un client TCP
Il client, dal canto suo, crea un TcpClient e si connette all'endpoint del server. Ottenuto il NetworkStream, invia e riceve dati con le stesse operazioni del server, ma in ordine speculare: prima scrive la richiesta, poi legge la risposta.
using var client = new TcpClient();
// Connessione al server
await client.ConnectAsync("127.0.0.1", 5000);
using NetworkStream stream = client.GetStream();
// Invio di un messaggio
byte[] message = Encoding.UTF8.GetBytes("Ciao, server");
await stream.WriteAsync(message);
// Lettura della risposta
var buffer = new byte[1024];
int bytesRead = await stream.ReadAsync(buffer);
string response = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"Risposta: {response}");
Gestire più client contemporaneamente
Un server reale deve servire molti client insieme. La soluzione idiomatica combina il ciclo di accettazione con l'asincronia: per ogni client accettato si avvia un task di gestione dedicato, senza attendere che termini, così il ciclo può subito accettare la connessione successiva. Questo sfrutta i concetti di asincronia e concorrenza visti nelle serie precedenti.
var listener = new TcpListener(IPAddress.Any, 5000);
listener.Start();
while (true)
{
// Attende un nuovo client
TcpClient client = await listener.AcceptTcpClientAsync();
// Avvia la gestione senza attenderla: il ciclo prosegue
_ = HandleClientAsync(client);
}
static async Task HandleClientAsync(TcpClient client)
{
using (client)
using (NetworkStream stream = client.GetStream())
{
var buffer = new byte[1024];
int bytesRead;
// Continua a leggere finche il client invia dati
while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
{
string text = Encoding.UTF8.GetString(buffer, 0, bytesRead);
byte[] echo = Encoding.UTF8.GetBytes(text);
await stream.WriteAsync(echo.AsMemory(0, echo.Length));
}
}
}
Il problema del framing dei messaggi
Un aspetto cruciale, spesso sottovalutato, è che TCP è un flusso di byte, non di messaggi. Non garantisce che una scrittura corrisponda a una lettura: i dati di più invii possono arrivare uniti, oppure un singolo invio può arrivare spezzato in più letture. Chi progetta il protocollo deve quindi definire come delimitare i messaggi, ad esempio anteponendo la loro lunghezza o usando un carattere separatore. Ignorare questo aspetto è una fonte frequente di bug nei protocolli personalizzati.
// Un approccio comune: anteporre la lunghezza del messaggio
static async Task SendMessageAsync(NetworkStream stream, string message)
{
byte[] payload = Encoding.UTF8.GetBytes(message);
// Invia prima la lunghezza come intero a 4 byte
byte[] length = BitConverter.GetBytes(payload.Length);
await stream.WriteAsync(length);
// Poi invia il contenuto vero e proprio
await stream.WriteAsync(payload);
}
Quando usare i socket diretti
Lavorare con i socket TCP offre il massimo controllo, ma comporta anche la responsabilità di gestire dettagli che le astrazioni di alto livello risolvono automaticamente. Ha senso quando si implementa un protocollo personalizzato, si integra un sistema legacy o si costruisce un servizio con requisiti particolari. Per la comunicazione web ordinaria, restano preferibili HttpClient e ASP.NET Core, che sono costruiti proprio sopra questi socket.
Conclusione
Abbiamo lavorato direttamente con i socket TCP, costruendo un server con TcpListener e un client con TcpClient, e scambiando dati tramite il NetworkStream con codifica e decodifica dei byte. Abbiamo visto come gestire più client in parallelo e affrontato il tema fondamentale del framing dei messaggi, ricordando che TCP è un flusso di byte. Nel prossimo articolo esploreremo UDP, il protocollo senza connessione, con la classe UdpClient.