Java Tutorial - Parte 2 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Versione 0.8: implementazione del TCP/IP

Introduzione

In questo capitolo scriveremo le classi che implementano la connessione di rete che usa il protocollo TCP/IP. Noterete che, avendo organizzato le classi generiche del package mastermind.net che si occupano di tutta la organizzazione della connessione e trasmissione dei messaggi, la mera implementazione di un particolare protocollo è davvero facile e si limita alla scrittura dei metodi astratti che si occupano del vero input/output dei messaggi.

I metodi che implementano il protocollo di rete possono anche essere bloccanti: è la classe generica Connection che si fà carico di eseguirli in un thread separato. Prima di procedere con la analisi dei nuovi sorgenti, dobbiamo introdurre una caratteristica di Java di cui non abbiamo ancora discusso: il package JavaNIO (=Java New I/O)

JavaNIO (Java New IO)

Nel capitolo Java Networking abbiamo analizzato il package java.net che contiene le classi che Java mette a disposizione del programmatore per gestire una connessione TCP/IP. In questo capitolo analizzeremo degli strumenti alternativi per la gestione delle connessioni TCP/IP (ma non solo): questi strumenti si basano su paradigmi totalmente diversi rispetto a java.net. Se il lettore è già a conoscenza dei concetti di canali e buffers di I/O può saltare direttamente alla sezione La connessione in Mastermind.

Le sezioni successive sono una libera traduzione dalla lingua inglese di un articolo scritto da Jakob Jenkov e pubblicato il 13 ottobre 2020 al seguente link: ava NIO Tutorial. In realtà, non tutto l'articolo viene tradotto in italiano ma solo le parti che interessano la gestione del protocollo TCP/IP.

JavaNIO è una API di Input/Output alternativa per Java, ovvero alternativa alle API Java Networking standard già descritte in Java Networking e che abbiamo usato per realizzare il progetto Un piccolo telnet. Le caratteristiche principali della API JavaNIO sono:

  • IO non bloccante: JavaNIO consente di eseguire I/O non bloccante. Ad esempio, un thread può chiedere a un canale di leggere i dati in un buffer. Mentre il canale legge i dati nel buffer, il thread può fare qualcos'altro. Una volta che i dati sono stati letti nel buffer, il thread può quindi continuare a elaborarli. Lo stesso vale per la scrittura dei dati nei canali.
  • canali e buffer: nella API IO standard si lavora con flussi di byte e flussi di caratteri; In NIO si lavora con canali e buffer. I dati vengono sempre letti da un canale in un buffer, o scritti da un buffer in un canale.
  • Selettori: JavaNIO contiene il concetto di selettori. Un selettore è un oggetto che può monitorare più canali per eventi (come la connessione aperta, dati arrivati ​​ecc.). Quindi, un singolo thread può monitorare più canali per i dati.
  • Interruzioni: le primitive di I/O di java.net non sono interrompibili mentre i canali di JavaNIO lo sono

Per quanto riguarda l'ultimo punto, mi spiego meglio: prendiamo come esempio il metodo InputStream.read che, seppur bloccante, può essere eseguito in un thread separato in modo da non bloccare la GUI. Il problema è che il thread separato si trova anch'esso bloccato nella operazione di I/O e non si può sbloccare fintanto che almeno un byte viene letto dal flusso di input; il thread non si sblocca nemmeno se esso viene cancellato!!!
I canali di JavaNIO, invece, sono interrompibili nel senso che se un thread è bloccato nel metodo SocketChannel.read ed un altro thread (ovviamente il EDT nel nostro caso) lo "cancella", il metodo read viene interrotto sollevando una eccezione CancellationException ed il thread stesso termina.

I canali di NIO

I canali JavaNIO sono simili ai flussi, con alcune differenze:

  • è possibile sia leggere che scrivere su un canale mentre i flussi sono in genere unidirezionali (lettura o scrittura).
  • i canali possono essere letti e scritti in modo asincrono.
  • i canali leggono sempre da, o scrivono su, un buffer.

I buffers di NIO

I buffer JavaNIO vengono utilizzati quando si interagisce con i canali NIO. Come accennato in precedenza, i dati vengono letti dai canali nei buffer e scritti dai buffer nei canali.
Un buffer è essenzialmente un blocco di memoria in cui puoi scrivere dati, che puoi poi leggere di nuovo in seguito. Questo blocco di memoria è racchiuso in un oggetto di classe ByteBuffer, che fornisce un set di metodi che semplificano il lavoro con il blocco di memoria.

