MokaByte Numero 18 - Aprile 1998
Java  Relay Chat
II parte
di
Michele Sciabarrà
Primo appuntamento di una miniserie che ci porterà alla realizzazione di una chat in Java


 



 

Lo sviluppo di JRC comincia a prendere forma. Questa volta definiamo e implementiamo il protocollo del server, e possiamo cominciare ad usare il sistema.



La volta scorsa abbiamo definito sia la classe che accetta le connessioni (AppServer), che la classe base che elabora le connessioni (AppServerExec). L'AppServer accetta una connessione e crea una nuova istanza di AppServerExec; ogni istanza di questa classe avvia un nuovo thread, che gestisce l'interazione con i client connessi. In realtà non sarà AppServerExec a gestire le connessioni ma una sua classe derivata, JRCServerExec, che d'ora in poi chiamiamo l'esecutore. Questa classe comunque si appoggia ad altre classi accessorie che gestiscono i buffer, il registro dei nickname e l'analisi dei comandi. L'esecutore in pratica è un interprete di comandi che esegue le richieste provenienti dai client. I comandi implementati sono quelli definiti nel protocollo di comunicazione tra il client e il server.

L'esecutore di AppServerExec è rudimentale: fa solo l'echo di quanto gli invia il client, e riconosce il comando quit per chiudere la connessione.

D'ora in poi lo sviluppo del server prosegue completando l'implementazione dell'esecutore secondo quando specificato dal protocollo: già in quest'articolo gli daremo abbastanza forma per fare delle chat.
 

Il protocollo del JRC
Come in tutti i sistemi client/server, l'approccio seguito è quello di definire per prima cosa il protocollo che consente l'interazione tra il client e il server. Nella migliore tradizione dei protocolli di Internet, quello che definiamo è testuale: la comunicazione avviene scambiandosi righe di comando; queste righe contengono solo caratteri ASCII stampabili, e ogni riga è terminata con un newline. In questo modo il protocollo può anche essere utilizzato manualmente: infatti è possibile interagire col server come se si utilizzasse un programma con interfaccia a linea di comando. Questa tecnica rende semplice il testing e ci consente anche di utilizzare il nostro server, sia pur in maniera scomoda, anche senza aver ancora implementato il client. Basta infatti utilizzare il programma telnet disponibile, aprendo la porta del nostro server (6789) per interagire con esso. Naturalmente la vera interazione avverrà utilizzando il client scritto appositamente, che analizzeremo nei prossimi articoli. In Tabella 1 sono riassunti i comandi pianificati nel protocollo completo, anche se è possibile che ci sia qualche ripensamento durante l'implementazione.
 
Comando  e Significato 
NICK <user> <password> <email> <nome>: Specifica il nickname, la password e le informazioni collegate al nickname 
TALK <intervento>: Invia un intervento utilizzando il nickname corrente 
SEND <nick> <intervento>: Invia un intervento ad un altro utente dato il suo nickname 
READ <id>: Legge tutti gli interventi successivi all’id 
QUIT: Termina la sessione 
ROOM <id>. Cambia “stanza” di conversazione 
LIST:  Lista le stanze di conversazione 
WHO <room> 
INFO 
 
 
Tabella 1

Vediamo in dettaglio le parti del protocollo che implementiamo. La logica d'uso è che il client per prima cosa si identifichi, specificando con il comando NICK il proprio nickname (ovvero lo pseudonimo che si utilizza solitamente nelle chat per identificarsi). A quel punto, il client potrà intervenire nelle discussioni. Il comando per mandare un intervento a tutti è TALK, mentre per leggere gli interventi degli altri si utilizzerà il comando READ (da usare quindi esplicitamente). Il client dovrà quindi:

1) effettuare una READ

2) se l'utente è intervenuto (cioè ha digitato un intervento in una apposita casella di testo) mandare il proprio intervento con TALK

3) aspettare qualche secondo (per non intasare il server con richieste continue)

4) Ritornare al punto 1)

