Socket in Java: client e server TCP riga per riga
Dalla teoria dei socket alla pratica: package java.net e java.io, gestione eccezioni, InetAddress, ServerSocket, Socket e il nostro primo scambio reale di messaggi tra client e server.
Nella lezione precedente abbiamo visto la teoria dei socket: cosa sono, come si identificano tramite la coppia IP:porta, come funziona il ciclo operativo di uno stream socket lato client e lato server. Ho mantenuto il codice volutamente semplice per facilitarne la lettura in questa lezione, perché il nostro obiettivo oggi è un altro: passare dalla teoria alla pratica, scrivendo un vero client e un vero server TCP in Java, riga per riga, capendo esattamente cosa fa ciascuna istruzione. Se non hai ancora configurato l'ambiente di lavoro con Visual Studio Code e il JDK, recupera prima la guida dedicata: da qui in avanti diamo per scontato che tu abbia già un progetto Java pronto all'uso.
Ragazzi, oggi niente teoria astratta: scriviamo codice che funziona davvero. Vi chiedo una cosa sola: non copiate-incollate a caso. Ogni riga che vi mostro ha un commento che spiega perché c'è. Leggetela, capite cosa fa, poi scrivetela voi. Alla fine della lezione avrete un server che gira sulla 12345 e un client che gli parla — ed è vostro, non mio. Se riuscite a spiegare a voce cosa fa accept() e perché usiamo BufferedReader, avete già passato l'interrogazione.
1. La programmazione di rete in Java
La programmazione di rete consente la comunicazione tra dispositivi tramite protocolli come TCP/IP. In Java questa comunicazione è gestita attraverso i socket TCP, che — come abbiamo visto nella lezione precedente — garantiscono un trasferimento affidabile e ordinato dei dati tra client e server. Java mette a disposizione due package fondamentali per questo scopo: java.net, dedicato alla comunicazione di rete vera e propria, e java.io, dedicato alla lettura e scrittura dei dati che viaggiano su quella comunicazione.
Prima di iniziare, è consigliabile preparare l'ambiente di lavoro seguendo la guida VS Code + JDK. Create un progetto vuoto, due file Server.java e Client.java nella stessa cartella. Niente package custom per ora, tutto in default — così evitiamo complicazioni con classpath. Se usate IntelliJ, stesso discorso: progetto Java semplice, JDK 17+.
2. Il package java.net
Il package java.net fornisce classi e metodi per creare applicazioni di rete in Java, semplificando operazioni come l'apertura di socket, la gestione di connessioni client-server, la gestione di URL e molto altro.
2.1 Struttura dei package in Java
Un package è un contenitore logico di classi e interfacce che consente di evitare conflitti di nomi in progetti di grandi dimensioni, migliorare modularità e leggibilità del codice, e favorire la riusabilità di librerie e moduli predefiniti. Ogni file sorgente Java che dichiara di appartenere a un package deve iniziare con l'istruzione package, mentre per usare classi definite in un altro package si utilizza l'istruzione import.
2.1.1 Esempio di dichiarazione di package:
2.1.2 Esempio di importazione di un package:
Un package è come un cassetto etichettato: metti tutte le classi che riguardano la rete in java.net, quelle che riguardano i file in java.io. Se non ci fossero cassetti, avresti 10.000 classi buttate sul pavimento con lo stesso nome e non troveresti più nulla. Quando fai import java.net.* stai dicendo "apri il cassetto rete e fammi usare tutto quello che c'è dentro".
2.1.3 Classi principali del package java.net
| Classe | A cosa serve |
|---|---|
| InetAddress | Rappresenta un indirizzo IP, numerico o come nome host — per risolvere DNS |
| Socket e ServerSocket | Implementano la comunicazione client-server basata su stream TCP — cuore della lezione |
| URL e URLConnection | Gestiscono risorse Web e connessioni HTTP a più alto livello — livello applicativo |
3. Gestione dell'I/O in Java con il Package java.io
Il package java.io consente di leggere e scrivere dati da file, memoria o connessioni di rete. Include classi organizzate per tipo di dato e per funzione:
| Categoria | Classi principali | A cosa serve |
|---|---|---|
| Byte stream | InputStream, OutputStream | Gestiscono l'I/O a livello di singoli byte — grezzo, per immagini, file binari |
| Character stream | Reader, Writer | Gestiscono l'I/O a livello di caratteri — per testo, con codifica UTF-8 |
| Buffering | BufferedReader, BufferedWriter | Aggiungono un buffer interno per ottimizzare lettura/scrittura, migliorando prestazioni + metodo readLine() |
| File | File, FileInputStream, FileOutputStream | Manipolano file e directory, apertura file fisici |
| Oggetti | ObjectInputStream, ObjectOutputStream | Leggono/scrivono interi oggetti Java, con serializzazione |
java.net apre il tubo (il socket). java.io fa passare l'acqua nel tubo (i dati). Senza net non hai connessione. Senza io hai connessione muta, non puoi leggere/scrivere. È come telefono: net fa squillare e stabilisce la linea, io è la voce che ci passa dentro.
Questo esempio è nato proprio da una prova che ho effettuato in laboratorio, ed è il modo più diretto per vedere all'opera BufferedReader e BufferedWriter: leggere un file riga per riga, e scrivere un file da zero.
3.1 Esempio — lettura di un file riga per riga
Il metodo readLine() restituisce null quando il file è terminato: è la condizione che usiamo per fermare il ciclo while.
Notate le parentesi tonde dopo try? È il try-with-resources introdotto in Java 7: tutto quello che apri lì dentro (file, socket, stream) viene chiuso automaticamente alla fine, anche se c'è un'eccezione. Prima di Java 7 dovevate chiudere tutto a mano nel finally. Usatelo sempre: niente più reader.close() dimenticato che lascia file bloccati.
3.2 Esempio — scrittura su un file
Anche qui il try-with-resources garantisce la chiusura automatica dello stream, anche in caso di eccezione.
4. Gestione delle eccezioni
In Java, le eccezioni si gestiscono tramite quattro parole chiave: try-catch intercetta gli errori che si verificano nel blocco try; finally esegue del codice indipendentemente dal fatto che si sia verificata un'eccezione o meno; throw genera manualmente un'eccezione; throws dichiara, nella firma di un metodo, che quel metodo può generare un'eccezione controllata, lasciando a chi lo chiama la responsabilità di gestirla.
throw = azione, lanci un'eccezione dentro il metodo (throw new Exception()). throws = dichiarazione, dici nella firma "questo metodo PUÒ lanciare eccezione" (void metodo() throws IOException). Un piccolo errore in questa riga può cambiare completamente il comportamento del programma: dimenticare un throws nella firma di un metodo che lancia un'eccezione controllata è uno degli errori di compilazione più comuni.
4.1 Esempio — eccezione personalizzata
Non per ogni errore. Create eccezione custom quando volete distinguere un errore di dominio (età non valida, voto fuori range, saldo insufficiente) da un errore tecnico (IOException). Così chi chiama il metodo può fare catch(EtaNonValidaException) diverso da catch(IOException). Nei socket useremo principalmente IOException, ma il meccanismo è identico.
5. Realizzazione di Client e Server TCP
Vediamo ora come utilizzare le classi di Java per implementare un server e un client TCP, esplorando tre classi fondamentali: InetAddress, ServerSocket e Socket.
5.1 Classe InetAddress
La classe InetAddress rappresenta gli indirizzi IP e mette a disposizione diversi metodi per astrarre l'indirizzo, consentendo di lavorare sia con indirizzi numerici sia con nomi host. InetAddress non ha costruttori pubblici: per crearne un'istanza si usano metodi statici, cioè metodi che appartengono alla classe e non a una sua istanza specifica, e che quindi si possono chiamare senza creare prima un oggetto.
| Metodo | Cosa restituisce |
|---|---|
static InetAddress getByName(String host) | L'indirizzo dell'host specificato (nome o IP); se null, restituisce macchina locale |
static InetAddress getLocalHost() | L'indirizzo della macchina locale; se non registrata in DNS o dietro firewall, 127.0.0.1 |
String getHostName() | Il nome host corrispondente all'IP, via DNS se necessario |
String getHostAddress() | L'indirizzo IP numerico come stringa |
byte[] getAddress() | L'IP come array di byte, high byte first |
Se getLocalHost() vi restituisce 127.0.0.1 invece del vostro IP LAN, non è un bug: siete dietro NAT o il nome host non è registrato in DNS. Usate InetAddress.getByName("192.168.1.10") con il vostro IP esplicito, o lasciate localhost per test locali. In laboratorio useremo sempre localhost per semplicità.
5.2 Classe ServerSocket
La classe ServerSocket viene utilizzata per creare un server che accetta connessioni da parte dei client: è un punto di ascolto che rimane in attesa di richieste su una porta specificata. Il costruttore richiede come parametro il numero di porta su cui il server sarà in ascolto:
Il metodo principale usato con ServerSocket è accept(), che blocca l'esecuzione finché un client non si connette. Quando ciò accade, accept() restituisce un oggetto Socket che rappresenta il canale di comunicazione con quel client:
Quando chiamate accept() il vostro programma si ferma lì. Letteralmente. Non va avanti finché non arriva un client. È come stare alla cassa del supermercato ad aspettare il prossimo cliente: non potete fare altro finché non arriva. Per questo nei server veri accept() sta in un loop infinito e ogni client viene gestito in un thread separato — lo vedremo nella prossima lezione sui multi-client. Se lanciate il server e non succede nulla, è normale: sta aspettando in accept().
5.3 Classe Socket
La classe Socket rappresenta un canale di comunicazione tra un client e un server. Un oggetto Socket viene creato lato client quando ci si connette a un server tramite indirizzo e porta:
Una volta connesso, il client può inviare e ricevere dati tramite gli stream di input e output associati al socket:
Perché usare stream bufferizzati
Quando si comunica attraverso connessioni TCP, i dati vengono suddivisi in pacchetti IP: per questo è consigliabile utilizzare stream bufferizzati, evitando così pacchetti contenenti poche informazioni ciascuno. La definizione tipica dei due stream, di input e output, che un server usa verso un client, è la seguente:
client.getInputStream() → flusso byte grezzi dalla connessione. new InputStreamReader(...) → adattatore byte→caratteri UTF-8. new BufferedReader(...) → buffer + metodo readLine(). Matrioska: ogni livello aggiunge funzionalità. Dopo questa definizione, l'uso diventa trasparente: readLine() e println().
Nell'esempio seguente che ho testato in laboratorio:
- Il server si aspetta di ricevere del testo dal client.
- Ogni volta che il client invia una linea di testo e termina con nuova linea (\n), il BufferedReader legge quella linea e il server la stampa.
6. Primo Esercizio client e server che si scambiano un messaggio
Obiettivo: il client invia un messaggio al server e riceve una risposta che include il proprio indirizzo IP.
Ho preferito mostrare anche il risultato dell'esecuzione più avanti, dopo il codice completo, così puoi verificare che il tuo output combaci esattamente con il mio prima di passare oltre. Leggete prima l'algoritmo in italiano, poi il codice Java commentato riga per riga. Se capite l'algoritmo, il codice è solo traduzione.
L'algoritmo del server, passo per passo
- Dichiaro il package in cui saranno contenuti i file Server.java e Client.java
- Importo i package necessari (java.io e java.net)
- Definisco la classe Server
- Nel main, dentro un blocco try-catch per gestire le eccezioni: creo un ServerSocket in ascolto sulla porta 12345 e stampo un messaggio di conferma, accetto una connessione e stampo l'IP del client, creo gli stream di input/output per ricevere e inviare dati, ricevo il messaggio dal client, rispondo al client, chiudo tutte le risorse aperte
In questo primo esempio realizziamo tutto dentro il solo metodo main. Per rendere il codice più leggibile, riusabile e ben strutturato, più avanti estrarremo la logica nei metodi attendi() e comunica(). È normale iniziare semplice.
Server.java (commentato riga per riga)
Client.java (commentato riga per riga)
Il client si connette al server sulla porta 12345, invia il messaggio "Ciao, server!" e riceve la risposta.
Server.java
(1) creazione ServerSocket porta 12345 + conferma console
(2) accept() → Socket dedicato a quel client
(3) comunicazione: BufferedReader per leggere, PrintWriter per rispondere con IP client
(4) chiusura ordinata risorse
Client.java
(1) Socket("localhost",12345) connessione
(2) invio "Ciao, server!" via output, ricezione risposta via input
(3) chiusura
6.1 Output in console
Se il vostro output è identico al mio, avete fatto tutto giusto. Se il server resta fermo su "in ascolto..." è normale: sta in accept() aspettando. Lanciate il client in un altro terminale. Se vedete "Connection refused" → avete dimenticato di lanciare prima il server. Se vedete "Address already in use" → porta 12345 già occupata da altro processo, cambiate porta o killate processo precedente.
6.2 Separare la logica in metodi: attendi() e comunica()
Inizialmente avevo impostato il codice tutto dentro il main, ed è la scelta più naturale quando si scrive un primo esempio: ma per rendere il codice più leggibile, riusabile e strutturato correttamente conviene estrarre la logica del server nei metodi attendi() e comunica(), come mostrato di seguito.
Nota che attendi() e comunica() dichiarano throws IOException invece di gestire l'eccezione al proprio interno: è una scelta di progettazione deliberata, che lascia al chiamante — in questo caso il main — la responsabilità di decidere come reagire a un errore. È lo stesso principio, applicato in piccolo, che ritroverai in quasi ogni libreria Java ben progettata.
Dopo alcune prove ho preferito lasciarti proprio questa versione con i metodi separati come punto di arrivo della lezione, perché è la struttura più vicina a quella che userai davvero nei prossimi laboratori — a partire dal prossimo, in cui proveremo a gestire più client contemporaneamente. Se oggi hai capito main → attendi() → comunica(), domani aggiungiamo un while(true) + thread e hai un server multi-client.
7. 📌 Concetti chiave
- Il package java.net fornisce le classi per la comunicazione di rete (InetAddress, Socket, ServerSocket, URL); il package java.io fornisce le classi per leggere e scrivere dati, inclusi gli stream bufferizzati che ottimizzano le prestazioni.
- Un package è un contenitore logico di classi; si dichiara con
packagee si importa conimport. Evita conflitti di nomi e migliora modularità. - Gli stream si organizzano a strati: byte stream (InputStream) → character stream (InputStreamReader) → buffered stream (BufferedReader con readLine). Matrioska di funzionalità.
- Le eccezioni si gestiscono con try/catch/finally, si generano con throw e si dichiarano nella firma di un metodo con throws; è possibile creare eccezioni personalizzate estendendo Exception.
- InetAddress rappresenta un indirizzo IP e si istanzia solo tramite metodi statici come getByName() e getLocalHost().
- ServerSocket si mette in ascolto su una porta e usa accept() (bloccante) per accettare una connessione, restituendo un Socket dedicato a quel client.
- Per comunicare in modo efficiente si usano stream bufferizzati: InputStream → InputStreamReader → BufferedReader per leggere, PrintWriter con autoflush per scrivere.
readLine()legge fino a \n. - Separare la logica in metodi dedicati (come
attendi()ecomunica()) rende il codice più leggibile, testabile e riusabile rispetto a tutto concentrato nel main.throws IOExceptiondelega gestione errore al chiamante.
Vuoi passare all'azione? Accedi alla repository ufficiale GitHub del corso per scaricare il file del laboratorio pratico
🧪 Esercizi Socket TCP GitHub →Vuoi consultare la sezione dedicata ai laboratori sul sito? Accedi cliccando il pulsante qui sotto.
📊 Sezione dedicata ai LaboratoriVuoi consultare la sezione dedicata alle guide sul sito? Accedi cliccando il pulsante qui sotto.
🧪 Guida preparazione ambiente di lavoro →📚 Prossimo passo - Server multi-client
Nella prossima lezione faremo evolvere questo server: da un solo client a N client contemporanei con thread e while(true). Se oggi il codice con attendi() e comunica() ti è chiaro, domani aggiungiamo new Thread() e il tuo server diventa davvero professionale. Esercizio: modifica Client.java per inviare il tuo nome invece di "Ciao, server!" e fai rispondere il server con "Ciao [tuo nome], IP [tuo IP]". Se ci riesci, hai capito tutto.