Uso di base di un buffer

L'utilizzo di un buffer per leggere e scrivere dati segue in genere questo piccolo processo in 4 fasi:

  • scrivi i dati nel buffer
  • richiama il metodo buffer.flip()
  • leggi i dati dal buffer
  • richiama il metodo buffer.clear() o buffer.compact()

Quando scrivi dati in un buffer, il buffer tiene traccia della quantità di dati che hai scritto. Quando hai bisogno di leggere i dati, devi passare dalla modalità di scrittura alla modalità di lettura usando la chiamata al metodo flip(). In modalità di lettura il buffer ti consente di leggere tutti i dati scritti nel buffer.
Dopo aver letto tutti i dati, devi cancellare il buffer, per renderlo di nuovo pronto per la scrittura. Puoi farlo in due modi: chiamando clear() o chiamando compact(); il metodo clear() cancella l'intero buffer mentre Il metodo compact() cancella solo i dati che hai già letto. Tutti i dati non letti vengono spostati all'inizio del buffer e i dati che verranno ora scritti nel buffer saranno posizionati dopo i dati non letti.

Ecco un semplice esempio di utilizzo del buffer, con le operazioni di scrittura, capovolgimento (flip) del buffer, lettura e cancellazione

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();
//crea un buffer di capacità 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf); // scrive 48 bytes dal canale nel buffer
while (bytesRead != -1) {
buf.flip(); // ora il buffer è pronto per essere letto
while(buf.hasRemaining()){
System.out.print((char) buf.get()); // legge 1 byte per volta
}
// cancella il buffer, rendendolo pronto per la successiva scrittura
buf.clear();
bytesRead = inChannel.read(buf);
}
aFile.close();

Capacità, posizione e limite del buffer

Un buffer è essenzialmente un blocco di memoria in cui puoi scrivere dati, che puoi poi leggere di nuovo in seguito. Questo blocco di memoria è racchiuso in un oggetto NIO Buffer, che fornisce un set di metodi che semplificano il lavoro con il blocco di memoria.
Un buffer ha tre proprietà con cui devi avere familiarità, per capire come funziona un buffer. Queste sono:

  • capacity: la capacità del buffer
  • position: la posizione corrente di scrittura e lettura
  • limit: il limite entro il quale è possibile leggere o scrivere

Il significato di position e limit dipende dal fatto che il buffer sia in modalità di lettura o scrittura mentre capacity ha sempre lo stesso significato, indipendentemente dalla modalità del buffer. Nella figura seguente si può osservare il significato di queste tre proprietà:

Capacità del buffer

Essendo un blocco di memoria, un Buffer ha una certa dimensione fissa, detta anche capacità. Puoi scrivere solo un numero finito di dati nel buffer e questo numero è pari alla capacità del buffer, inteso come capacità massima. Una volta che il Buffer è pieno, devi svuotarlo (leggere i dati o cancellarlo) prima di poter scrivere altri dati al suo interno.

Posizione del buffer

Quando scrivi dati nel Buffer, lo fai in una certa posizione. Inizialmente la posizione è pari a ZERO. Quando un dato è stato scritto nel Buffer, la posizione viene avanzata per puntare alla cella successiva nel buffer in cui inserire i dati. La posizione può diventare al massimo capacity-1.

Quando leggi dati da un Buffer, lo fai anche da una data posizione. Quando passi un Buffer dalla modalità di scrittura alla modalità di lettura, la posizione viene reimpostata a 0. Quando leggi dati dal Buffer, lo fai dalla posizione e la posizione viene avanzata alla posizione successiva per la lettura.

Limite del buffer

In modalità di scrittura, il limite di un Buffer è il limite di quanti dati puoi scrivere nel buffer e questo limite è pari alla capacità.
Quando si commuta il Buffer in modalità lettura, limit indica il limite di quanti dati è possibile leggere dal buffer e pertanto, quando si commuta un Buffer in modalità lettura, limit è impostato sulla posizione di scrittura della modalità scrittura. In altre parole, è possibile leggere tanti dati quanti ne sono stati scritti (limit è impostato sul numero di byte scritti, che è contrassegnato da position).

Tipi di buffer

Java NIO è dotato dei seguenti tipi di buffer che corrispondono alle classi definite nel package java.nio

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