Naturalmente ci sono delle complicazioni delle quali ci occuperemo più avanti nel progetto (cosa fare, ad esempio, se l'utente vuole inviare comunicazioni private, o se vuole cambiare stanza?).

Per ora ci limitiamo a descrivere e implementare un subset del protocollo che consenta di parlare tra più utenti utilizzando una unica "stanza" (o canale) di conversazione.

Il protocollo in dettaglio
Dopo l'invio di ogni comando ci si attende una riga di risposta, che inizia con un codice numerico seguita da altre eventuali informazioni. I codici delle possibili risposte sono mostrate in Tabella 2, che descriveremo secondo il loro nome simbolico piuttosto che con il codice numerico. In realtà la risposta del server è sempre <codicenumerico> <codicesimbolico> <testodescrittivo> ma al client basterà interpretare il codice numerico per capire la risposta; il resto è per una questione di leggibilità.
 
Codice  Valore Significato
OK 
DATA 
SYNTAX 
NOCMD 
NOAUTH 
NONICK 
NOROOM 
UNDEFINED




10 
11 
12 
255
Comando riuscito senza dati 
Comando riuscito, seguono dati 
Errore di sintassi 
Comando non esistente 
Autorizzazione negata 
Nickname non esistente 
Stanza non esistente 
Errore generale indefinito
Tabella 2

Dopo il codice di risposta possono seguire altre informazioni (per esempio in risposta al codice DATA seguono esplicitamente dei dati che il client dovrà leggere).

Il comando NICK <nickname> consente di specificare il nickname. Le risposte possibili sono OK se il nickname è stato accettato e NOAUTH se il nickname esiste già.

Il comando TALK <intervento> consente ad un client di intervenire nella discussione Le risposte possibili sono OK se è stato possibile inviare il messaggio e NOAUTH se il nickname non è stato definito.

Il comando READ consente di leggere tutti gli interventi finora non letti. Se ci sono appunto interventi, la risposta sarà composta da più linee ognuna contenenti :

1) La stringa "DATA"

2) Un numero, che indica la quantità di linee che seguiranno

3) Le linee rappresentanti gli interventi degli altri client connessi.

Verrà risposto semplicemente "OK" nel caso che non ci fossero interventi da leggere.

Il comando QUIT consente al client di disconnettersi e va sempre dato per terminare una sessione (anche se l'implementazione prende qualche precauzione nel caso il client non lo faccia).
 

Architettura del JRC Server
Vediamo ora i dettagli implementativi del server.

Ogni oggetto esecutore (JRCServerExec) ha un suo buffer degli interventi (ChatBuffer). Ogni volta che qualcuno interviene nella discussione, il suo intervento viene aggiunto ai ChatBuffer degli esecutori destinatari, ovvero a tutti nel caso si faccia una TALK. e al solo destinatario nel caso di una SEND.

Quindi deve esistere un registro degli esecutori attivi in un dato momento, ovvero il NickRegister. Ogni esecutore sarà identificato nel registro utilizzando il nickname specificato dell'utente. Quando l'utente specifica il proprio nickname (col comando NICK), l'esecutore provvede a registrarsi (appunto) nel registro.

Quindi, inviare un intervento a tutti equivale a scrivere l'intervento nel ChatBuffer di tutti coloro che sono registrati nel NickRegister. Inviare un intervento a qualcuno in particolare, significa consultare comunque il NickRegister per sapere dove trovare il ChatBuffer di quell'utente per scrivervi . Infine, avremo una classe che si occupa dell'analisi dei comandi, CmdParser, pensata appositamente per analizzare linee di comando nel formato solitamente accettato dai server.
 

Programmazione concorrente
Dato che il nostro sistema è multithreaded, ci dovremo preoccupare di alcuni problemi tipici della programmazione concorrente. Infatti, abbiamo degli oggetti (ovvero il NickRegister e di vari ChatBuffer) che sono accessibili a più thread contemporaneamente. Se due thread potessero accedere contemporaneamente ad uno stesso oggetto senza protezione, le conseguenze potrebbero essere dannose: per un esempio dettagliato vedere Java Multithreading, in [1]. Anche se normalmente non ci si fa caso, quando si usa un metodo di un oggetto si causa in esso una transizione da uno stato valido prima dell'esecuzione ad un altro stato valido dopo l'esecuzione. Ma se durante l'esecuzione di un metodo il thread che lo stava eseguendo venisse interrotto, un altro thread potrebbe agire sull'oggetto mentre si trova in uno stato non valido. Supponiamo ad esempio di avere uno stack: un thread potrebbe venire interrotto mentre sta effettuando una push. Un altro thread potrebbe quindi entrare in azione eseguendo a sua volta un'altra push, mentre probabilmente il puntatore al top dello stack risulterebbe corrotto proprio a causa della prima push ancora in esecuzione.

Il debugging in una situazione come questa sarebbe quantomeno difficile. Quel che è peggio, è che gli errori dovuti all'esecuzione concorrente non protetta sono casuali e imprevedibili, e potenzialmente disastrosi.

Java mette a disposizione un meccanismo generale, semplice ma potente, che mette al riparo da problemi di accesso contemporaneo ad un oggetto condiviso: i metodi sincronizzati. Per definire un metodo come sincronizzato, si usa lo specificatore synchronized. Per ogni diversa istanza dell'oggetto stack, quando un thread esegue un metodo sincronizzato, nessun altro thread può eseguire un altro metodo sincronizzato su quello stesso oggetto. Riconsideriamo l'oggetto stack, e supponiamo che la push sia synchronized. Supponiamo, nel caso visto prima, che un secondo thread tenti di eseguire una push mentre il primo è stato interrotto nell'esecuzione della prima push: questa volta il secondo thread sarebbe messo in attesa fino alla terminazione completa del primo.

Si dice in questi casi che sull'oggetto è presente un semaforo, che crea una coda di thread in attesa mentre l'oggetto è occupato. Gli accessi all'oggetto vengono detti serializzati (ovvero eseguiti uno dopo l'altro), mentre i metodi sincronizzati sono chiamati atomici, nel senso che l'azione da loro eseguita è indivisibile e non può essere interrotta.

