Java Tutorial - Parte 2 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Versione 0.8: il protocollo Mastermind

Introduzione

In questo capitolo e nei due successivi implementeremo la connessione remota di MasterMind e scriveremo le classi che gestiranno lo scambio dei messaggi tra i due giocatori umani remoti. Le classi che scriveremo in questo capitolo sono classi astratte, che non implementano un particolare protocollo di rete ma che sono la base per poter poi implementare i veri protocolli di rete con una certa facilità.

I nuovi files sorgente

I files sorgente delle classi che si occupano della connessione remoata si trovano nel package mastermind.net il quale possiede due sottopackages:

  • mastermind.net.impl che contiene le classi che rappresentano l'effettiva implementazione di un protocollo di rete
  • mastermind.net.udp che contiene le classi che implementano un particolare protocollo di rete, basato su IP, usato per la ricerca dei giocatori in una rete locale; questo argomento sarà affrontato in un capitolo a se stante: Version 0.9: la ricerca dei giocatori in rete
nome file package descrizione
MessageException.java mastermind.net messaggi invalidi o malformati
CommException.java mastermind.net errori sul canale di comunicazione
ConnectionLostException.java mastermind.net connessione chiusa dal remoto
Connection.java mastermind.net classe base astratta
ConnectionListener.java mastermind.net gestore degli eventi di comunicazione
ConnectionPanel.java mastermind.net pannello di avanzamento dello stato
Message.java mastermind.net il messaggio scambiato tra i giocatori
PlayerRemote.java mastermind.net il giocatore remoto
RecvQueue.java mastermind.net la coda di ricezione dei messaggi
SendQueue.java mastermind.net la coda di invio dei messaggi
ConnectionTCP.java mastermind.net.impl implementazione del protocollo TCP/IP
ConnectionTest.java mastermind.net.impl implementazione connessione di test
MasterMindClient.java mastermind.game il client mastermind
RecvWorker.java mastermind/test/tcp classe di supporto alla app ostile
TCPClient.java mastermind/test/tcp una app GUI ostile
TestMessage.java mastermind/test/tcp app CLI di test della classe Message
Main08.java mastermind.app classe principale versione 0.8

Considerato il numero piuttosto elevato su sorgenti, una breve panoramica è doverosa. Cominciamo con le classi eccezione:

  • MessageException: viene sollevata quando un messaggio ricevuto sul canale di comunicazione non rispetta il formato previsto dal protocollo oppure se le informazioni contenute sono incompatibili tra di loro
  • CommException: viene sollevata quando intervengono errori sul canale di comunicazione; queste eccezioni dovrebbero essere recuperabili tentando una nuova connessione col remoto
  • ConnectionLostException: derivata da CommException questa eccezione è un caso particolare di errore sul canale; viene sollevata quando la connessione viene chiusa, di solito intenzionalmente, dal remoto

Le classi che si occupano della connessione, ricezione ed invio dei messaggi sono le seguenti:

  • Connection.java: si tratta di una classe base astratta che fornisce la logica di tutte le operazioni prepedeutiche: apertura e chiusura del canale, invio e ricezione dei messaggi;
  • ConnectionListener.java: definisce la interfaccia del gestore di tutti gli eventi che scaturiscono dalla connessione; i metodi definiti nella interfaccia vengono richiamati dai threads separati usati nella classe Connection per non bloccare la GUI
  • Message.java: questa classe definisce la formalità ed il contenuto di un messaggio Mastermind e definisce i metodi per ottenere le informazioni dal messaggio
  • PlayerRemote.java: il tipo di giocatore remoto viene rappresentato da questa classe, derivata da Player che sovrascrive i metodi di notifica; unico scopo di questa classe è quello di inoltrare i messaggi di notifica provenienti dal server sul canale di comunicazione
  • SendQueue.java: questa classe implementa la coda di invio dei messaggi sul canale di comunicazione; la coda è una ottima tecnica per l'invio di messaggi su un canale di comunicazione; essa risolve una serie di problemi che avremo modo di affrontare
  • RecvQueue.java: questa è la coda di ricezione dei messaggi anche se nella app Mastermind una coda in ricezione non è affatto necessaria

Come accennato in precedenza, le classi che fanno parte del package mastermind.net sono classi generiche, che gestiscono la logica della comunicazione ma non implementano alcun protocollo di rete reale. La effettiva comunicazione tra i due giocatori remoti viene realizzata da classi specializzate, derivate da Connection; il compito di queste classi specializzate è quello di implementare un vero e proprio protocollo di rete. Al momento vi sono solo due implementazioni di protocolli di rete, entrambe fanno parte del package mastermind.net.impl (impl sta per implementations)

  • ConnectionTCP: questa classe implementa il protocollo TCP/IP
  • ConnectionTest: questa classe implementa un protocollo fittizio in grado di simulare una vera connessione ma che invece viene usata per il test del protocollo applicativo

Infine, il package mastermind.test.tcp contiene una applicazione malevola, ostile scritta al solo scopo di mettere sotto stress il server di gioco e cercare di mandarlo in crisi; vogliamo verificare come reagisce il server a questi attacchi.

Il protocollo applicativo