Come puoi vedere, questi tipi di buffer rappresentano i tipi di dati primitivi di Java. Noterete la assenza di un buffer per contenere stringhe di caratteri (la classe String) ma questo è dovuto al fatto che un buffer è una zona di memoria di dimensioni fisse e che deve contenere un numero ben definito di elementi: poichè una String può essere di dimensioni variabili non sarebbe possibile allocare con precisione la memoria necessaria a contenerle.

Il MappedByteBuffer è un po' speciale e non verrà trattato in questo contesto.

Allocazione di un Buffer

Per ottenere un oggetto Buffer, devi prima allocarlo usando il metodo allocate(). Ecco un esempio che mostra l'allocazione di un ByteBuffer, con una capacità di 48 byte:

ByteBuffer buf = ByteBuffer.allocate(48);

Scrittura di dati in un buffer

È possibile scrivere dati in un buffer in due modi:

  • scrivi dati da un canale in un buffer
  • scrivi i dati nel buffer da solo, tramite i metodi put() del buffer.

Ecco un esempio che mostra come un canale può scrivere dati in un buffer:

int bytesRead = inChannel.read(buf); //leggi nel buffer.

Ecco un esempio che scrive dati in un buffer tramite il metodo put():

buf.put(127);

Esistono molte altre versioni del metodo put(), che consentono di scrivere dati nel buffer in molti modi diversi ad esempio, scrivendo in posizioni specifiche o scrivendo un array di bytes nel buffer. Consulta JavaDoc per l'implementazione concreta del buffer per maggiori dettagli.

Il metodo flip() commuta un buffer dalla modalità di scrittura alla modalità di lettura. La chiamata a flip() ripristina la posizione a 0 e imposta il limite al punto in cui si trovava position.

In altre parole, position ora segna la posizione di lettura, mentre limit segna quanti dati sono stati scritti nel buffer, ovvero il limite di quanti dati possono essere letti.

Lettura dei dati da un buffer

Ci sono due modi per leggere i dati da un buffer.

  • leggere i dati dal buffer in un canale.
  • leggere i dati dal buffer da soli, utilizzando uno dei metodi get().

Ecco un esempio di come leggere i dati da un buffer in un canale:

//leggere dal buffer nel canale.
int bytesWritten = inChannel.write(buf);

Ecco un esempio che legge i dati da un buffer utilizzando il metodo get():

byte aByte = buf.get();

Esistono molte altre versioni del metodo get(), che consentono di leggere i dati dal buffer in molti modi diversi ad esempio, leggendo in posizioni specifiche o eggendo un array di bytes dal buffer. Per maggiori dettagli, consulta JavaDoc per l'implementazione concreta del buffer.

Il metodo rewind()

Il metodo rewind() riporta la posizione a 0, così puoi rileggere tutti i dati nel buffer. Il limite rimane intatto, quindi continua a segnare quanti elementi possono essere letti dal Buffer.

I metodi clear() e compact()

Una volta terminata la lettura dei dati dal Buffer, devi preparare il Buffer di nuovo per la scrittura. Puoi farlo chiamando clear() o compact().
Se chiami clear(), la posizione viene riportata a 0 e il limite alla capacità del buffer; in altre parole, il Buffer viene cancellato anche se, effettivamente, i dati nel Buffer non vengono cancellati. Solo i marcatori che indicano dove puoi scrivere i dati nel Buffer lo sono.
Se ci sono dati non letti nel Buffer quando chiami clear(), quei dati verranno dimenticati, il che significa che non hai più marcatori che indicano quali dati sono stati letti e quali no.

Se ci sono ancora dati non letti nel Buffer e vuoi leggerli in seguito, ma devi prima scrivere qualcosa, chiama compact() invece di clear().
Il metodo compact() copia tutti i dati non letti all'inizio del Buffer. Quindi imposta la posizione subito dopo l'ultimo elemento non letto eLa proprietà limit è ancora impostata sulla capacità, proprio come fa clear(). Ora il buffer è pronto per la scrittura, ma i dati non letti non verranno sovrascritti.

I sockets Internet

Un SocketChannel è un canale connesso a un socket di rete TCP ed è l'equivalente NIO della classe Socket della libreria Java Networking. Ci sono due modi per creare un SocketChannel:

  • apri un SocketChannel e ti connetti a un server da qualche parte su Internet: questa è la modalità di utilizzo del client
  • un SocketChannel può essere creato quando una connessione in arrivo viene accettata da un ServerSocketChannel (il canale socket del server)