Il meccanismo dei metodi sincronizzati ci consente di risolvere in maniera semplice i problemi della programmazione concorrente ma non ci mette del tutto al riparo da errori: occorre riconoscere esplicitamente gli oggetti soggetti ad un accesso concorrente e dichiarare opportunamente i metodi. Inoltre l'uso indiscriminato di metodi sincronizzati va evitato in quanto può portare a sensibili inefficienze.
 

java.util.Vector
Gli array in Java sono di dimensione fissa: una volta creati, non è possibile variarne la dimensione. Se occorresse aggiungere un nuovo elemento, sarebbe necessario crearne uno nuovo più grande, e ricopiarvi il contenuto di quello vecchio. Questa operazione in generale è complicata e inefficiente.

La classe java.lang.Vector, molto utilizzata nella programmazione in Java, implementa un array dinamico ed aiuta a gestire le operazioni di ridimensionamento di array.

La logica d'uso è semplice: basta creare un nuovo vettore con Vector v = new Vector(), per poi aggiungere nuovi elementi in coda con v.addElement(). Naturalmente, poiché Java non supporta l'operator overloading, l'accesso agli elementi non viene effettuato utilizzando l'operatore [] ma tramite un metodo, elementAt(); come gli array, se il Vector contiene n elementi, gli indici vanno da 0 a n1; è possibile sapere il numero di elementi contenuti grazie al metodo size(), simile alla length degli array. Un'altra importante differenza tra i Vector e gli array, è che non è possibile tipizzare gli elementi contenuti nel Vector: sono tutti Object. Questo significa che il programmatore deve sapere che cosa ha messo nel suo Vector per ripristinare il tipo nel momento in cui estrae gli elementi. Quindi se si aggiunge una stringa s in un Vector v con v.addElement(s), estraendola si dovrà eseguire un cast:

    s = (String)v.elementAt(0).