Prima di scrivere le classi che implementeranno la comunicazione tra giocatori remoti è necessario stabilire un protocollo di comunicazione, cioè quali messaggi e di che tipo le applicazioni dovranno scambiarsi. Abbiamo già incontrato un primo semplice esempio di protocollo applicativo nel capitolo Un piccolo telnet - il protocollo.
Possiamo suddividere i messaggi scambiati dalla applicazione MasterMind in tre categorie:

  • servizio: sono i messaggi di servizio e/o utilità generale
  • input: sono i messaggi che trasportano i dati di input del singolo giocatore cioè le sequenze
  • notify: sono i messaggi tramite i quali il server di gioco notifica al giocatore remoto gli eventi della partita

Messaggi di servizio

Tra i messaggi di servizio nel Mastermind includiamo il messaggio di presentazione nel quale i due giocatori si presentano l'un l'altro. In questo messaggio sono contenute le seguenti informazioni:

  • il nome del giocatore
  • il numero di versione della applicazione usata

Un secondo messaggio di servizio è il cosidetto messaggio ECHO: si tratta di un messaggio in cui una delle due parti invia al remoto il timestamp di sistema, normalmente espresso in millisecondi. Questo messaggio sarà chiamato ECHO-REQUEST.
La controparte risponde a questo messaggio inviando un messaggio ECHO-RESPONSE che contiene lo stesso timestamp contenuto nella ECHO-REQUEST: calcolando la differenza tra il timestamp del messaggio ed il timestamp di ricezione della risposta si può ottenere la latenza della connessione cioè il ritardo in millisecondi introdotto dalla rete.

Il nome ECHO che ho dato a questo particolare tipo di messaggio deriva dal protocollo IP che prevede un particolare tipo di pacchetto denominato ICMP (Internet Control Message Protocol = protocollo dei messaggi internet di controllo) che ha proprio lo stesso scopo: calcolare la latenza della rete. Lo scambio del messaggio ECHO avviene proprio come un vero eco: il destinatario restituisce al mittente lo stesso identico pacchetto ricevuto.
L'eco viene usato, per esempio, per individuare i sottomarini in immersione grazie ad uno strumento chiamato sonar che produce quel caratteristico rumore che si sente spesso nei film del tipo "Caccia ad Ottobre Rosso": ping. Non è quindi un caso che il comando da usare per inviare i pacchetti ICMP-ECHO ad un qualsiasi host sulla rete sia proprio ping:

>ping 10.253.175.44

Esecuzione di Ping 10.253.175.44 con 32 byte di dati:
Risposta da 10.253.175.44: byte=32 durata=4ms TTL=64
Risposta da 10.253.175.44: byte=32 durata=4ms TTL=64
Risposta da 10.253.175.44: byte=32 durata=4ms TTL=64
Risposta da 10.253.175.44: byte=32 durata=4ms TTL=64

Statistiche Ping per 10.253.175.44:
    Pacchetti: Trasmessi = 4, Ricevuti = 4,
    Persi = 0 (0% persi),
Tempo approssimativo percorsi andata/ritorno in millisecondi:
    Minimo = 4ms, Massimo =  4ms, Medio =  4ms

Messaggi di notifica

Tutti i messaggi di notifica provengono dal server e sono inviati al client che li deve visualizzare nella GUI. Questi messaggi descrivono gli eventi del gioco e sono già stati ampiamente commentati in La classe base del gioco. Un messaggio inviato al remoto deve contenere come dati gli argomenti forniti al metodo di notifica.

Messaggi di input

Vi sono due messaggi di questo tipo:

  • la risposta del giocatore con la solution: questo messaggio deve essere inviato dal client quando viene ricevuta la notifica newGame
  • la risposta del giocatore con una guess: questo messaggio deve essere inviato dal client quando viene ricevuta la notifica swapTurn ed il turno attuale è quello del giocatore

Tabella dei tipi di messaggio

Riassumendo, la tabella dei tipi di messaggio scambiati da Mastermind è la seguente:

codice categoria metodo field1 field2 field3
101 service echoRequest timestamp
102 service echoResponse timestamp
103 service presentation nickname version
202 input haveSolution turn solution
204 input haveGuess turn guess
301 notify newGame params
302 notify startGame turn
304 notify swapTurn turn tryCount
305 notify haveResults turn guess results
306 notify endGame winner resign
307 notify solution turn solution
308 notify byeBye playAgain
312 notify error turn errorCode errorDescr

La classe Message

La classe Message rappresenta un messaggio scambiato sulla rete dalla applicazione. Essa possiede i seguenti membri dati:

  • type è il codice del messaggio come da tabella precedente
  • id un identificativo unico del messaggio; si tratta di un contatore progressivo che parte da 10 ed arriva a 99999; non ha molta importanza e, al momento attuale, il suo unico scopo è quello di marcare un messaggio come malformato se questo ID è negativo
  • fields è una array di quattro elementi stringa che contengono le informazioni che dipendono dal tipo di messaggio, come descritto nella tabella di cui sopra

La classe Message possiede quattro campi di informazioni anche se, come vediamo dalla tabella, ne basterebbero tre: si tratta di una ridondanza che ho previsto, una specie di espansione per futuri utilizzi.

I costruttori