Apertura di un SocketChannel

Ecco come aprire un SocketChannel dal lato del client:

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("www.acme.com", 80));

Chiusura di un SocketChannel

Dopo l'uso, il SocketChannel si chiude chiamando il metodo SocketChannel.close():

socketChannel.close();

Lettura da un SocketChannel

Per leggere i dati da un SocketChannel si chiama uno dei metodi read(). Ecco un esempio:

ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);

Per prima cosa viene allocato un Buffer. I dati letti dal SocketChannel vengono scritti nel Buffer in una unica operazione. Vi è da notare che questa operazione è bloccante; in altre parole il thread si blocca fino a che l'intero buffer non viene letto e questo forse non è quello che il programmatore si aspetta.

In secondo luogo viene chiamato il metodo SocketChannel.read(). Questo metodo legge i dati dal SocketChannel nel Buffer. Il valore intero restituito dal metodo read() indica quanti byte sono stati scritti nel Buffer. Se viene restituito -1, significa che è stata raggiunta la fine del flusso, in una connessione TCP/IP per "fine del flusso" si intende che la connessione è stata chiusa dalla macchina remota.

Scrittura su un SocketChannel

La scrittura dei dati su un SocketChannel viene eseguita utilizzando il metodo SocketChannel.write(), che accetta un Buffer come parametro. Ecco un esempio:

String newData = "Nuova stringa da scrivere nel file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}

Nota come il metodo SocketChannel.write() viene chiamato all'interno di un ciclo while. Non c'è garanzia di quanti bytes il metodo write() scrive nel canale quindi ripetiamo la chiamata a write() finché il Buffer non ha più byte da scrivere.

Il server socket

La classe ServerSocketChannel è un canale che può rimanere in ascolto su una porta TCP/IP ed accettare le connessioni in arrivo dal client. La classe ServerSocketChannel si trova nel pacchetto java.nio.channels. Ecco un esempio:

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(18862));
while(true){
SocketChannel socketChannel = serverSocketChannel.accept();
}

Di seguito la descrizione del codice:

  • nella prima riga si apre il server socket con il metodo open
  • nella seconda riga si lega il server ad un servizio specifico, cioè ad una porta TCP/IP che, nel caso in esame, è la 18862.
  • nel ciclo while viene richiamato il metodo accept il quale accetta una richiesta di connessione proveniente dal client; questo metodo ritorna il canale socket da usare per l'invio e la ricezione dei messaggi

Da notare che il metodo accept blocca il thread fintanto che una connessione non viene accettata.

L'indirizzo del remoto

Dopo che il server ha accettato la connessione da parte del client è possibile ottenere l'indirizzo IP del client:

SocketAddress socketAddress = socketChannel.getRemoteAddress();
String ipAddress = socketAddress.toString();

La connessione in Mastermind