Il Vector può essere utilizzato in modo elementare utilizzando i pochi metodi finora elencati. Tuttavia è possibile ottimizzarne l'uso di memoria avendo alcune informazioni sul numero massimo di elementi che saranno inseriti. Il Vector è progettato per essere espandibile, e per questo motivo la vera dimensione del vettore non è data da size() ma da capacity() (che è sempre >= size()). Nel momento in cui il numero degli elementi diventa uguale alla capacità, quest'ultima viene incrementata del valore dato dalla proprietà capacityIncrement. I costruttori consentono di specificare, opzionalmente, sia la capacità che l'incremento di capacità. Avendo una stima del massimo numero di elementi che potranno essere inseriti e dell'incremento medio di capacità, è possibile ottimizzare l'uso del vettore. Quando la dimensione del vettore si è stabilizzata, si può utilizzare trimToSize() per eliminare lo spazio inutilizzato e portare la capacità pari alla dimensione effettiva.

Ci sono altre importanti caratteristiche dei Vector che consentono di utilizzarlo quasi come una lista generica. È infatti possibile inserire elementi "in mezzo" e rimuoverli: insertElementAt(object, i) inserisce l'elemento object alla i-esima posizione , shiftando di una posizione tutti i successivi; removeAllElements() svuota il vettore; removeElementAt(i) rimuove l'elemento di indice i; removeElement(object) rimuove la prima occorrenza dell'object nel vettore (uno stesso oggetto ovviamente può trovarsi più volte in un vettore).