La classe possiede quattro costruttori:

  • Message(): il costruttore di default è protetto poichè viene usato dagli altri costruttori per inizializzare i membri dati interni: in effetti, un messaggio senza il suo codice non ha molto senso
  • Message(int type): costruisce un messaggio del tipo specificato nell'argomento: vi è da osservare che sarà comunque necessario impostare i campi del messaggio con le informazioni specifiche per il tipo di messaggio come evidenziato nella tabella di cui sopra; fà eccezione a questa regola il tipo 101 (echo-request) : in questo caso il costruttore inserisce nel primo campo il timestamp in millisecondi rendendo di fatto il messaggio completo
  • Message(int type, int id): costruisce un messaggio del tipo e con la ID specificati negli argomenti: anche in questo caso vale quanto scritto poc'anzi a proposito del tipo di messaggio; per quanto riguarda la ID, ogni messaggio viene costruito con una ID univoca ma questo costruttore può essere usato per impostare una ID specifica, se necessario
  • Message(Message msg): questo particolare costruttore accetta come argomento un messaggio di tipo 101 (=echo-request) e costruisce un messaggio di tipo 102 (=echo-response)

La classe Message possiede molti metodi per impostare ed ottenere informazioni dai campi del messaggio oltre a metodi di servizio.

I metodi di servizio

I metodi di servizio sono i seguenti:

  • getID(): ritorna l'identificativo del messaggio
  • getType(): ritorna il tipo di messaggio
  • toString(): ritorna una stringa che descrive il messaggio

I metodi setter

Come accenntato in precedenza, per costruire un messaggio è necessario specificarne il tipo ma questo non basta per considerare il messaggio completo: è anche necessario impostare correttamente i suoi campi in modo che contengano le informazioni propeduetiche al tipo di messaggio.
Per impostare i campi del messaggio si usa il metodo setField che è sovrascritto in modo da accettare vari tipi di dato in input:

  • setField(int numField, boolean value)
  • setField(int numField, int value)
  • setField(int numField, String str)

Per sempio, per costruire un messaggio di presentazione, si dovrà eseguire un codice simile a questo:

Message msg = new Message( 103 );
msg.setField( 1, nickname ) // campo 1
msg.setField( 2, version.asInt()); // campo 2

Il metodo setField può sollevare la eccezione IndexOutOfBoundsException se l'argomento numField è fuori dal range permesso che è tra 1 e 4.

I metodi getter

I metodi getter consentono di ottenere le informazioni da un oggetto di classe Message; esistono molti metodi getter specializzati per ottenere un certo tipo di dato. Tutti i metodi getter accettano un solo argomento: l'indice del campo del messaggio da dove estrarre la informazione. Questo indice va da 1 a 4.

  • getField(int numField): questo è il metodo base che restituisce una stringa dal momento che ogni campo del messaggio è una stringa; questo è l'unico metodo getter che solleva una eccezione IndexOutOfBoundsException nel caso l'indice del campo è fuori range (1 .. 4)
  • getSequence(int numField): è simile al metodo precedente in quanto ritorna la stringa della sequenza me solleva una eccezione MessageException in caso di errori ossia se l'indice del campo è fuori range (1 .. 4)
  • getBoolean(int numField): restituisce un booleano leggendo il campo fornito come argomento
  • getInt(int numField): restituisce un intero leggendo il campo fornito come argomento
  • getParams(int numField): restituisce un oggetto di classe Params leggendo il campo fornito come argomento
  • getResults(int numField): restituisce un oggetto di classe Results leggendo il campo fornito come argomento
  • getVersion(int numField): restituisce un oggetto di classe Version leggendo il campo fornito come argomento

Ad eccezione del primo metodo getter, tutti gli altri sollevano eccezioni di tipo MessageException in caso di errori; vi possono essere due cause di errore nella lettura di un messagio:

  • la prima causa di errore è la IndexOutOfBoundsException nel caso l'indice del campo da leggere sia fuori dal range permesso (1 .. 4)
  • la seconda causa di errore è che la stringa letta dal campo del messaggio non è compatibile con il tipo di dato da ritornare: un booleano, un intero, un oggetto di classe Params, Results o Version

E' abbastanza ovvio che eccezioni di tipo MessageException non dovrebbero mai essere sollevate nel corso del programma: se dovessero presentarsi significa che c'è un bug nel codice dal momento che tutti i messaggi vengono creati dal codice Java e quindi un errore nel formato delle informazioni o un errore nel indicare il numero del campo dove ottenere l'informazione è sicuramente un bug.

Il giocatore remoto

Derivata da Player, la classe PlayerRemote rappresenta un giocatore remoto per la macchina locale: esso viene costruito con un unico argomento: il turno di gioco. Oltre ai membri dati definiti nella classe base (vedi I giocatori del Mastermind) questa classe aggiunge un solo membro dati:

private Connection connection;

che rappresenta l'oggetto connessione su cui il giocatore remoto si appoggia per l'inoltro dei messaggi di notifica provenienti dal server (vedi Il diagramma di flusso). Nella parte client, di contro, l'oggetto PlayerRemote non è chiamato in causa: in teoria potrebbe esserlo per l'input delle sequenze del giocatore lato client ma l'input viene gestito dalla GUI e non è affatto necessario passare per l'oggetto giocatore. La classe del giocatore remoto sovrascrive quasi tutti i metodi della classe base:

