Il networking in Java è un argomento interessante e molto utile perché
permette di capire come costruire applicazioni capaci di comunicare attraverso
la rete. L’obiettivo è vedere come sia possibile implementare un’applicazione
che gestisca direttamente un flusso di comunicazione tra due entità collegate
in rete.
In realtà, in parte abbiamo già avuto a che fare con risorse di rete. In
precedenza abbiamo utilizzato l’oggetto URL, e quindi abbiamo già visto che
Java consente di interagire con server remoti. Ad esempio, con JavaFX avevamo
fatto qualche prova con servizi online. Tuttavia, in quei casi lavoravamo a un
livello più alto: usando un URL si specifica già un protocollo applicativo, come
HTTP, FTP e simili. In pratica si sfruttano protocolli già pronti, che operano al
livello applicazione.
Quello che vogliamo fare adesso è diverso: non vogliamo usare direttamente
protocolli applicativi già esistenti, ma vogliamo costruire noi un dialogo client-
server partendo dai protocolli del livello trasporto. In questo modo saremo
noi a definire la logica dello scambio di messaggi.
Java mette a disposizione, a grandi linee, due possibili approcci per il
networking, che corrispondono ai due principali protocolli di trasporto:
UDP
TCP
Il protocollo UDP è quello non orientato alla connessione e considerato
inaffidabile dal punto di vista della consegna dei dati, perché non garantisce né
l’arrivo dei pacchetti né il loro ordine. È comunque molto usato in alcune
applicazioni dove conta soprattutto la velocità, come streaming o giochi online.
Per lavorare con UDP Java mette a disposizione classi come DatagramPacket
e DatagramSocket.
In questo corso, però, non ci concentreremo su UDP. Preferiamo analizzare
un’implementazione basata su TCP, che è invece un protocollo orientato alla
connessione e garantisce una comunicazione affidabile, ordinata e controllata.
Per lavorare con TCP, Java fornisce due classi fondamentali:
Socket
ServerSocket
Queste due classi permettono di costruire una classica architettura client-
server.
Struttura della comunicazione client-server
Abbiamo due soggetti:
da un lato il client
dall’altro il server
Il server rimane in attesa di richieste di connessione provenienti dai client.
Siccome TCP è orientato alla connessione, prima dello scambio di dati deve
esserci una fase iniziale in cui il client richiede la connessione e il server la
accetta.
Cosa deve conoscere il client?
Per potersi collegare a un server, il client deve conoscere due informazioni
fondamentali:
1. L’indirizzo IP del server
2. Il numero di porta su cui il server è in ascolto
L’indirizzo IP identifica la macchina nella rete, mentre la porta identifica il
servizio o processo che aspetta connessioni su quella macchina.
Cosa deve conoscere il server?
Il server deve fissare essenzialmente:
il numero di porta su cui mettersi in ascolto
L’indirizzo IP è implicitamente quello della macchina su cui gira il server.
La classe Socket
La classe principale della comunicazione TCP lato client è Socket. Essa
rappresenta il canale di comunicazione tra client e server.
Dal lato client, un oggetto Socket si ottiene specificando:
indirizzo IP del server
numero di porta
Ad esempio:
Socket s = new Socket("[Link]", 5000);
Quando si istanzia l’oggetto Socket, la richiesta di connessione viene effettuata
automaticamente. Non è necessario chiamare metodi aggiuntivi come
connect(): la connessione parte già durante la creazione dell’oggetto.
Naturalmente, affinché funzioni, deve esistere un server in ascolto su
quell’indirizzo e su quella porta.
Quando la connessione viene stabilita, si crea un canale virtuale
bidirezionale. Questo significa che:
il client può inviare dati al server
il client può ricevere dati dal server
e lo stesso vale per il server.
Comunicazione tramite stream
Una volta creata la socket, il problema diventa: come inviare e ricevere i dati?
Qui entrano in gioco gli InputStream e OutputStream.
L’oggetto Socket mette a disposizione direttamente i flussi di comunicazione:
InputStream in = [Link]();
OutputStream out = [Link]();
Questi flussi rappresentano il componente base della comunicazione. Sopra di
essi possiamo poi costruire decoratori, come già visto nel pattern Decorator,
per gestire diversi tipi di dati:
flussi binari
flussi testuali
lettura bufferizzata
scrittura formattata
Ad esempio:
BufferedReader br = new BufferedReader(new InputStreamReader(in));
PrintWriter pw = new PrintWriter(out, true);
Il comportamento del client
Dal lato client servono almeno due operazioni:
1. Inviare messaggi
2. Ricevere risposte
Invio dei messaggi
L’invio può essere gestito tramite un metodo dell’applicazione, ad esempio:
send(String msg)
Questo metodo scrive sullo stream di output.
Ad esempio:
[Link](msg);
Questa operazione viene eseguita su richiesta dell’utente. Se il programma ha
un’interfaccia grafica, potrebbe essere il click su un pulsante a scatenare
l’invio.
Il problema della ricezione
Il client però non deve solo inviare dati. Deve anche essere pronto a ricevere
risposte dal server.
Quindi la sua esecuzione non può limitarsi a:
aprire la socket
recuperare gli stream
inviare un messaggio
Deve invece mantenere la connessione attiva e restare in ascolto.
Dal punto di vista concettuale, questo significa che una parte dell’applicazione
client deve restare continuamente in attesa di dati provenienti dal server.
La soluzione naturale è utilizzare un thread separato.
Thread dedicato alla ricezione
Il client dovrebbe avere un thread apposito che rimane in ascolto delle risposte.
Mentre il thread principale si occupa dell’interazione con l’utente e dell’invio
dei messaggi, il thread secondario controlla continuamente lo stream di input.
In pratica:
il metodo send() usa l’OutputStream
un thread dedicato usa l’InputStream
Questo thread può eseguire un ciclo continuo, ad esempio:
while(true) {
String msg = [Link]();
[Link](msg);
In questo modo, ogni volta che arriva una risposta dal server, il client la
intercetta immediatamente.
Considerazione importante
Non è solo il server a restare in attesa. Anche il client, una volta connesso,
deve restare pronto a ricevere messaggi.
La differenza è che:
inizialmente il server aspetta richieste di connessione
dopo la connessione, sia client sia server possono attendere dati e
rispondere
Per questo motivo entrambe le applicazioni devono essere progettate in modo
concorrente, spesso con thread dedicati alla ricezione.
Quindi una tipica applicazione client TCP in Java quindi prevede:
1. Creazione della Socket
2. Ottenimento di InputStream e OutputStream
3. Metodo per inviare messaggi
4. Thread separato che resta in ascolto delle risposte
5. Gestione continua della connessione finché non viene chiusa
Questo è il modello base di moltissime applicazioni reali: chat, sistemi
distribuiti, giochi online, strumenti di controllo remoto e servizi di rete in
generale.
SERVER
Adesso analizziamo il comportamento del server. In realtà, rispetto al client, il
funzionamento è simile solo in parte, perché cambia soprattutto il modo in cui
viene gestita la connessione.
Nel caso del client avevamo visto che l’oggetto Socket viene creato
direttamente specificando:
indirizzo IP del server
numero di porta
Nel momento stesso in cui il client istanzia l’oggetto Socket, viene inviata
automaticamente una richiesta di connessione verso il server.
Nel caso del server, invece, la situazione è diversa. Il server non deve avviare
connessioni, ma deve restare in ascolto delle richieste provenienti dai client.
Per questo motivo non basta usare direttamente la classe Socket: serve un
oggetto specifico che rappresenti il comportamento lato server.
Questa classe si chiama ServerSocket.
La classe ServerSocket
ServerSocket è la classe che Java mette a disposizione per realizzare un server
TCP. Il suo compito principale è mettersi in ascolto su una determinata porta e
attendere richieste di connessione.
Per creare un server si specifica generalmente il numero di porta:
ServerSocket ss = new ServerSocket(5000);
A differenza del client, qui non si indica l’indirizzo IP remoto, perché il server è
in ascolto sulla propria macchina.
Il metodo accept()
Una volta creato l’oggetto ServerSocket, il server deve attendere che un client
provi a connettersi. Per farlo si utilizza il metodo:
accept()
Questo metodo è fondamentale e ha una caratteristica molto importante: è un
metodo bloccante.
Significa che, quando il server esegue accept(), il programma si ferma in quel
punto e rimane in attesa finché non arriva una richiesta di connessione da
parte di un client.
Esempio:
Socket socket = [Link]();
In questa istruzione il server resta fermo finché un client non tenta la
connessione sulla porta indicata.
Cosa restituisce accept()
Quando finalmente arriva una richiesta di connessione, il metodo accept()
termina l’attesa e restituisce un oggetto di tipo:
Socket -> Socket s = ServerSocket(PORT).accept()
Questo è molto importante: il server inizialmente usa un ServerSocket solo per
ascoltare le connessioni, ma una volta accettata una richiesta ottiene un
normale Socket.
Da quel momento in poi il comportamento diventa praticamente identico a
quello del client.
Confronto tra client e server
Lato client
Il client ottiene direttamente il Socket scrivendo qualcosa del tipo:
Socket s = new Socket("[Link]", 5000);
Questa operazione genera subito la richiesta di connessione.
Lato server
Il server invece procede in due fasi:
ServerSocket ss = new ServerSocket(5000);
Socket s = [Link]();
Prima crea il ServerSocket, poi aspetta una connessione e solo dopo ottiene il
Socket.
La vera differenza tra client e server
La differenza principale tra i due lati non è nell’uso finale della comunicazione,
ma nel modo in cui si ottiene il Socket.
Il client crea il Socket direttamente e avvia la connessione.
Il server crea prima un ServerSocket, poi riceve indirettamente il Socket
tramite accept().
Una volta ottenuto il Socket, però, entrambi lavorano nello stesso modo.
Cosa si può fare con il Socket
Sia il client sia il server, una volta in possesso del Socket, possono:
inviare dati
ricevere dati
mantenere la connessione aperta
chiuderla quando necessario
Entrambi possono ottenere:
InputStream in = [Link]();
OutputStream out = [Link]();
Attraverso questi stream possono comunicare in modo bidirezionale.
Il canale di comunicazione virtuale
Quando client e server sono connessi tramite i rispettivi Socket, si crea un
canale logico tra le due applicazioni. Questo canale non è fisico, ma virtuale: è
la connessione TCP che permette lo scambio continuo di informazioni.
Grazie agli stream di input e output, i due programmi possono scambiarsi dati
in entrambe le direzioni.
Quali dati si possono scambiare?
Una volta ottenuti gli stream, si può trasmettere praticamente qualsiasi tipo di
informazione. Dipende da come costruiamo l’applicazione.
Possiamo scambiare:
Byte grezzi
[Link](...)
Testo
Usando ad esempio BufferedReader, PrintWriter, Scanner, ecc.
Oggetti serializzati
Con:
ObjectInputStream
ObjectOutputStream
In questo caso si possono inviare veri e propri oggetti Java, purché serializzabili.
Concetto importante da ricordare
Dal punto di vista teorico, ciò che conta davvero è capire il ruolo delle due
classi:
ServerSocket
Serve solo al server per ascoltare connessioni in ingresso.
Socket
Serve sia al client sia al server per comunicare realmente.
Infatti è il Socket che mette a disposizione gli stream e permette lo scambio di
dati.
Schema mentale corretto
Il flusso logico è questo:
Client
1. Crea Socket
2. Richiede connessione
3. Comunica
Server
1. Crea ServerSocket
2. Chiama accept()
3. Ottiene Socket
4. Comunica
Dopo la fase iniziale di connessione, client e server si trovano in una situazione
simmetrica: entrambi possiedono un Socket e possono scambiarsi informazioni
liberamente.
Per questo motivo tutta la logica successiva alla connessione (lettura, scrittura,
gestione dei messaggi, thread di ascolto, protocolli applicativi personalizzati)
può essere progettata nello stesso modo su entrambi i lati.
CODICE
Adesso passiamo alla realizzazione pratica del progetto Java dedicato alla
comunicazione client-server. L’idea non è semplicemente scrivere due classi
separate, una per il client e una per il server, ma progettare la soluzione in
modo più ordinato e intelligente, cercando di individuare prima di tutto ciò che i
due comportamenti hanno in comune. Se osserviamo bene, infatti, client e
server svolgono molte operazioni simili: entrambi devono instaurare una
connessione, mantenerla attiva, inviare messaggi, ricevere dati e chiudere
correttamente tutte le risorse utilizzate. La vera differenza tra i due riguarda
principalmente il modo in cui ottengono la Socket: il client la crea direttamente
specificando indirizzo IP e porta, mentre il server la ottiene indirettamente
tramite una ServerSocket e il metodo accept().
Per questo motivo conviene costruire una classe astratta comune che
rappresenti genericamente una connessione di rete, che può essere chiamata
NetworkConnection. Questa classe raccoglie tutta la logica condivisa e verrà
poi specializzata da due sottoclassi concrete, una per il client e una per il
server. In questo modo evitiamo duplicazioni di codice e organizziamo meglio il
progetto.
All’interno della classe astratta servono innanzitutto gli attributi fondamentali
della connessione. Occorre memorizzare un indirizzo IP, rappresentato come
stringa, e un numero di porta, rappresentato come intero. L’indirizzo IP è
particolarmente utile per il client, che deve sapere a quale host collegarsi,
mentre il server normalmente utilizza soltanto la porta sulla quale resta in
ascolto. Tuttavia, per mantenere uniforme la struttura della classe, si possono
prevedere entrambi gli attributi.
Questa classe deve offrire alcuni servizi di base.
1. Il primo è il metodo connect(), che rappresenta l’avvio della connessione.
2. Poi serve un metodo disconnect(), utile per interrompere la
comunicazione e rilasciare correttamente socket e stream.
3. Un altro metodo essenziale è send(...), che permette di inviare dati
all’altra estremità della connessione. Per renderlo il più generale
possibile, il parametro può essere di tipo Serializable, così non siamo
limitati alle stringhe ma possiamo trasmettere qualsiasi oggetto
serializzabile: testi, numeri, strutture dati o oggetti personalizzati.
4. Poiché client e server ottengono la socket in modo diverso, conviene
introdurre un metodo astratto: isServer() oppure equivalente.
Questo metodo restituirà:
- true se l’oggetto rappresenta un server
- false se rappresenta un client
In base a questo valore il codice saprà come creare la connessione.
5. Serve poi anche un meccanismo per gestire i messaggi ricevuti. In una
prima versione si potrebbe immaginare un metodo come
handleMessage(Serializable msg), incaricato di elaborare i dati che
arrivano dalla rete.
Tuttavia, la parte davvero importante del progetto è capire che la connessione
non può limitarsi ad aprire la socket e basta: deve restare viva, pronta a
ricevere dati in qualsiasi momento. Per questo motivo è necessario introdurre
un thread separato, dedicato esclusivamente alla comunicazione.
La soluzione migliore consiste nel creare una classe interna chiamata ad
esempio ConnectionThread, che estende Thread. Essendo interna a
NetworkConnection, essa può accedere direttamente agli attributi della classe
esterna, rendendo la gestione più comoda. Nel metodo run() di questo thread si
realizza tutta la logica operativa della connessione.
Il thread si occupa di:
1. creare la socket
2. aprire input/output stream
3. restare in ascolto
4. leggere messaggi ricevuti
5. gestire chiusura connessione
La prima operazione che il thread deve compiere è ottenere la socket. Qui
emerge la differenza tra client e server. Se l’oggetto rappresenta un server,
allora bisogna creare una ServerSocket sulla porta indicata e invocare accept().
Quest’ultimo metodo è bloccante: il thread rimane fermo finché non arriva una
richiesta di connessione da parte di un client. Quando la richiesta arriva,
accept() restituisce finalmente un oggetto Socket. Se invece l’oggetto
rappresenta un client, il thread crea direttamente una nuova Socket,
specificando indirizzo IP e porta del server. In quel momento parte
automaticamente la richiesta di connessione.
Una volta ottenuta la socket, è consigliabile utilizzare il costrutto try-with-
resources, perché sia la socket sia gli stream sono risorse che devono essere
chiuse correttamente. Quando il blocco termina, Java provvede
automaticamente a rilasciare tutto. Questo è molto utile per evitare perdite di
risorse o connessioni lasciate aperte.
Dalla socket si ottengono poi gli stream di comunicazione. In questo progetto
vengono usati ObjectOutputStream e ObjectInputStream, perché consentono di
inviare e ricevere oggetti serializzati. Il metodo send(...) utilizzerà il riferimento
all’ObjectOutputStream per invocare writeObject(...), trasmettendo così il
messaggio all’altra estremità della connessione.
Il thread di comunicazione, però, non deve terminare dopo la sola apertura
della socket. Deve restare attivo e in ascolto. Per questo si inserisce un ciclo
continuo, ad esempio un while(true) o comunque un ciclo che prosegue finché
la connessione è aperta. All’interno del ciclo il thread invoca readObject() sul
flusso di input. Anche questo metodo è bloccante: rimane in attesa finché non
arriva un nuovo messaggio. Quando il messaggio arriva, viene letto e può
essere elaborato.
In una prima versione si potrebbe semplicemente stampare il contenuto
ricevuto sulla console. Tuttavia, questa soluzione è troppo rigida, perché
obbliga tutte le connessioni a comportarsi nello stesso modo. Per rendere il
codice più flessibile, si introduce un Consumer<Serializable>: private
Consumer<Serializable> onReceive. Il Consumer rappresenta una procedura
da eseguire ogni volta che arriva un messaggio. In pratica, quando il thread
riceve un oggetto dalla rete, invoca accept(...) sul Consumer, passandogli il
messaggio ricevuto.
Questo approccio è molto elegante, perché il comportamento non è più cablato
dentro la classe. Chi crea l’oggetto client o server può decidere dinamicamente
cosa fare quando arriva un messaggio. Si può scegliere di stamparlo in console,
salvarlo su file, aggiornare un’interfaccia grafica o eseguire qualsiasi altra
logica applicativa. Tutto questo si realizza comodamente con una lambda
expression o con un riferimento a metodo.
A questo punto implementare le classi concrete diventa molto semplice. La
classe Client estende NetworkConnection, richiede indirizzo IP, porta e
Consumer, e segnala che si tratta di un client. La classe Server estende
anch’essa NetworkConnection, richiede soltanto porta e Consumer, e segnala
che si tratta di un server. Tutta la logica comune resta nella superclasse
astratta.
Nel main si possono quindi creare un oggetto server e un oggetto client. Prima
si avvia il server con connect(), così esso si mette in attesa sulla porta scelta.
Poi si avvia il client, che tenta la connessione. Una volta stabilito il
collegamento, il client può inviare ad esempio il messaggio “ciao”, il server
riceverlo e rispondere con “arrivederci”. Infine entrambi possono invocare
disconnect(), chiudendo ordinatamente la comunicazione.
Questo progetto è molto significativo dal punto di vista didattico, perché unisce
numerosi concetti fondamentali di Java. Si utilizzano programmazione a
oggetti, classi astratte, ereditarietà, thread, stream, serializzazione, lambda
expressions e interfacce funzionali. Inoltre, mostra un esempio concreto di
buona progettazione del software, perché separa le responsabilità, evita
duplicazioni e rende il codice riusabile.
Con una struttura di questo tipo si ottiene una base estremamente flessibile.
Modificando soltanto la logica del Consumer, la stessa architettura può
diventare una chat, un sistema client-server con interfaccia grafica,
un’applicazione distribuita, uno strumento di controllo remoto o qualsiasi altro
programma che richieda scambio di dati in rete.
C’è un aspetto importante da chiarire riguardo all’uso degli stream nella
comunicazione di rete. Quando si lavora con i file, normalmente un
InputStream legge un contenuto che esiste già, perché il file è stato creato in
precedenza e contiene dati già memorizzati. In quel caso il flusso di input si
limita a recuperare qualcosa che è già disponibile su disco. Quando invece si
lavora con il networking, la situazione è diversa: il contenuto non è già
presente, ma deve essere prodotto dinamicamente dall’altra estremità della
connessione. Per questo motivo l’implementazione degli stream dipende
direttamente dalla connessione stessa e dal fatto che esiste un canale attivo
tra due applicazioni.
Questo spiega anche la differenza tra comunicazione basata su TCP e
comunicazione basata su UDP. Se si usassero DatagramPacket e
DatagramSocket, non si lavorerebbe con stream continui. In UDP, infatti, non
esiste il concetto di flusso sequenziale di dati. Il programmatore costruisce un
pacchetto di byte e invia esattamente quel pacchetto. Ogni invio è
indipendente dagli altri. Non esiste una connessione stabile tra mittente e
destinatario, non c’è garanzia di consegna, non c’è certezza che i pacchetti
arrivino nell’ordine corretto e non c’è la possibilità di gestire automaticamente
frammentazione, riordinamento o continuità dello scambio come avviene con
TCP. In pratica, con UDP si controlla direttamente il singolo blocco di byte da
spedire, mentre con TCP si lavora su un flusso continuo di dati.
Il concetto di stream, infatti, è possibile proprio perché TCP crea una
connessione stabile tra due estremità. Una volta identificati client e server e
instaurata la connessione, il protocollo garantisce una sequenzialità nello
scambio delle informazioni. I dati arrivano nell’ordine corretto e vengono
trattati come un flusso continuo. È questa caratteristica che permette a Java di
costruire sopra la socket gli InputStream e gli OutputStream.
Esiste però un dettaglio tecnico molto importante quando si usano
ObjectInputStream e ObjectOutputStream. Quando si istanzia un
ObjectInputStream, esso si aspetta immediatamente di ricevere un header,
cioè una particolare intestazione iniziale del flusso. Questo header viene inviato
automaticamente dall’ObjectOutputStream dell’altra parte nel momento in cui
quest’ultimo viene creato. In pratica, l’ObjectInputStream rimane bloccato
finché non riceve quell’intestazione necessaria per iniziare a leggere
correttamente il flusso serializzato.
Di conseguenza, l’ordine con cui si creano gli stream è fondamentale. Se su
entrambe le estremità si crea prima l’ObjectOutputStream e poi
l’ObjectInputStream, il sistema funziona correttamente. Ogni lato invia il
proprio header tramite l’output stream, e l’input stream dell’altra parte lo
riceve, sbloccandosi. Se invece si commette l’errore di creare prima
l’ObjectInputStream su entrambe le estremità, si genera una situazione di
stallo: ciascun lato aspetta un header che nessuno ha ancora inviato, perché
nessuno ha ancora creato l’ObjectOutputStream. In questo caso i due
programmi restano bloccati indefinitamente.
Per questo motivo bisogna sempre rispettare la sequenza corretta: prima si
istanzia l’ObjectOutputStream, poi l’ObjectInputStream. Questo vale sia lato
client sia lato server. È una regola pratica molto importante, perché se la si
ignora il programma può sembrare corretto ma restare bloccato senza
spiegazioni evidenti.
Un altro dettaglio utile riguarda il momento in cui si avviano client e server.
Normalmente conviene far partire prima il server e lasciargli qualche istante
per mettersi in ascolto sulla porta scelta. Solo dopo si avvia il client. Se i due
processi partissero in modo simultaneo o troppo ravvicinato, il client potrebbe
tentare di collegarsi prima che il server abbia completato la fase di ascolto,
generando eccezioni di connessione o errori nel reperimento degli stream. Per
questo spesso, negli esempi didattici, si inserisce un piccolo ritardo tra l’avvio
del server e quello del client. Non è una soluzione elegante in applicazioni reali,
ma aiuta a gestire correttamente la sincronizzazione in esempi semplici.
Un’altra osservazione importante riguarda la complessità reale dei server. Negli
esempi svolti finora si considera un server molto semplificato, capace di
accettare una sola connessione client alla volta. In questo caso il codice è più
lineare: il server attende una connessione, la accetta, comunica con quel client
e poi termina o resta dedicato a quella singola sessione.
Nella realtà, però, un server può accettare molte connessioni
contemporaneamente sulla stessa porta. Pensiamo a un server web: decine o
centinaia di client possono collegarsi allo stesso tempo. In questi casi, ogni
volta che il ServerSocket accetta una nuova connessione, di solito si crea un
thread separato dedicato a quel client. Ogni thread gestisce la comunicazione
con uno specifico utente, mentre il thread principale del server continua a
restare in ascolto di nuove richieste.
Questo rende il codice più complesso, perché non si gestisce più una sola
socket ma un insieme di connessioni attive contemporaneamente. Occorre
coordinare thread multipli, eventuali strutture dati condivise, sincronizzazione e
gestione corretta delle chiusure. Tuttavia, il principio generale rimane lo stesso
di quello studiato nel caso semplice: accettare connessioni, ottenere una
socket, creare stream, leggere e scrivere dati. Cambia soltanto la scala del
problema.
L’importante, a livello teorico, è aver compreso bene il meccanismo base nel
caso più semplice, perché tutto il resto deriva da quello. Prima si comprende il
modello con un solo client e un solo server, poi si può estendere la stessa
logica a scenari più avanzati con molti client contemporanei o con interfacce
grafiche.
Infatti, un’evoluzione naturale di questo tipo di esercizio consiste nell’integrare
la comunicazione di rete con JavaFX. In quel caso la logica di networking resta
sostanzialmente la stessa, ma invece di stampare i messaggi sulla console si
aggiornano componenti grafici, finestre, pulsanti, aree di testo e così via. In
pratica si mantiene l’architettura client-server già studiata, ma la si collega a
un’interfaccia utente più completa e realistica.