La classe che implementa la connessione TCP/IP in Mastermmind è la ConnectionTCP, derivata da Connection, e si trova nel package mastermind.net.impl Essa implementa i quattro metodi di I/O astratti usati dalla classe base per ke effettive operazioni di input/output sul canale di comunicazione (vedi I metodi astratti di I/O.

Messaggi completi

Come avete imparato nelle sezioni precedenti, i canali sockets sono mezzi di comunicazione a basso livello: l'unità essenziale di trasmissione sui canali di comunicazione è il byte (otto bits) ma è comunque possibile trasmettere anche int e float attraverso i buffer specifici.
Nella sezione Telnet - protocollo applicativo abbiamo imparato che è necessario per chi riceve un messaggio da una connessione remota stabilire in che modo il messaggio può considerarsi completo in modo da poterlo elaborare. Nella sezione citata abbiamo elaborato due protocolli distinti:

  • dal lato client, un messaggio può essere considerato concluso quando il flusso di dati termina con il carattere new-line dal momento che un messaggio del client rappresenta un comando ed esso è composto da una sola riga
  • dal lato server, il messaggio di risposta può contenere una o più righe quindi il carattere new-line non può essere usato come marcatore di fine messaggio ed infatti abbiamo usato una altra tecnica

Mentre per l'invio del messaggio non ci sono particolari problemi dal momento che la lunghezza del messaggio da inviare è nota, per quanto riguarda la ricezione il problema esiste dal momento che il messaggio non è ancora stato costruito e la sua lunghezza non è nota. Per ovviare a questo problema ci sono sostanzialmente quattro approcci diversi:

  • si inviano sempre e solo messaggi di lunghezza fissa
  • si inserisce alla fine del messaggio un marcatore di fine messaggio; nello standard ASCII questo marcatore è il EOT (End Of Transmission), codice 0x04.
  • si divide il messaggio in due parti: una di lunghezza fissa detta header (=intestazione) ed una di lunghezza variabile detta body (=corpo del messaggio) nell'header del messaggio si inserisce la lunghezza effettiva del messaggio (o del solo body)
  • si continua a leggere dal canale socket fino allo scadere di un timeout prestabilito; se non c'è più nulla da leggere si presume che il messaggio sia terminato

Il primo approccio ha il vantaggio di essere facile da implementare ma ha un evidente svantaggio: se i messaggi sono di lunghezza molto variabile abbiamo da una parte il rischio di non poter inviare un messaggio completo perchè troppo lungo e dall'altra parte di dover riempire di filler (=un riempimento senza alcun significato) un messaggio molto corto.

Il secondo approccio ha il vantaggio di essere facile da implementare e di avere anche la certezza di non sprecare larghezza di banda usando un filler; l'unico svantaggio di questo approccio è che funziona solo con i messaggi di testo: non potremmo mai inviare, per esempio, una array di interi codificati in binario dal momento che uno di essi potrebbe contenere il valore 0x04 che sarebbe interpretato come la fine del messaggio.
Questo non significa che non possiamo trasmettere valori numerici; essi possono comunque essere convertiti in stringhe di testo (p.es. "1234") ma come fare con altri tipi di dato come suoni, immagini o testo compresso?

Il terzo approccio è quello che consente la più ampia flessibilità a fronte di una complessità maggiore ed è di gran lunga il più usato nelle comunicazioni remote: si divide il messaggio in due parti:

  • una prima parte di lunghezza fissa detta header che contiene informazioni di servizio tra cui la lunghezza effettiva del messaggio; questa parte è solitamente molto corta
  • una seconda parte di lunghezza variabile detta body: la lunghezza di questa parte del messaggio viene specificata nel header

Il quarto approccio è semplice da implementare ma si applica solo ad applicazioni particolari come per esempio una applicazione che invia comandi ad un server ed aspetta sempre una risposta prima di continuare la applicazione; questo approccio non può funzionare se i messaggi arrivano in modo asincrono.

Esistono alri modi per risolvere il problema e si deve comunque tenere sempre presente che è la applicazione che determina il modo migliore. Per esempio nella applicazione Un piccolo telnet si è usato un modo completamente diverso da quelli descritti più sopra.

Il formato del messaggio

Analizzando la Tabella dei tipi di messaggio possiamo osservare che la lunghezza di un messaggio Mastermind è più o meno sempre la stessa per tutti i codici di messaggio e che essa è piuttosto contenuta:

  • il campo codice ha una lunghezza fissa di tre caratteri
  • il campo id può essere lungo fino a 10 caratteri
  • ognuno dei quattro campi dati può essere lungo fino a 10 caratteri
  • il campo nickname potrebbe essere lungo a piacere ma, per convenienza sarà limitato a 16 caratteri
  • il campo errorDescr potrebbe anche essere lungo a piacere ma, per convenienza, sarà limitato a 64 caratteri

Potrebbe quindi essere conveniente per i messaggi MasterMind usare buffers di lunghezza fissa; calcolando che ben difficilmente il messaggio supererà i 256 bytes in lunghezza, decidiamo di esagerare assegnando al messaggio MasterMind una lunghezza fissa di 512 bytes.

Per quanto riguarda il formato dei dati si potrebbe optare per uno molto semplice, di puro testo, con campi separati da un carattere speciale quale il punto-e-virgola.
Il messaggio avrebbe questo aspetto:

MM0;type;id;field1;field2;field3;field4;filler

dove:

  • MM0 è un codice speciale che indica che il messaggio appartiene a MasterMind (MM) e lo "0" stà ad indica la versione del formato; in questo modo possiamo in futuro cambiare formato identificandolo con "MM1" e mantenere la compatibilità col passato
  • type è il codice del tipo di messaggio
  • id è la ID del messaggio; il numero progressivo attribuito automaticamente
  • field-(n): sono i quattro campi che contengono le informazioni; il contenuto effettivo dipende dal tipo di messaggio
  • filler: è un riempimento senza significato, necessario ad ottenere messaggi della stessa lunghezza; il filler viene riempito di caratteri whitespace (=letteralmente: spazio bianco, è il carattere che si ottiene premendo la barra spaziatrice sulla tastiera).

Il codice per ottenere il messaggio nel formato desiderato da inviare su un canale socket è piuttosto semplice.

// File: ConnectionTCP.java
ByteBuffer messageToByteBuffer( Message msg )
{
int MESSAGE_LENGTH = 512;
ByteBuffer buffer = ByteBuffer.allocate( MESSAGE_LENGTH );
buffer.put( "MM0".getBytes( StandardCharsets.UTF_8 ));
buffer.put( (byte) ';' );
buffer.put( msg.getType().toString().getBytes( StandardCharsets.UTF_8 ));
buffer.put( (byte) ';' );
buffer.put( msg.getID().toString().getBytes( StandardCharsets.UTF_8 ));
buffer.put( (byte) ';' );
for ( int i = 0; i < NUM_FIELDS; i++ ) {
if ( fields[i] != null ) {
buffer.put( fields[i].getBytes( StandardCharsets.UTF_8 ));
}
else {
buffer.put( "null".getBytes( StandardCharsets.UTF_8 ));
}
buffer.put( (byte) ';' );
}
// inserisce il filler
while ( buffer.hasRemaining()) {
buffer.put( (byte) ' ' );
}

Con i buffers, scrivere il messaggio sul canale è davvero facilissimo:

// File: ConnectionTCP.java
public int doSendMessage( Message msg ) throws MMException
{
... omissis ...
ByteBuffer buffer = messageToByteBuffer( msg, MESSAGE_LENGTH );
buffer.flip();
try {
while( buffer.hasRemaining()) {
channel.write(buffer);
}
}
catch( ... omissis ...
return msg.getID();
}

Per quanto riguarda la ricezione di un messaggio sul canale, il fatto di aver optato per messaggi a lunghezza fissa ci facilita enormemente il compito:

// File: ConnectionTCP.java
public Message doReadMessage() throws MMException
{
Message msg = null;
ByteBuffer buffer = ByteBuffer.allocate( MESSAGE_LENGTH );
try {
while( buffer.hasRemaining()) {
int r = channel.read( buffer );
if ( r < 0 ) {
throw new ConnectionLostException( "connetion closed by the peer" );
}
}
buffer.flip();
msg = messageFromByteBuffer( buffer );
}
catch( ... omissis ... )
return msg;
}

Il codice è di facile lettura. Una nota merita il costrutto:

int r = channel.read( buffer );
if ( r < 0 ) {
throw new ConnectionLostException( "connetion closed by the peer" );
}

Se nella primitiva di I/O del canale SocketChannel.read viene ritornato come numero di bytes letti un numero negativo significa che il flusso di dati è stato chiuso. Questa situazione denota normalmente il fatto che il peer remoto ha chiuso la comunicazione intenzionalmente: per questo motivo viene sollevata una eccezione specifica, derivata da CommException: è possibile intercettare la classe base se non si è interessati al fatto in se.

Altri formati

Quello scelto per il MasterMind è un formato davvero semplice ma questa sua semplicità è giustificata dal fatto che anche le informazioni scambiate dalla applicazione sono semplici e numericamente esigue.
Trascurando i formati specifici per particolari tipi di informazioni come le immagini, i suoni ed i video, uno degli standards più diffusi al mondo per contenere informazioni testuali come il messaggio MasterMind è il eXtensible Markup Language (lett. "linguaggio di marcatura estendibile"), abbreviato in XML.
Benchè XML sia effettivamente un linguaggio di markup davvero potente, la sua curva di apprendimento è difficile e lunga senza contare che ha bisogno di strumenti dedicati per il parsing e la validazione del testo. Una alternativa semplice e veloce ad XML è un nuovo standard chiamato JSON. Per chi volesse approfondire questo argomento, è disponibile un breve articolo in appendice (vedi JSON vs XML).

La classe ConnectionTCP

Un oggetto di classe ConnectionTCP si costruisce con i tre argomenti con cui si costruisce la sua classe base, il listener degli eventi, il player remoto e le proprietà della applicazione dalle quali si estraggono i parametri di rete. Le proprietà da leggere per una connessione TCP/IP vengono estratte nel costruttore e memorizzate in due membri dati:

  • serverAddr: l'ndirizzo IP (o il hostname) del server di gioco a cui il client deve connettersi
  • serverPort: la porta di ascolto del server di gioco o la porta a cui il client deve connettersi

Il vantaggio di avere scritto una classe generica Connection che si occupa di tutta la logica della connessione e del invio/ricezione dei messaggi, è che la la mera implementazione di un particolare protocollo di rete si riduce a pochi e semplici passi: la implementazione dei metodi astratti:

  • doConnect
  • doClose
  • doSendMessage
  • doReadMessage

Il metodo doConnect

In una connessione TCP/IP, quindi in una architettura client/server, il metodo doConnect è speciale nel senso che è necessario stabilire se siamo dal lato del server o del client: questo viene stabilito dalla proprietà serverAddr: se il valore di questo dato è null allora siamo dal lato del server mentre se invece contiene una stringa (un indirizzo IP oppure un hostname) allora siamo dal lato client.
Il resto del metodo è facile: a seconda del lato della connessione vengono richiamati i metodi privati corrispondenti: doConnectServer o doConnectClient Questi due metodi sono davvero di facile lettura; non hanno bisogno di ulteriori commenti.

Il metodo doClose

Che dire di questo metodo? Nulla, si commenta da se.

Il metodo doSendMessage

Anche questo metodo non avrebbe bisogno di commenti. L'unico commento che vale la pena scrivere è quanto sia facile inviare il messaggio usando i canali e buffers. L'aver organizzato il messaggio come buffer a lunghezza fissa contribuisce ulteriormente a rendere la operazione di invio del messaggio una cosa banale:

// File: ConnectionTCP.java
public int doSendMessage( Message msg ) throws MMException
{
... omissis ...
ByteBuffer buffer = messageToByteBuffer( msg, MESSAGE_LENGTH );
buffer.flip();
try {
while( buffer.hasRemaining()) {
channel.write(buffer);
}
}
catch( IOException ex )
... omissis ...
}

Il metodo doReadMessage

Anche questo metodo è facile da scrivere usando i canali ed i buffers di javaNIO:

public Message doReadMessage() throws MMException
{
... omissis ...
ByteBuffer buffer = ByteBuffer.allocate( MESSAGE_LENGTH );
try {
while( buffer.hasRemaining()) {
int r = channel.read( buffer );
if ( r < 0 ) {
throw new ConnectionLostException( "connetion closed by the peer" );
}
}
buffer.flip();
msg = messageFromByteBuffer( buffer );
}
... omissis ...
}

Una nota merita il costrutto che verifica se il numero di bytes letti dal canale è minore di ZERO: come anticipato poc'anzi, un valore negativo nel numero di bytes letti dal canale indica la "fine del flusso di dati" che, in una connessione TCP/IP, segnala che la macchina remota ha chiuso la connessione, forse intenzionalmente da parte dello user.
L'evento viene comunque segnalato dalle primitive Java sollevando una eccezione di tipo SocketException ma il mio consiglio è quello di usare una eccezione specifica per questo: ecco perchè ho implementato la eccezione ConnectionLostException che viene sollevata nella situazione descritta.

Il client MasterMind

La classe MasterMindClient viene istanziata nella classe principale se l'oggetto Connection è dal lato client della connessione cioè se il metodo isServer restituisce false. Il client mastermind sovrascrive il metodo astratto run della classe base; come ci si può aspettare, il client ha un metodo run completamente vuoto:

// File: MasterMindClient.java
public void run() throws MMException
{
assert logger != null : "MasterMindClient.run() logger is null";
logger.info( "MasterMindClient.run()" );
}

La classe masterMindClient sovrascrive anche i metodo haveGuess ed haveSolution: il compito del client è quello di inoltrare l'input del giocatore al server di gioco preparando i messaggi propedeutici e richiamando il metodo forwardMessage:

// File: MasterMindClient.java
protected void forwardMessage( Message msg )
{
logger.config( "forwardMessage():" + msg );
connection.sendMessage( msg );
}

Ulteriore documentazione

La documentazione completa del package descritto in questo capitolo può essere visualizzata clikkando il seguente link che riporta alla documentazione Javadoc del progetto: The MasterMind Project Version 0.8

Argomento precedente - Argomento successivo - Indice Generale