I metodi astratti

Dal momento che i metodi astratti devono essere obbligatoriamente sovrascritti, la classe PlayerRemote li sovrascrive tutti non essendo essa stessa una classe astratta:

  • getGuess e getSolution: le sequenze non vengono elaborate dalla classe PlayerRemote ma digitate dal giocatore umano che sta dalla altra parte della connessione; questi metodi ritornano semplicemente null.
  • getType: ritorna Player.Type.REMOTE
  • isLocal: ritorna sempre false
  • isConnected: ritorna true solo se il membro dati connection non è null e il metodo Connection.isConnected ritorna true.

Metodi di servizio

  • getConnection: ritorna l'oggetto connessione collegato a questo giocatore remoto; l'oggetto connessione viene istanziato quando i due giocatori iniziano la prima partita e rimane in essere fino al termine della applicazione.
  • setConnection: imposta l'oggetto connessione collegato a questo giocatore remoto; poichè l'oggetto connessione persiste per tutta la durata della applicazione questo metodo viene richiamato quando la connessione è stata stabilita con successo.

Vi è da osservare che la connessione di rete può cadere e deve essere ristabilita. Tuttavia in questo caso l'oggetto connessione istanziato la prima volta non viene dereferenziato ma si usa sempre lo stesso oggetto per tentare la riconnessione con gli stessi parametri. In ogni caso, l'oggetto connessione passato a PlayerRemote è quello che ha stabilito (o ri-stabilito) una connessione con successo.

I metodi di notifica

Tutti i metodi di notifica provenienti dal server di gioco vengono inviati alla macchina remota attraverso l'oggetto connessione il cui riferimento è memorizzato nel membro dati connection.

  • notifyEndGame
  • notifyHaveGuess
  • notifyHaveResults
  • notifyHaveSolution
  • notifyNewGame
  • notifySolution
  • notifyStartGame
  • notifySwapTurn

I metodi richiamano una delle versioni sovraccaricate del metodo di I/O sendNotify per eseguire la operazione. Per esempio, questo è il codice del metodo notifyNewGame che invia un messaggio di tipo 301 con i parametri di gioco nel primo campo della classe Message:

// File: PlayerRemote.java
@Override
public void notifyNewGame( Params params, InputListener listener )
{
super.notifyNewGame( params, listener );
sendNotify( 301, params.toString());
}

Notate che la prima istruzione del metodo è quella di richiamare la versione base del metodo; questo è assolutamnete necessario poichè nel codice del metodo della versione base vi sono importanti elaborazioni.

Il metodo sendNotify

Poichè un messaggio può essere composto da uno, due o tre campi, a seconda del tipo di messaggio, esistono tre versioni sovraccaricate di questo metodo per pura convenienza:

  • void sendNotify(int type, String field1): invia un messaggio di notifica che contiene un solo campo significativo
  • void sendNotify(int type, String field1, String field2): invia un messaggio di notifica che contiene due campi significativi
  • void sendNotify(int type, String[] fields): invia un messaggio di notifica composto da tre o più campi significativi

Vi è da osservare che le prime due versioni del metodo sendNotify si appoggiano alla terza versione: essi allocano semplicemente una array di uno o due elementi che conterranno gli argomenti al metodo e poi richiamano la terza versione che crea una istanza della classe Message e la invia:

// File: PlayerRemote.java
public void sendNotify( int type, String[] fields )
{
Message msg = new Message( type );
for ( int i=0; i < fields.length; i++ ) {
msg.setField( i+1, fields[i] );
}
sendMessage( msg );
}

Il metodo sendMessage

Il metodo sendMessage è piuttosto semplice: non fa altro che verificare che l'argomento ed il membro dati connection non siano null e richiama il metodo Connection.sendMessage il quale provvederà all'effettivo invio del messaggio.

// File: PlayerRemote.java
public int sendMessage( Message msg )
{
assert msg != null : "PlayerRemote.sendMsg() msg is null";
logger.finer( "sendMessage() msg: " + msg.getID());
assert connection != null : "PlayerRemote.sendMessage() connection is null";
msgId = connection.sendMessage( msg );
return msgId;
}

Noterete la assenza di una clausola throw nonostante sappiamo che le primitive di I/O possono sollevare eccezioni di cui una di tipo checked: la IOException.
Non è una dimenticanza ma una precisa scelta strategica: gli eventi che riguardano le operazioni di I/O sulla connessione vengono notificati attraverso un listener (vedi Il listener degli eventi). Il metodo sendMessage della connessione non esegue veramente l'invio del messaggio ma si limita ad accodare il messaggio nella coda di invio; una volta richiamato il metodo sendMessage ci si può disinteressare del suo esito; il messaggio sarà recapitato oppure saranno notificati degli errori al listener degli eventi.
Quello che conta è che il metodo sendMessage, eseguito nel EDT, ritorni immediatamente, in modo che la GUI non si blocchi.

Il metodo readMessage

Il metodo readMessage si comporta praticamente allo stesso modo. Non solleva eccezioni, questo è vero, ma allo stesso tempo ritorna sempre null poichè anche il metodo Connection.readMessage ritorna null.

