Implementazione di un server e un client TCP in Java
Guida al funzionamento del protocollo TCP e all'uso dei socket in Java. Analizza il ciclo di vita delle connessioni e confronta le architetture client/server single-thread e multithread con esempi di codice pronti all'uso.
TCP è il protocollo di rete fondamentale che abilita la maggior parte delle comunicazioni affidabili su Internet, fungendo da base per framework di alto livello come HTTP, WebSocket e gRPC. Il ciclo di vita di una richiesta HTTP, che include la risoluzione DNS, l'handshake TCP, la trasmissione della richiesta, l'elaborazione e la risposta del server, e infine il teardown TCP, evidenzia il ruolo critico di questo protocollo nella moderna architettura client-server del Web.
Introduzione
Oggi vediamo le fondamenta su cui si basa l'Internet moderna. TCP è la base di quasi tutte le comunicazioni di rete affidabili: anche quando si lavora con framework ad alto livello come HTTP, WebSocket o gRPC, dietro le quinte c'è sempre un socket TCP che gestisce la connessione. Capire come funziona dà un vantaggio enorme in termini di comprensione dei sistemi che usiamo ogni giorno.
Per capire quanto sia fondamentale la comprensione del protocollo TCP e della sua implementazione, ecco qualche esempio di uso comune.
- Web server e API REST: HTTP è un protocollo costruito sopra TCP. Quando scrivi un server web, sotto il cofano c'è un
ServerSocketche accetta connessioni e parla TCP. Capire come funziona può aiutarti nel debugging dei problemi di rete, dei timeout, del keep-alive e così via. - Database: qualsiasi database comunica con i client tramite protocolli basati su TCP.
- Applicazioni di messaggistica e chat: WhatsApp, Slack e Telegram utilizzano connessioni TCP persistenti per inviare e ricevere messaggi in tempo reale.
- Email: SMTP, IMAP e POP3 sono protocolli TCP. I server di posta sono server TCP specializzati.
Ce ne sarebbero molti altri, ma ci fermiamo qui: nelle aziende, la conoscenza dei fondamenti è molto apprezzata anche nei colloqui tecnici.
Il ciclo di vita di una richiesta HTTP su TCP
L'architettura client-server del Web si basa su un modello asimmetrico richiesta-risposta governato dal protocollo HTTP. Il client avvia attivamente la comunicazione per ottenere una risorsa, mentre il server ascolta passivamente, elabora la richiesta, invia la risorsa e chiude la connessione TCP.
- Client: è il processo (browser, script cURL, applicazione mobile) che conosce l'indirizzo della controparte. Apre un socket TCP verso l'IP di destinazione e invia un messaggio di testo formattato (richiesta HTTP) indicando un metodo, ad esempio
GET /index.html. - Server: è un processo in esecuzione su una macchina con un IP raggiungibile, di solito statico. Resta in stato di ascolto su una porta specifica (80 per HTTP, 443 per HTTPS) e non avvia mai la comunicazione verso il client.
Il ciclo di vita si compone essenzialmente di cinque fasi.
- Risoluzione DNS: il client converte il nome di dominio nell'indirizzo IP del server.
- Handshake TCP: il client instaura una connessione a livello di trasporto (layer 4) tramite il meccanismo del three-way handshake TCP (SYN, SYN-ACK, ACK).
- Richiesta: il client trasmette l'header HTTP, specificando la risorsa voluta e i parametri accessori.
- Elaborazione e risposta: il server processa la richiesta, recupera il file dal disco o lo genera dinamicamente tramite un backend, e risponde con un codice di stato seguito dal payload.
- Teardown TCP: terminata la trasmissione, il server avvia la chiusura formale della connessione inviando un pacchetto FIN.
Per approfondire il meccanismo del protocollo TCP, il Cloudflare Learning Center offre una spiegazione tecnica accurata e aggiornata.
Cos'è una socket
Prima di passare al codice, vale la pena spendere due parole sul concetto di socket: per molti può sembrare una banalità, ma è doveroso vista la centralità che ha nell'implementazione che vedremo.
Una socket non è altro che un'interfaccia per un processo. Un processo classico vive sulla tua macchina, ma quando ha bisogno di comunicare con altre macchine in rete ha bisogno di un meccanismo, appunto le socket, che gli permetta di scambiare messaggi e dati.
Per dirla in modo più preciso, una socket è il punto di accesso al canale di comunicazione tra processi in rete, e si identifica attraverso la combinazione di indirizzo IP e numero di porta.
Client e server TCP single-thread
Iniziamo dalla versione più semplice: un server che accetta una sola connessione alla volta e un client che dialoga con lui riga per riga, implementando un classico servizio di echo (il server rispedisce al client lo stesso messaggio ricevuto, preceduto da "Echo:").
Il client TCP single-thread
package it.dev.tcp.v1.client;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
public class ClientSinglethreadTCP {
private static final int PORTA = 50000;
private static final String INDIRIZZO_SERVER = "127.0.0.1";
public static void main(String[] args) {
try {
Socket connessione = new Socket(INDIRIZZO_SERVER, PORTA);
BufferedReader console = new BufferedReader(new InputStreamReader(System.in));
System.out.print("Connesso al server. Scrivi un messaggio ('exit' per uscire): ");
String messaggioDigitato = console.readLine();
BufferedReader ingressoServer = new BufferedReader(new InputStreamReader(connessione.getInputStream()));
PrintWriter uscitaServer = new PrintWriter(connessione.getOutputStream(), true);
while (messaggioDigitato != null) {
if ("exit".equalsIgnoreCase(messaggioDigitato)) {
break;
}
uscitaServer.println(messaggioDigitato);
String risposta = ingressoServer.readLine();
if (risposta == null) {
System.out.println("Il server ha chiuso la connessione");
break;
}
System.out.println("Risposta dal server: " + risposta);
System.out.print("\nNuovo messaggio: ");
messaggioDigitato = console.readLine();
}
console.close();
uscitaServer.close();
ingressoServer.close();
connessione.close();
} catch (IOException e) {
System.out.println("Errore: " + e.getMessage());
}
}
}
Vediamo cosa succede riga per riga, perché ogni scelta ha un motivo preciso.
new Socket(INDIRIZZO_SERVER, PORTA): è il punto di partenza di tutto. Creare un oggettoSocketcon indirizzo e porta esegue implicitamente il three-way handshake visto sopra: se il server non è in ascolto su quella porta, questa riga lancia un'eccezione (catturata più sotto dal bloccocatch).BufferedReader console: legge l'input digitato dall'utente da tastiera.System.inè lo stream grezzo,InputStreamReaderlo converte in caratteri leggibili,BufferedReaderaggiunge un buffer che permette di leggere comodamente riga per riga conreadLine(). Il risultato di ogni lettura viene salvato inmessaggioDigitato.ingressoServereuscitaServer: sono gli stream associati alla socketconnessione, non alla console.ingressoServerlegge ciò che arriva dal server,uscitaServerscrive ciò che va inviato al server. Nota il secondo parametrotruepassato aPrintWriter: attiva l'auto-flush, cioè ogniprintln()viene inviato immediatamente sulla rete invece di restare bloccato in un buffer interno in attesa di essere svuotato. Senza questo parametro il messaggio potrebbe non arrivare mai al server finché il buffer non si riempie.- Il ciclo
while (messaggioDigitato != null): è il cuore della logica. Finché l'utente non digitaexit(controllato conequalsIgnoreCaseper non essere sensibili a maiuscole/minuscole) e finché il flusso di input non termina, il client inviamessaggioDigitatotramiteuscitaServer, attende la risposta coningressoServer.readLine()e la stampa a video. if (risposta == null):readLine()restituiscenullquando il flusso in lettura è terminato, cioè quando la controparte ha chiuso la connessione. È il modo standard in Java per rilevare la disconnessione senza bisogno di eccezioni.- I quattro
close()finali: chiudono ordinatamenteconsole,uscitaServer,ingressoServereconnessione. È buona pratica farlo sempre, anche se il programma sta per terminare, per evitare di lasciare file descriptor o socket aperti a livello di sistema operativo.
Il server TCP single-thread
package it.dev.tcp.v1.server;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerSingleThreadTCP {
private static final int PORTA = 50000;
public static void main(String[] args) {
try {
ServerSocket socketAscolto = new ServerSocket(PORTA);
System.out.println("Server in ascolto sulla porta " + PORTA);
Socket connessioneClient = socketAscolto.accept();
BufferedReader ingressoClient = new BufferedReader(new InputStreamReader(connessioneClient.getInputStream()));
PrintWriter uscitaClient = new PrintWriter(connessioneClient.getOutputStream(), true);
String messaggio = ingressoClient.readLine();
while (messaggio != null) {
System.out.println("Messaggio ricevuto: " + messaggio);
uscitaClient.println("Echo: " + messaggio);
messaggio = ingressoClient.readLine();
}
ingressoClient.close();
uscitaClient.close();
connessioneClient.close();
} catch (IOException e) {
System.out.println("500 - Internal Server Error");
}
}
}
main. Perché la JVM riconosca ed esegua automaticamente il metodo come punto di ingresso dell'applicazione tramite il comando java, deve essere dichiarato public static void main(String[] args). Se manca il modificatore public, il metodo non è visibile all'esterno del package e l'esecuzione della classe come programma standalone fallisce.
Analizziamo la logica specifica del server, che differisce da quella del client in punti chiave.
new ServerSocket(PORTA): a differenza delSocketdel client, unServerSocketnon si connette a nessuno: apre semplicementesocketAscolto, una porta in ascolto sulla macchina locale, pronta ad accettare richieste in entrata.socketAscolto.accept(): questa è la riga più importante da capire. È una chiamata bloccante: il thread principale del programma si ferma qui e non prosegue finché un client non si connette. Quando arriva una connessione,accept()restituisce un nuovo oggettoSocket, salvato inconnessioneClient, distinto dasocketAscolto, che rappresenta il canale di comunicazione dedicato a quel singolo client.- Perché questo server è "single-thread" nel senso limitativo del termine:
accept()viene chiamato una sola volta, fuori da qualsiasi ciclo. Questo significa che il server gestisce un solo client per tutta la durata dell'esecuzione: se un secondo client prova a connettersi mentre il primo è ancora attivo, la sua richiesta di connessione resta in coda a livello di sistema operativo (nel cosiddetto backlog) finché la connessione corrente non viene chiusa, dopodiché il programma termina comunque, perché il flusso di esecuzione esce dal bloccotrysenza richiamare di nuovoaccept(). - Il ciclo
while (messaggio != null): è speculare a quello visto nel client. Ogni riga ricevuta tramiteingressoClientviene stampata in console e rispedita al client tramiteuscitaClient, con il prefisso"Echo: ".
Le due porzioni di codice viste finora illustrano l'implementazione client/server TCP a singolo thread: il server non accetta connessioni multiple, e se proviamo a connetterci con un secondo client la connessione resta bloccata in coda finché il primo client non si disconnette.
Client e server TCP multithread
Passiamo ora all'implementazione multithread, che risolve il limite principale della versione precedente.
Il client TCP multithread
Il client multithread è utile, ad esempio, nelle applicazioni di messaggistica, per inviare e ricevere messaggi con un thread di ricezione separato, in modo asincrono: l'utente può scrivere un nuovo messaggio anche mentre è in attesa di una risposta dal server, senza che l'interfaccia (in questo caso la console) resti bloccata.
package it.dev.tcp.v1.client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
public class ClientMultithreadTCP {
private final static Logger logger = LoggerFactory.getLogger(ClientMultithreadTCP.class);
private final static String INDIRIZZO_SERVER = "127.0.0.1";
private final static int PORTA = 50000;
public static void main(String[] args) {
try {
Socket connessione = new Socket(INDIRIZZO_SERVER, PORTA);
PrintWriter uscitaServer = new PrintWriter(connessione.getOutputStream(), true);
BufferedReader ingressoServer = new BufferedReader(new InputStreamReader(connessione.getInputStream()));
BufferedReader console = new BufferedReader(new InputStreamReader(System.in));
avviaThread(ingressoServer);
System.out.println("Connesso al server ('exit' per uscire)");
System.out.print("Nuovo messaggio: ");
String messaggioDigitato = console.readLine();
while (messaggioDigitato != null) {
if ("exit".equalsIgnoreCase(messaggioDigitato)) {
break;
}
uscitaServer.println(messaggioDigitato);
messaggioDigitato = console.readLine();
}
console.close();
uscitaServer.close();
ingressoServer.close();
connessione.close();
} catch (IOException e) {
logger.error("400 - Bad Request: {}", e.getMessage());
}
}
private static void avviaThread(BufferedReader ingressoServer) {
Runnable compito = () -> gestisciServer(ingressoServer);
Thread threadRicezione = new Thread(compito);
threadRicezione.start();
}
private static void gestisciServer(BufferedReader ingressoServer) {
try {
String risposta = ingressoServer.readLine();
while (risposta != null) {
System.out.println("\nRisposta dal server: " + risposta);
System.out.print("Nuovo messaggio: ");
risposta = ingressoServer.readLine();
}
System.out.println("\nIl server ha chiuso la connessione");
} catch (IOException e) {
logger.error("400 - Bad Request: {}", e.getMessage());
}
}
}
Le differenze chiave rispetto al client single-thread sono concentrate in due punti.
avviaThread(ingressoServer): viene chiamato subito dopo l'apertura della socket, prima ancora che l'utente digiti qualcosa. Crea unRunnablechiamatocompitoche punta al metodogestisciServere lo esegue su unThreaddedicato (threadRicezione), distinto dal thread principale (main). Da questo momento in poi, il thread principale gestisce solo l'input da console tramitemessaggioDigitato, mentrethreadRicezionegestisce solo la ricezione dal server: i due flussi lavorano in parallelo e in modo indipendente.gestisciServer(BufferedReader ingressoServer): è il metodo eseguito dal thread secondario. Contiene un ciclo che legge continuamente dal server coningressoServer.readLine()e stampa a schermo ogni risposta, finché la connessione non viene chiusa (readLine()restituiscenull). Il vantaggio pratico è che, se il server impiega tempo a rispondere, il thread principale non resta bloccato in attesa: l'utente può continuare a scrivere.loggeral posto diSystem.out: qui viene introdotta la libreria SLF4J per il logging. A differenza diSystem.out.println, un logger strutturato permette di distinguere il livello di gravità del messaggio (info,errore così via) e di configurare in modo centralizzato dove e come questi messaggi vengono scritti, un aspetto importante quando l'applicazione cresce oltre il semplice esempio didattico.
Il server TCP multithread
package it.dev.tcp.v1.server;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ServerMultithreadTCP {
private static final Logger logger = LoggerFactory.getLogger(ServerMultithreadTCP.class);
private final static int PORTA = 50000;
public static void main(String[] args) {
try {
ServerSocket socketAscolto = new ServerSocket(PORTA);
logger.info("Server in ascolto sulla porta {}", PORTA);
while (true) {
Socket connessioneClient = socketAscolto.accept();
logger.info("Nuovo client connesso: {}", connessioneClient.getRemoteSocketAddress());
avviaThread(connessioneClient);
}
} catch (IOException e) {
logger.error("500 - Internal Server Error: {}", e.getMessage());
}
}
private static void avviaThread(Socket connessioneClient) {
Runnable compito = () -> gestisciClient(connessioneClient);
Thread thread = new Thread(compito);
thread.start();
}
private static void gestisciClient(Socket connessioneClient) {
String infoClient = connessioneClient.getRemoteSocketAddress().toString();
try {
BufferedReader ingressoClient = new BufferedReader(new InputStreamReader(connessioneClient.getInputStream()));
String messaggio = ingressoClient.readLine();
PrintWriter uscitaClient = new PrintWriter(connessioneClient.getOutputStream(), true);
while (messaggio != null) {
logger.info("[{}] Messaggio ricevuto: {}", infoClient, messaggio);
uscitaClient.println("Echo: " + messaggio);
messaggio = ingressoClient.readLine();
}
ingressoClient.close();
uscitaClient.close();
} catch (IOException e) {
logger.info("Errore nella comunicazione con {}: {}", infoClient, e.getMessage());
} finally {
logger.error("Connessione terminata: {}", infoClient);
}
}
}
Questa è la classe più importante dell'intero articolo, perché è quella che risolve concretamente il limite del server single-thread.
while (true) { ... socketAscolto.accept(); ... }: la differenza fondamentale rispetto alla versione precedente sta tutta qui.accept()non viene più chiamato una sola volta, ma dentro un ciclo infinito: appena una connessione viene accettata e salvata inconnessioneClient, delegata a un thread separato, il ciclo torna immediatamente a chiamareaccept()e resta pronto ad accogliere il client successivo, senza dover aspettare che il precedente si disconnetta.avviaThread(connessioneClient): per ogni client accettato viene creato un nuovoThreaddedicato che eseguegestisciClient. Questo pattern si chiama comunemente "thread-per-connessione": ogni connessione vive la sua vita indipendentemente dalle altre, su un thread proprio, permettendo al server di conversare in parallelo con più client contemporaneamente.gestisciClient(Socket connessioneClient): contiene la stessa logica di echo vista nel server single-thread (leggi riga tramiteingressoClient, rispondi con "Echo:" tramiteuscitaClient, ripeti), ma ora questa logica viene eseguita in isolamento su ogni thread, uno per client.connessioneClient.getRemoteSocketAddress()viene salvato ininfoCliente usato nei log per identificare quale client ha inviato o ricevuto cosa, un dettaglio utile quando più client sono connessi simultaneamente.- Il blocco
finally: garantisce che il messaggio di terminazione connessione venga loggato indipendentemente dal fatto che il ciclo sia terminato normalmente (client disconnesso senza errori) o a causa di un'eccezione catturata nelcatch.
catch di gestisciClient, l'errore di comunicazione viene registrato con logger.info, mentre la normale terminazione della connessione nel blocco finally usa logger.error. I livelli sono invertiti rispetto alla convenzione: un errore dovrebbe essere loggato come error, e una chiusura regolare della connessione come info. Vale la pena rivedere questi due livelli prima di considerare il codice pronto per un ambiente diverso da quello didattico.
Confronto tra le due architetture
| Aspetto | Server single-thread | Server multithread |
|---|---|---|
| Client gestiti in parallelo | Uno solo alla volta | Più client contemporaneamente |
| Complessità del codice | Minima, adatta a scopi didattici | Maggiore, richiede gestione della concorrenza |
| Scalabilità | Nulla oltre il singolo client | Buona, limitata dal numero di thread creabili dal sistema |
| Caso d'uso tipico | Test locali, apprendimento dei socket | Servizi reali con più utenti connessi (chat, giochi multiplayer, protocolli custom) |
Come TCP distingue le connessioni sulla stessa porta
Un dubbio comune, soprattutto per chi si avvicina per la prima volta a questi concetti, è capire come un server multithread possa ricevere più connessioni sulla stessa porta (50000 in questo esempio) senza che i messaggi dei diversi client si mescolino tra loro.
La risposta sta nel modo in cui TCP identifica una connessione: non basta la sola porta di destinazione, ma serve una combinazione di quattro valori, spesso chiamata tupla a quattro elementi (4-tuple).
- IP sorgente (il client)
- Porta sorgente (assegnata dinamicamente dal sistema operativo del client)
- IP destinazione (il server)
- Porta destinazione (
50000, la porta su cuisocketAscoltoè in ascolto)
Ogni volta che un nuovo client si connette, il suo sistema operativo gli assegna automaticamente una porta sorgente diversa e temporanea. Il risultato è che, anche se tutti i client si collegano allo stesso IP e alla stessa porta del server, la combinazione dei quattro valori resta unica per ciascuna connessione. È proprio questa unicità che permette a socketAscolto di restituire, ad ogni chiamata di accept(), un Socket distinto e indipendente per ciascun client, ognuno instradato correttamente dal sistema operativo verso il thread che lo sta gestendo.
Oltre il thread-per-connessione
Il modello "un thread per client" utilizzato nel server multithread è un ottimo punto di partenza didattico, ma ha un limite pratico: ogni thread tradizionale in Java consuma memoria e risorse del sistema operativo, quindi con un numero molto alto di client connessi contemporaneamente le prestazioni possono degradare. Due direzioni comuni per evolvere questo modello in un contesto più vicino alla produzione sono:
- l'uso di un pool di thread predefinito tramite
ExecutorService, che riutilizza un numero limitato di thread invece di crearne uno nuovo per ogni connessione; - l'adozione dei thread virtuali introdotti in Java (Da verificare: numero di versione preciso), pensati proprio per rendere sostenibile il modello thread-per-connessione anche con un numero molto elevato di connessioni simultanee, senza dover riscrivere la logica in stile asincrono/reattivo.
Un esempio illustrativo (non presente nella bozza originale, valore della dimensione del pool puramente esemplificativo) potrebbe essere:
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> gestisciClient(connessioneClient));
Come testare i server
Prima di scrivere un client Java completo, è possibile verificare rapidamente il comportamento di entrambi i server usando uno strumento a riga di comando presente sulla maggior parte dei sistemi operativi (Da verificare: comando introdotto ex novo, non presente nella bozza originale).
telnet 127.0.0.1 50000
In alternativa, dove telnet non è disponibile, si può usare nc (netcat) con la stessa sintassi di base (Da verificare: comando introdotto ex novo, non presente nella bozza originale).
nc 127.0.0.1 50000
Con il server single-thread avviato, la prima connessione funzionerà normalmente; una seconda connessione aperta in un'altra finestra di terminale resterà invece in attesa. Ripetendo lo stesso test con il server multithread avviato, entrambe le connessioni riceveranno risposta in modo indipendente: è il modo più immediato per toccare con mano la differenza tra le due implementazioni viste in questo articolo.