È possibile effettuare una ricerca lineare in avanti (a partire dal primo elemento verso l'ultimo): indexOf(object) ritorna l'indice dell'oggetto nel vettore, o -1 se non è stato trovato, mentre indexOf(object, n) ritorna l'indice dell'oggetto nel vettore cercandolo a partire dalla posizione n; si comportano analogamente lastIndexOf(object) e lastIndexOf(object, n) che effettuano la ricerca scandendo il vettore dall'ultimo elemento verso il primo.
 

ChatBuffer e NickRegister
La classe ChatBuffer, mostrata nel Listato 1, utilizza sia i meccanismi di sincronizzazione che i vettori. Ogni esecutore possiede un suo ChatBuffer; questa classe ha solo due metodi, put e get. Poiché un buffer può essere utilizzato da più esecutori contemporaneamente, i metodi sono sincronizzati, garantendo in questo modo che non ci siano dannose interferenze. Il buffer internamente è un Vector e il metodo put non fa altro che aggiungere un elemento al buffer. Il metodo get invece ricopia il contenuto del vettore in un array e lo ritorna al chiamante (che ha quindi la possibilità di manipolare liberamente i dati ricevuti).
 

La classe NickRegister, mostrata nel Listato 2, fa uso della Hashtable di cui parleremo prossimamente. Esiste una sola istanza di questa classe, che è una sorta di variabile globale. Ho posto tutti gli oggetti che sono in qualche modo globali come elementi statici della classe principale. JRC.nicks è il registro dei nickname. Forse il nome NickRegister non è proprio azzeccato, in quanto la classe si occupa anche di registrare i nickname, e di aggiungere gli interventi ai vari chat buffer. Questa organizzazione sarà probabilmente soggetta a ristrutturazione.
 

Comunque sia, vediamo adesso la logica d'uso del NickRegister. Un nuovo esecutore comunica la propria disponibilità a "chiaccherare" con gli altri, registrandosi con add, e passando al NickRegister il proprio ChatBuffer. Quando ci si sconnette, l'esecutore dovrà comunicare che il nickname non c'è più, deregistrandosi con del. Notare che il NickRegister non consente di assegnare a due utenti lo stesso nickname. Abbiamo anche un metodo replace che consente di cambiare il proprio nickname.

Il NickRegister è a conoscenza di tutte le informazioni necessarie per il chat. Sono sono quindi definiti anche i metodi talk e send che consentono di aggiungere interventi ai ChatBuffer. Con talk si invia un intervento a tutti, ovvero si aggiunge nei ChatBuffer di ogni esecutore registrato il proprio intervento, mentre con send si invia l'intervento soltanto ad un ChatBuffer, individuato attraverso il nickname.
 

java.util.StringTokenizer
Questa classe di libreria è un utile strumento per suddividere le stringhe in sottostringhe (token).

L'uso tipico di questo strumento è l'analisi di file di configurazione o dati in input. Per la verità esiste un tokenizzatore più potente, lo java.io.StreamTokenizer, che però utilizza degli stream come input ed è adatto all'analisi di interi file. Questa classe è invece più adatta per l'analisi di stringhe dalla struttura più semplice. In realtà lo StringTokenizer non basta per i nostri scopi, e lo utilizzeremo come base per implementare il CmdParser, che vedremo più avanti. Comunque si tratta di uno strumento molto utile che vale la pena di conoscere.

Lo StringTokenizer analizza le stringhe spezzettandole in sottostringhe (token) in base a singoli caratteri che fanno da separatori. Per default i separatori sono di spazi e tab. Ecco un esempio di uso:

Questo esempio produrrà in output:
  Occorre creare uno StreamTokenizer, specificando come parametri la stringa da tokenizzare, una stringa i cui caratteri sono i delimitatori dei token, (default " \t\r\n"), e un booleano che specifica se i delimitatori vadano ritornati come token oppure no (gli ultimi due parametri sono opzionali).

Si estraggono i singoli token uno a uno usando nextElement(), mentre hasMoreElements() indica se ci sono altri token da estrarre. È possibile specificare come argomento di nextElement una nuova stringa di delimitatori (che cambia quella specificata col costruttore); infine è possibile contare i token in una stringa utilizzando il set di corrente di delimitatori.

CmdParser
Il CmdParser è una classe che ho scritto di tempo addietro per semplificare l'analizzatore di comandi di un altro server che stavo sviluppando. L'analizzatore era diventato gigantesco, eppure si ripeteva spessissimo la stessa sequenza di codice: spezzettamento dei token, controllo dei tipi e verificava di errori. Generalizzando quella impostazione ho scritto un parser di comandi, il cui scopo era di analizzare righe di comandi: la struttura di una riga di comando era una sequenza di token separati da spazi, in cui il primo token specificava il comando, mentre i successivi erano argomenti che potevano essere numeri o stringhe. Prendiamo in esame, ad esempio, la seguente stringa:

PUT 123 ciao

PUT è il comando, mentre 123 e ciao sono gli argomenti, il primo numerico mentre il secondo stringa. Questa impostazione è adattabile perfettamente al protocollo del JRC. Per usare CmdParser occorre innanzitutto creare un oggetto istanza di tale classe, e definire quindi - tramite il metodo add - i comandi che può analizzare. A questo punto per analizzare una riga di comando basta utilizzare il metodo parse; i risultati dell'analisi possono essere letti con getCode, getString, getLong, getDouble, e rimangono disponibili finché non si usa nuovamente parse.

Vediamo ora in dettaglio come eseguire tutte queste operazioni.
Innanzitutto bisogna creare un nuovo analizzatore di comandi, tramite la riga:

cmd = new CmdParser()

Il metodo add(String cmd, int code, String param) accetta i seguenti parametri:

· il cmd è la stringa del comando

· code è un intero che rappresenta un codice numerico per il comando

· param specifica il numero e il tipo dei parametri che il comando può accettare.

Per esempio:,

cmd.add("MODE", 7, "wns")

Questa riga dice al CmdParser di accettare un comando MODE il cui codice è 17 e che prende come parametri una stringa non contenente spazi, seguita da un intero e infine una stringa contente spazi. In particolare, l'iesimo carattere di param indica il tipo dell'iesimo argomento che può essere accettato dal comando.

Ogni carattere ha, ovviamente, un significato:

· la n specifica di accettare interi

· la f specifica di accettare numeri in virgola mobile

· la w rappresenta stringhe non contenenti spazi o tab

· la s rappresenta stringhe contenenti spazi.

Attenzione: poiché la s è in grado di "assorbire" ogni cosa fino a fine linea, può essere utilizzato solo come ultimo carattere in una stringa di parametri.

Se scrivessimo per esempio:

cmd.parse("MODE on 15 prova generale").

Il valore ritornato da parse sarebbe CmdParser.PARSE_NOCMD se il comando non esistesse, CmdParser.PARSE_SYNTAX se ci fosse un errore di sintassi (i parametri non corrispondono ai tipi previsti, sono troppi o troppo pochi), e CmdParser.OK se non ci fossero problemi. In tal caso è possibile esaminare il codice del comando riconosciuto con cmd.getCode(): i comandi specificati ovviamente possono essere più di uno, e il codice ritornato per il comando è quello specificato con la add. I comandi vengono specificati per codice invece che per stringa, in quanto un intero può essere utilizzato in uno switch. I parametri possono essere esaminati per tipo con getXxx(int): nel nostro caso, cmd.getCode() ritorna 7, cmd.getString(0) ritorna "on", cmd.getString(1) ritorna "prova generale" mentre cmd.getLong(0) ritorna 15.

L'esecutore: JRCServerExec
Siamo giunti all'implementazione dell'esecutore, mostrato nel Listato 3. Si tratta di una classe derivata da JRCServerExec. Come avevamo detto la volta scorsa, questa classe deve implementare un metodo detach() per creare una nuova istanza di sé stessa, e un metodo run() che implementi il ciclo principale di fetchexecute. Come campi, abbiamo un ChatBuffer, un CmdParser e una stringa che conservi il nickname corrente. I costruttori e il detach() sono banali. Quello che è interessante è JRCServerExec.run().
 

Questo metodo innanzitutto inizializza il parser dei comandi secondo quanto detto prima sul CmdParser. I comandi aggiunti rappresentano per ora un subset di quello che sarà il protocollo definitivo.
Il ciclo principale legge una riga, analizza il comando, controlla che non ci siano errori ed infine esegue il comando stesso.

Analizziamo l'implementazione dei vari comandi.

Nel caso di un comando NICK, occorre registrare nel NickRegister globale, ovvero JRC.nicks, il nickname e il ChatBuffer. Questo comando è utilizzato sia per registrare un nuovo nick, sia per cambiarne uno esistente: il programma dovrà quindi effettuare tutti i controlli in merito all'uno ed all'altro caso.

I comandi SEND e TALK sono molto simili, e in ogni caso si appoggiano al NickRegister: tutto quello che fanno è chiedere al NickRegister di accodare nei ChatBuffer dei destinatari la stringa letta sulla riga di comando.

Il comando READ svuota il ChatBuffer e lo stampa. Ricordo che il protocollo richiede che si ritorni il numero di righe in output prima delle righe vere e proprie (in modo che il client abbia una idea chiara di quanto output gli arriverà).

Il comando QUIT non fa altro che chiudere la connessione. In ogni caso prima di uscire (nel caso si terminasse prematuramente con una eccezione, se il client chiude bruscamente la connessione) la clausola finally fa in modo che ci si deregistri dal NickRegister.

Conclusioni
Già si comincia a vedere qualcosa. In Figura 1 possiamo vedere una sessione, ottenuta interagendo con il server ed utilizzando il telnet di Windows. Ho aperto tre connessioni, specificando il nickname per ognuno e poi ho inviato prima una comunicazione a tutti, poi una comunicazione privata ad un utente. Nelle sessioni di telnet non sono mostrati i comandi di input in quanto il server non fa l'echo di quanto digitato: sono visibili solo gli output del server. Comunque è possibile seguire l'intera sessione esaminando il log, nella finesta apposita. Il server può ancora essere arricchito di funzioni, ma non ce ne occuperemo subito. Piuttosto, la prossima volta scriveremo una applet client capace di comunicare con il server appena implementato, in modo da poter "toccare con mano" i risultati ottenuti.


 

 

Bibliografia

[1] Michele Sciabarrà "Lezioni di Java" Computer Programming 53, 54, 55, 56, 57, 58 Edizioni Infomedia 1996/1997
[2] Michele Sciabarrà "Cos'è Java" e "Dissolvenze in Java" , Computer Programming 48, Edizioni Infomedia 1996
[3] Yuri Bettini "OOP e Java" Dev 38, 39 Edizioni Infomedia 1997
[4] James Gosling "The Java Programming Language", AddisonWesley 1996
[5] Gary Cornell "Core Java", AddisonWesley 1996
[6] David Flanagan "Java in a Nutshell", O'Reilly & Associates 1996

Michele Sciabarrà è consulente informatico e scrittore tecnico, specializzato in sistemi e applicazioni Internet ed Intranet. Si occupa di consulenza, amministrazione e sviluppo software per ambienti di rete e siti Web.

 

 
 
 

MokaByte rivista web su Java

MokaByte ricerca nuovi collaboratori
Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it