// File: PlayerRemote.java
public Message readMessage()
{
logger.finer( "readMessage() for player: " + toString());
assert connection != null : "PlayerRemote.readMessage connection is null";
Message msg = connection.readMessage();
return msg;
}

La classe Connection

Si tratta di una classe base astratta che dichiara i metodi propedeutici per compiere le operazioni necessarie alla connessione ed allo scambio dei messaggi tra le due copie della applicazione, quella locale e quella remota. Vi sono sostanzialmente sei operazioni da compiere ed ad esse sono dedicati sei metodi specifici:

  • connect: che avvia la connessione remota
  • reconnect: che tenta la ri-connessione in caso la connect sia fallita
  • recover: che tenta di chiudere e riaprire la connessione in caso di errori sul canale di trasmissione
  • sendMessage: che invia il messaggio fornito come argomento
  • readMessage: che legge un messaggio dal canale
  • close: che chiude la connessione

Nelle prossime sotto-sezioni analizzeremo tutti questi metodi.

I membri dati

I membri dati della classe Connection sono dichiarati in modo da dare accesso alle classi derivate:

Lo stato della connessione

Nella classe base astratta viene definito un enumeratore che rappresenta lo stato della connessione:

// File: Connection.java
public enum State {
NOT_CONNECTED,
IN_PROGRESS,
CONNECTED,
ABORTED,
ERRORS,
TERMINATED
};

Concordo con voi che forse gli stati enumerati sono ridondanti; in sostanza quello che interessa davvero è solo se la connessione è attiva oppure no. Per esempio, che differenza c'è tra NOT_CONNECTED e TERMINATED: se vogliamo cercare il pelo nell'uovo si potrebbe intendere che

  • NOT_CONNECTED è lo stato iniziale, quando non si è nemmeno tentata una connessione
  • TERMINATED è lo stato in cui, dopo una connessione attiva, essa è stata chiusa dall'utente

La operazione da eseguire

Nella classe Connection è definito un enumeratore che ha il compito di rappresentare quale operazione di I/O è in corso di esecuzione; viene usata per segnalare gli errori sul canale:

// File: Connection.java
public enum IOperation {
CONNECT,
RECONNECT,
CLOSE,
SEND,
RECEIVE
};

I costruttori

La classe Connection possiede un solo costruttore al quale devono essere passati tre argomenti:

  • ConnectionListener: il listener degli eventi che scaturiscono dalla connessione; il listener sarà la applicazione principale definita dalla classe Main08.
  • Player il giocatore remoto; questo è necessario perchè quando la connessione sarà stabilita con successo, alla classe PlayerRemote và notificato quale oggetto connessione deve usare per l'inoltro delle notifiche provenienti dal server di gioco
  • MMProperties: le proprietà della applicazione; esse contengono i parametri di rete necessari a stabilire la connessione

Nel costruttore si verifica che l'oggetto passato come argomento player sia effettivamente una istanza di PlayerRemote. In difetto, sarebbe un bug enorme, inutile continuare il programma.

I metodi astratti

Oltre a quelli elencati di seguito, ci sono altri metodi astratti che però saranno commentati in seguito, quando affronteremo i quattro metodi di I/O dei quali ho accennato all'inizio di questa sezione.

  • getRemoteAddr: restituisce una stringa che descrive la macchina remota; questa stringa dipende dal protocollo usato nella connessione; per esempio, nel protocollo IP essa sarà l'indirizzo IP o il nomehost del computer remoto
  • isServer: il metodo ritorna TRUE se questo oggetto è il lato server della connessione; in una connessione TCP/IP vi è sempre un lato server ed un lato client di una connessione. In altri protocolli di comunicazione potrebbe non esistere questa differenza ma sarà compito del programmatore stabilire comunque un lato server fittizio: questo è necessario per stabilire quale delle due istanze di Mastermind funge da server di gioco

I metodi concreti

I metodi concreti dell'oggetto Connection gestiscono quegli aspetti non legati ad un particolare protocollo e sono, per lo più, quelli che restituiscono i dati interni della classe, i cosidetti metodi getter:

  • getState: ritorna l'enumerato già descritto in Lo stato della connessione che rappresenta lo stato della connessione
  • isConnected: ritorna TRUE se lo stato della connessione è State.CONNECTED
  • getRemotePlayer: ritorna l'oggetto di classe PlayerRemote associato a questo oggetto connessione
  • getListener e setListener: questi metodi restituiscono ed impostano il listener degli eventi della connessione; questo aspetto verrà approfondito nella sezione Il listener degli eventi

I metodi di I/O della connessione

Come accennato in La classe Connection vi sono sei metodi che eseguono le operazioni di input e output vero e proprio in una connessione:

  • connect: che avvia la connessione remota
  • sendMessage: che invia il messaggio fornito come argomento
  • readMessage: che restituisce il messaggio ricevuto dal remoto
  • close: che chiude la connessione
  • reconnect: che tenta la ri-connessione
  • recover: che prima di tentare una ri-connessione, chiude quella in corso: questo metodo richiama prima close e poi reconnect

Il metodo reconnect differisce da connect perchè nel protocollo Mastermind alla prima connessione i due giocatori si scambiano un messaggio di presentazione. Tuttavia, la riconnessione è una operazione da fare quando la connessione viene persa durante il gioco: non è necessario presentarsi nuovamente anzi, è una operazione da evitare.
Tutti questi metodi di I/O eseguono le operazioni in un thread separato istanziando un oggetto di classe SwingWorker: nel metodo in background viene eseguita la operazione di I/O effettiva mentre nel metodo done viene richiamato il metodo di comodo del listener.

Il metodo connect

Prendiamo come esempio il metodo connect, ma tutti gli altri sono molto simili:

// File: Connection.java
public void connect()
{
connectionStarted( "Connecting ... " + getRemoteAddr());
checkIfInProgress( workerConnect );
workerConnect = new SwingWorker<String, Void>() {
@Override
public String doInBackground() throws MMException
{
String addr = doConnect();
return addr;
}
@Override
public void done()
{
try {
String addr = get();
connectionEstablished( addr );
}
catch (InterruptedException | CancellationException ignore )
{
}
catch (ExecutionException ex )
{
connectionError( IOperation.CONNECT, (MMException) ex.getCause());
}
}
};
workerConnect.execute();
}

Come prima operazione il metodo connect verifica che non ci sia già in esecuzione un worker-thread che si occupa della connessione; dobbiamo evitare il proliferare dei worker-threads. Nel metodo eseguito in background, viene richiamato il metodo doConnect: questo è il metodo che esegue la vera e propria operazione di input/output sul canale di comunicazione. Questo aspetto sarà discusso in I metodi astratti di I/O.
Una volta conclusa la connessione, il metodo done richiama il listener degli eventi informando il thread principale che la connessione è stata stabilita con successo.

Il metodo sendMessage

Il metodo sendMessage potrebbe essere scritto allo stesso modo: si crea un worker-thread separato nel cui metodo doInBackground si richiama il doSendMessage: facile come bere un bicchier d'acqua. Stessa cosa per il metodo readMessage, no? Ebbene, benchè questa soluzione sia davvero di facile implementazione è pessima: soffre di problemi piuttosto severi non facili da risolvere.
Possono essere risolti, questo è vero, ma al prezzo di introdurre complessità al codice che è quello che invece la soluzione dovrebbe evitare. Per questo motivo l'invio e la ricezione dei messaggi sono stati implementati con una tecnica totalmente diversa; le code. Nel capitolo Versione 0.8: le code di invio e ricezione descriverò questa soluzione che, al prezzo di un piccolo sforzo di codifica, permette di superare i problemi elegantemente ed efficacemente.

I metodi astratti di I/O

Avrete notato che i metodi di I/O della classe Connection NON eseguono la vera operazione ma richiamano un metodo astratto il cui nome è uguale al metodo di I/O preceduto da "do". Abbiamo quindi quattro metodi astratti definiti nella classe Connection:

  • doConnect
  • doSendMessage
  • doReadMessage
  • doClose

Questi quattro metodi astratti, che devono essere definiti in una classe derivata, sono i veri responsabili della effettiva operazione di input / output. Con questa organizzazione, è facile implementare protocolli di rete diversi: basta derivare da Connection ed implementare i quattro metodi astratti.
Ed è proprio quello che avviene in Mastermind: anche se per il momento viene implementato il solo protocollo TCP/IP, aggiungere altri protocolli risulta abbastanza facile.

Il listener degli eventi

Abbiamo imparato in Un piccolo telnet che la implementazione di una trasmssione e di un protcollo applicativo non è particolarmente difficile da scrivere. La applicazione telnet funziona piuttosto bene ma soffre di un problema: tutte le operazioni che riguardano l'input e output sono bloccanti: il server deve attendere un comando dal client per poter continuare la elaborazione e nella attesa non può fare nient'altro!
Dall'altro lato, il client deve attendere la risposta del server per poter continuare la elaborazione ed accettare un ulteriore comando dal utente. Per il telnet questo non costituisce un serio problema specialmente dal lato client: inutile impartire un altro comando se non abbiamo avuto la risposta di quello precedente.
Diverso il discorso per il lato server: in attesa di input sulla connessione, il server non può fare nulla, nemmeno accettare una nuova richiesta di connessione da un secondo client e questo, per un server degno di questo nome, sarebbe inaccettabile.

Ma torniamo a MasterMind: in un gioco basato sui turni, il fatto che lo scambio dei messaggi sia bloccante non è nemmeno così scandaloso: il giocatore che non è in turno non può fare altro che aspettare che l'avversario abbia inviato la sua guess, la ipotesi di soluzione, prima di poter continuare.
Tuttavia, questa è una applicazione GUI e non è accettabile per l'utente che la interfaccia si congeli e diventi non responsiva: egli potrebbe volersi arrendere anche se non è in turno quando capisce che oramai l'avversario è molto vicino alla soluzione.
Per questo motivo implementeremo una soluzione che nonostante il gioco sia basato sui turni, non blocchi del tutto la GUI e dia la possibilità al giocatore non in turno di interagire comunque con la interfaccia grafica. Ovviamente, egli non potrà inviare una guess al server se non è in turno ma potrebbe comunque prepararla clikkando sui bottoni di comando.

input / output non bloccante

Per ottenere una comunicazione di rete non-bloccante (ma il discorso si applica a tutte le operazioni di input / output, non solo alle reti) vi sono sostanzialmente due strade:

  • Java mette a disposizione del programmatore una libreria alternativa a quella vista in Un piccolo telnet che si chiama JavaNIO: non è specializzata nelle comunicazione in rete ma gestisce tutto l'I/O non-bloccante in un unico thread attraverso il concetto dei selettori: si possono aprire quanti canali di I/O si desidera e questi vengono registrati in un SelectorProvider; quando i dati sono disponibili per la lettura e/o la scrittura il SelectorProvider "seleziona" il canale e ne richiama il codice che esegue la operazione
  • le operazioni di I/O sui canali (nel nostro caso la trasmissione in rete) rimangono operazioni bloccanti ma vengono eseguite in un thread separato lasciando il Event Dispathcing Thread (EDT) libero di gestire la GUI

Quale dei due approcci è il migliore? Come ribadito più e più volte nel corso di questo tutorial, non esiste l'approccio migliore in assoluto: entrambi hanno vantaggi e svantaggi e tutto dipende dal tipo di applicazione.

Il primo approccio ha il vantaggio di eseguire in un unico thread: prendiamo ad esempio un server web che può servire potenzialmente migliaia o decine di migliaia di connessioni che, per la natura del protocollo HTTP, durano pochissimo tempo: creare un thread separato per ogni connessione ha un costo non indifferente sia in termini di tempo che di consumo di memoria e quindi è conveniente usare i selettori in questo contesto. Lo svantaggio di questa soluzione è che la gestione dei selettori non è banale ed il codice necessario è piuttosto complesso; è molto più facile creare un thread separato, specialmente usando Java, ricordate? (vedi La classe SwingWorker).

Il secondo approccio ha il vantaggio della semplicità di gestione e della stesura del codice ma, di contro, è molto più avido di risorse. Per una applicazione che instaura una sola connessione di rete durante tutta la sua esecuzione e che scambia messaggi solo nel turno di gioco, il costo della creazione del thread di I/O è irrisorio. Quindi questa è la strategia migliore per MasterMind ed è quella che useremo.

La interfaccia ConnectionListener

In un approccio non-bloccante per le operazioni di I/O dobbiamo rivedere tutte le nostre strategie che abbiamo imparato nel capitolo precedente. Prendiamo come punto di partenza il codice del client telnet discusso in Il blocco try-catch, che, per comodità, riassumo brevemente di seguito:

try ( resources )
{
... omissis ...
// continua in un loop infinito, la chiusura della connessione
// viene rilevata da una eccezione SocketException
while (true ) {
command = stdIn.readLine();
out.println(command); // <------- I/O sul canale
printResponse( in );
}
}
catch ( ... omissis ...

la riga marcata "I/O sul canale" mostra l'operazione bloccante: quando il metodo rientra abbiamo due certezze, una alternativa all'altra:

  • il messaggio è stato inviato
  • sono state sollevate eccezioni quindi la trasmissione non è andata a buon fine

Nel primo caso, il comando è stato inviato al server e la riga successiva attende e stampa la risposta del server: il metodo printResponse legge la risposta del server in modo bloccante: solo quando la risposta è completa essa viene stampata sul terminale.

In un approccio non-bloccante, invece, il metodo che esegue la operazione di I/O rientra immediatamente ma non è assolutamente possibile stabilire se l'operazione è andata a buon fine oppure no. Ma non solo: anche se il comando sarà inviato con successo al remoto non sappiamo quando l'operazione sarà conclusa nè sappiamo quando sarà disponibile una risposta.
Questo perchè la effettiva trasmissione del messaggio è posticipata ed affidata al thread separato la cui esecuzione è asincrona: non è possibile stabilire quando la operazione sarà completata. Tuttavia, la applicazione non può ignorare lo stato della trasmissione: e se ci sono stati errori?

La soluzione a questo problema è quella di dichiarare dei metodi che il thread separato di I/O richiami quando le operazioni sono concluse: questi metodi saranno eseguiti nel thread principale. In molti linguaggi simili metodi vengono chiamati callback methods (=metodi callback).
In quale classe dovranno essere definiti questi metodi? Nella app principale (la classe Main)? O forse nel oggetto game (la classe MasterMind)? Risposta: non dobbiamo deciderlo adesso anzi, lasciamo aperte tutte le porte: qualunque classe può gestire gli eventi asincroni di I/O purchè implementi la interfaccia ConnectionListener.
I metodi dichiarati dalla interfaccia sono questi:

  • connectionAborted: la connessione è stata interrotta prima ancora di essere stata stabilita con successo
  • connectionError: le operazioni di I/O (connect, send e read) hanno sollevato una eccezione di tipo MMException la quale contiene come cause la effettiva eccezione sollevata dai metodi di I/O
  • connectionEstablished: viene richiamato quando la connessione è stata stabilita con successo.
  • connectionClosed: viene richiamato quando la connessione viene chiusa
  • connectionStarted: viene richiamato all'inzio della operazione di connessione dai metodi connect(), reconnect() e recover()
  • messageReceived: un messaggio completo è stato ricevuto sul canale
  • messageSent: un messaggio è stato inviato con successo sul canale
  • reconnected: viene richiamato quando la connessione è stata ristabilita con successo dal metodo reconnect e recover.

I threads che eseguono le operazioni di I/O non richiamano direttamente i metodi della classe che implementa la interfaccia ma, invece, richiamano i metodi di comodo della classe Connection stessa; saranno questi metodi di comodo che richiameranno quelli della interfaccia; questo è necessario perchè la classe Connection deve aggiornare il suo stato interno in risposta agli eventi occorsi sul canale di trasmissione.
Pensiamo per esempio al evento connectionEstablished: oltre ad aggiornare il membro dati state ed impostarlo a CONNECTED, il metodo di comodo richiama il PlayerRemote.setConnection in modo che l'oggetto Player possa inoltrare i messaggi di notifica provenienti dal server di gioco alla connessione.
I metodi di comodo della classe Connection hanno lo stesso nome di quelli della interfaccia ConnectionListener. Di seguito è riportato il codice di uno dei metodo di comodo. Notate che, alla fine del metodo, viene richiamato l'omonimo metodo del listener degli eventi:

// File: Connection.java
public void connectionEstablished( String addr )
{
logger.fine( "connectionEstablished() remote: " + remote );
state = State.CONNECTED;
assert( remote != null );
remote.setConnection( this );
readMessage(); // inizia a leggere i messaggi
if ( listener != null ) {
listener.connectionEstablished( this, addr );
}
}

Il listener degli eventi

La classe che implementa la interfaccia Connectionlistener è la applicazione principale e cioè il Main08. Essa riceve i messaggi e gli eventi dal oggetto connessione e li smista al oggetto deputato ad elaborarli: il pannello dello stato di avanzamento se siamo nella fase di connessione e presentazione; l'oggetto game se siamo in una partita in corso.
I metodi sono di facile lettura, unica nota merita il metodo connectionEstablished il quale, oltre a visualizzare i messaggi informativi sul pannello dello stato di avanzamento della connessione provvede ad inviare il messaggio ECHO-REQUEST.

Anche il metodo connectionError merita un commento: esso visualizza l'errore nel pannello di stato e abilita il bottone "Retry" sul pannello stesso in modo che l'user abbia la possibilità di ritentare con gli stessi parametri scelti in precedenza.

A proposito di errori di connessione, essi possono accadere molto più frequentemente di quanto si possa immaginare. Prendiamo il caso in cui un client tenta di connettersi al lato server del gioco ma il giocatore server non ha ancora iniziato la propria parte di connessione; non essendoci alcun server in ascolto sulla porta specificata nei parametri di connessione, il client otterrà un messaggio di errore che sarà visualizzato nel pannello della connessione:

Connection refused: connect

Per risolvere l'empasse è sufficiente che il giocatore lato server inizi le operazioni di connessione clikkando il bottone Play. Ora che il server è in ascolto, il client potrà connettersi correttamente clikkando il bottone "Retry".

Il pannello della connessione

Se c'è una cosa che fà imbufalire gli user è quella di non sapere cosa sta facendo la applicazione; può accadere che per stabilire la connessione sia necessario un pò di tempo e non è piacevole per lo user vedere lo splashscreen o il pannello delle proprietà per troppo tempo ignaro del fatto che la applicazione sta tentando di connettersi al remoto. Molte applicazioni (specialmente quelle professionali) inviano un feedback allo user attraverso messaggi sulla barra di stato ma, nel Mastermind la barra di stato è disponibile solo quando una partita è cominciata.

Per ovviare a questo problema ho disegnato un pannello della connessione implementato dalla classe ConnectionPanel che viene visualizzato nel frame subito dopo che l'user clikka il bottone Play. In questo pannello saranno fornite le informazioni sullo stato di avanzamento della connessione. Questo è un esempio di output del pannello connessione in versione definitiva:

Connecting ... ConnectionTEST
Connected to: ConnectionTEST
Sending echo-request message
Estimated latency: 155 ms
Sending presentation message
Remote nickname: ConnTEST
Local  version: 0.8.0
Remote version: 0.8.0

I messaggi visualizzati sul pannello della connessione sono di facile comprensione ma un breve commento lo meritano:

  • la prima riga informa il giocatore sull'indirizzo del remoto a cui si sta cercando di connettersi; questo output avviene nel metodo di interfaccia connectionStarted
  • la seconda riga informa il giocatore che la connessione è avvenuta con successo: questo output avviene nel metodo di interfaccia connectionEstablished
  • la terza riga informa che è stato inviato un messaggio di tipo ECHO-REQUEST
  • la quarta riga viene visualizzata quando sulla connessione viene ricevuto il messaggio ECHO-RESPONSE e si può calcolare la latenza della rete
  • nella quinta riga si informa che è stato inviato il messaggio di presentazione
  • nelle ultime righe vengono visualizzate le informazioni ottenute dal messaggio di presentazione del giocatore remoto

Il pannello contiene tre bottoni di comando:

  • Abort: se l'user non vuole più connettersi oppure se, anche dopo la connessione, non vuole più giocare
  • Start: abilitato solo se la connessione è stata stabilita con successo, questo comando provoca l'inizio della prima partita
  • Retry: abilitato solo se la connessione non è andata a buon fine, questo comando tenta la riconnessione con gli stessi parametri selezionati in precedenza

Ulteriore documentazione

La documentazione completa dei due packages descritti 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