MokaByteNumero 16 - Febbraio 1998

 

 

Java  Relay Chat
di
Michele
Sciabarrà
Primo appuntamento di una miniserie che ci porterà alla realizzazione di una chat in Java





Java è un linguaggio chiaro ed elegante, semplice ma potente, portabile e innovativo. Un "vero" programmatore, avendo tra le mani un simile strumento, non può certo evitare di scrivere un bel programma.
Scherzi a parte, nella precedente serie di articoli sul Perl (apparsa in Computer Programming n.d.r.) ho mantenuto un approccio generalista, passando da un esempio all'altro senza far riferimento ad un ordine particolare, ma cercando di proporre programmi significativi. L'obiettivo era esaminare ogni volta un aspetto differente. In questa nuova serie seguirò un approccio diverso e scriverò un programma di una certa complessità raccontando le varie peripezie e avventure.
In questo modo potremo imparare ad utilizzare Java per fare qualcosa di più complesso di una semplice applet. L'idea per l'impostazione di questa serie mi è venuta seguendo la column "Chaos Manor" di Jerry Pournelle sulla rivista americana Byte. Jerry racconta mese per mese le sue peripezie e ho sempre trovato gustosissimo quello che gli succede e i disastri indotti semplicemente dal cambiare un mouse (cose che in verità accadono tutti giorni anche a me). Tuttavia l'aspetto più importante che voglio riportare di quella column è quello istruttivo. Infatti, mi è capitato spesso di ritrovare trucchi e informazioni che poi mi sono stati utili anche nella pratica.
Un'altra chiave di lettura che voglio offrire è quella introduttiva. Descrivendo passo dopo passo la realizzazione di un programma mi soffermerò sui dettagli della libreria; sarà interessante, inoltre, se qualche lettore supporterà il mio lavoro fornendomi critiche, consigli, modifiche, aggiunte e test. Comunque sia, intendo mantenere il programma nei limiti consentiti dalla ragionevolezza e dalla semplicità, evitando di aggiungere funzioni che avrebbero bisogno di molte righe di codice e molto tempo per essere adeguatamente testate. Detto ciò, possiamo iniziare.

Un Sistema di chat multiutente in Java
Andando in giro per la rete avrete forse visto qualche sistema di chat sviluppato in Java (per esempio www.talk.com). Si tratta infatti di una applicazione molto interessante che va oltre le semplici applet che abbelliscono le pagine web. Il programma che svilupperemo non rappresenta dunque una novità, ma vederne la realizzazione pratica dovrebbe essere di un certo interesse, sia come base per fini didattici, sia come base per ulteriori modifiche e raffinamenti da parte dei lettori.
Inoltre la realizzazione è sufficientemente complessa da giustificare numerosi articoli che coprano moltissimi aspetti del linguaggio. Un sistema di chat, è per necessità di cose, un sistema client/server. Supponiamo infatti di voler fare un programma singolo. Una prima possibilità è collegarsi direttamente con gli utenti con i quali desideriamo comunicare, utilizzando un sistema a rotazione (per esempio il primo comunica col secondo che comunica col terzo e così via). Ma questo approccio ha delle serie difficoltà. Per esempio, come si fa a sapere l'indirizzo degli altri utenti? Ammettendo che questo venga comunicato in qualche modo, per esempio via e-mail, rimane un'altra seria difficoltà tecnica. Java è famoso per la sua capacità di incorporare programmi nelle pagine Web e naturalmente vorremmo che il programma potesse girare come applet. In questo caso dobbiamo scontrarci con la seguente limitazione: le applet possono collegarsi soltanto ad un IP, ovvero solo alla macchina da cui sono state scaricate. In queste condizioni, non è possibile in nessun modo comunicare con altri, ammessa l'esistenza di un modo per ottenere l'indirizzo automaticamente.
Quindi, l'unico approccio praticabile è quello di avere un programma server, che accetti delle connessioni da varie istanze di un altro programma client. Quindi si devono scrivere due programmi differenti e complementari. In pratica i client si connettono ad un server che funge da concentratore. I client saranno delle applet mentre il server girerà come applicazione. Quest'ultima condizione è necessaria perché solo così il server potrà evitare le limitazioni imposte dai browser, in particolare le limitazioni sugli IP.
Il meccanismo in teoria è molto semplice: ogni client invia messaggi al server che li ritrasmette a tutti gli altri client. Comunque ci sono diverse altre funzionalità che possono essere richieste: per esempio i "canali" di conversazione, le conversazioni private, la gestione dei nickname, la possibilità di collegare tra di loro a catena più server, ecc. Onestamente non so quante e quali di queste funzioni saranno implementate. Tuttavia rilascerò il codice in modalità freeware, protetto dalla famosa GPL (GNU Public License) in maniera tale che sia liberamente ampliabile ( il codice modificato dovrà comunque essere rilasciato freeware).
 

Funzioni di base
Chiameremo il programma JRC (Java Relay Chat), per similitudine al ben noto sistema di chat Internet IRC, col quale però il sistema non ha niente a che fare se non il fatto di svolgere funzioni molto simili.
JRC è un sistema di chat multiutente client/server, composto da due distinti programmi (un client e un server, appunto) implementati interamente in Java. La versione 0.1 sarà abbastanza limitata e avrà soltanto le funzioni base.
Il server ha la funzione di accettare connessioni dai client; ogni client invia al server gli interventi dell'utente che lo ha attivato e legge dal server gli interventi di tutti gli altri. In questa versione ci sarà un solo canale, e non ci sarà la gestione di nickname o di autorizzazioni di ingresso: semplicemente il server accetterà gli interventi da chiunque voglia connettersi, reinviandoli a tutti gli altri client, utilizzando un solo canale di conversazione. Canali multipli, reti di server, gestione dei nickname eccetera sono rimandati alle prossime versioni.
In questo articolo cominceremo ad implementare il server. Per il momento avremo soltanto un processo che attende connessioni; quando ne riceve una viene attivato un altro processo esecutore di comandi per gestirla. L'esecutore di comandi si limiterà semplicemente ad accettare il comando quit e a ritornare inalterato l'input che riceve. Sembra una cosa elementare, ma dobbiamo comunque far attenzione a parecchie cose: innanzitutto alle basi per la gestione dell'I/O e dei socket, poi all'implementazione di una interfaccia per la registrazione delle attività, infine al server principale e l'esecutore dei comandi, dal quale poi deriveremo delle classi che implementeranno il protocollo.
 

Gestione dei Socket
Un socket è un canale di comunicazione di rete con altre macchine; in un certo senso è come un file, con la differenza che scrivendo in questo canale, qualcun altro "dall'altra parte" potrà leggere quello che si è scritto; inoltre il canale è bidirezionale, cioè chi scrive può anche leggere. I socket sono nati in ambiente Unix, ma sono stati implementati in (praticamente) tutti i sistemi operativi. I socket sono alla base dei programmi per Internet, come i browser o i programmi di posta elettronica. Java comprende nella sua libreria standard delle classi che consentono di utilizzarli con molta facilità. Il package che comprende le funzioni per l'utilizzo dei socket è java.net, ma fa uso di alcune classi del package java.io.
Nella tabella 1 abbiamo riassunto le classi e i metodi principali per l'uso dei socket, mentre nella tabella 2 abbiamo quelle per l'uso dell'I/O.
 
Socket
Socket(String host, int port Crea una connessione all'host utilizzando la porta port
Socket(InetAddress addr, int port Crea una connessione all'indirizzo IP addr utilizzando la porta port
void close()  Chiude ilsocket 
InetAddress getInetAddress()  Ritorna l'indirizzo IP remoto a cui il socket è connesso 
InputStream getInputStream()  Ritorna lo stream di input 
OutputStream getOutputStream()  Ritorna lo stream di output 
int getLocalPort()  Ritorna la porta locale a cui è connesso il socket 
int getPort()  Ritorna la porta remota a cui è connesso il socket 
String toString()  Ritorna una rappresentazione del socket come stringa 
ServerSocket
ServerSocket(int port Crea un socket in attesa sulla porta port
Socket accept()  accetta una connessione 
void close()  chiude il socket 
int getLocalPort()  ritorna la porta in cui ascolta 
String toString()  ritorna una rappresentazione come stringa 
 
 
Tabella 1: classi  e metodi per l'utilizzo di socket
 
 

InputStream 
InputStream()  Costruttore 
available()  ritorna il numero di byte leggibili senza bloccarsi 
close()  chiude lo stream 
read()  legge un byte 
read(byte[] a legge in a un array di byte 
skip(long n salta n byte di input 
OutputStream 
OutputStream()  Costruttore 
close()  chiude lo stream 
flush()  svuota i buffer scrivendone i contenuti 
write(int n scrive un byte 
write(byte[] a scrive un array di byte 
 
 
Tabella 2: classi  e metodi per l'utilizzo di I/O
 

Poiché utilizzeremo queste classi, ci soffermiamo a discuterle.
Le classi mostrate in tabella 1 sono quelle necessarie per usare i socket; consideriamo la classe Socket che serve per aprire una connessione.
I costruttori consentono di costruirne uno, data una porta e un indirizzo IP (che va costruito creando una apposita istanza della classe InetAddress), oppure specificando una stringa nome di un host (quindi sarà il costruttore a ricavare l'indirizzo IP risolvendo il nome). Il socket può essere chiuso, può essere esaminato per conoscere le porte e gli indirizzi dei due capi della connessione, ed è possibile stamparlo (utilizzando l'universale metodo toString di Java), in modo da vederne la configurazione attuale. La cosa importante è comunque vedere come si fa per leggere o scrivere. A questo scopo si utilizzano due stream, uno di input e l'altro di output che possono essere letti con gli appositi metodi. Dobbiamo quindi esaminare gli stream, ma prima terminiamo la tab esaminando il ServerSocket: questo serve appunto per creare un server che sta in attesa. Il costruttore richiede una porta e si pone in attesa. Per accettare una connessione si utilizza il metodo accept() che rimane sospeso finché qualcuno non richiede una connessione client. A questo punto viene ritornato un Socket già aperto, del quale si possono ricavare gli stream di I/O con geInputStream() e getOutputStream(). Anche il ServerSocket naturalmente può essere chiuso, esaminato e stampato.
Gli stream sono il modo in cui viene implementato l'I/O in Java.
In tabella 1 sono riassunti i principali metodi delle classi InputStream e OutputStream che sono i tipi degli oggetti ritornati dai metodi che ricavano gli stream dai Socket e che devono essere utilizzate per l'I/O di rete. Sia gli stream di input (InputStream) che di output (OutputStream) hanno un costruttore e un metodo per chiuderli (close). Gli stream di input possono essere letti, utilizzando il metodo read, prelevando un singolo byte alla volta oppure un array di byte (il numero di byte da leggere è determinato dalla lunghezza dell'array). Un aspetto tipico dell'I/O è il fatto che i dati sono spesso trasferiti a blocchi. Questo perché il trasferimento di dati tipicamente impiega molto tempo, e il tempo necessario per trasferire un singolo byte è spesso uguale a quello necessario per trasferire un blocco di un kilobyte. Pensiamo ai dischi: il tempo impiegato a scrivere dei dati è determinato in misura preponderante dal tempo necessario per posizionarsi della testina del disco su un settore; a quel punto la differenza tra scrivere un byte o scriverne un migliaio è irrilevante. Questo fatto è vero anche per le connessioni di rete, in quanto i dati vengono normalmente trasferiti a pacchetti. Per questo motivo i sistemi di I/O normalmente raccolgono blocchi di input e li inviano tutti insieme, cioè bufferizzano l'I/O.
Viene richiesto un intero blocco e poi si possono leggere i dati nel buffer di input. Per questo motivo, l'input può procedere finché il buffer non è vuoto.
La lettura può essere eseguita con InputStream.read.
Poiché l'input può bloccarsi, InputStream.available() consente di conoscere quanti byte possono essere letti senza che una lettura si blocchi. Un discorso analogo vale per l'output, che viene bufferizzato finché il buffer non è pieno. Con OutputStream.write() si può scrivere, un byte o un array di byte per volta. Il metodo OutputStream.flush() obbliga lo stream a svuotare il buffer, inviando a destinazione l'output nel buffer.
 

Logger e derivati
Una operazione effettuata comunemente dai server è la registrazione delle attività. Questo perché un server normalmente opera senza che nessuno ne controlli le attività ma in generale è necessario sapere chi ha usato il server e che cosa ha fatto.
Questa operazione viene normalmente chiamata logging. Per questo motivo il server utilizzerà un Logger, ovvero un oggetto che si occuperà di registrare le operazioni effettuate.
Il logger è molto utile (praticamente indispensabile) nelle attività di debugging, quindi ci occuperemo immediatamente di costruirne uno. In generale il logging può andare sulla console, su un file oppure su una finestra: quindi definiremo una base comune, una semplicissima interfaccia, e poi implementeremo diversi logger.
I server si aspetteranno semplicemente un oggetto di classe Logger. L'interfaccia Logger è la seguente:

Tutto quello che viene richiesto da questa interfaccia è un metodo log, dove un intero serve ad identificare chi effettua il logging mentre la stringa fornisce il messaggio di logging (in generale un logger può essere utilizzato da più utenti contemporaneamente).
Nel listato 1 abbiamo due diverse implementazioni del logger: un semplicissimo LogStream che stampa un messaggio sullo standard error, e una più complessa LogWindow che fa il log delle operazioni in una finestra (che può essere esaminata e svuotata).
Successivamente penseremo ad implementare un logger che utilizzi un file come output per registrare le azioni, e nulla vieta di implementare anche un logger multiplo, che registri le operazioni sia a video che su un file.

AppServer e AppServerExec
Possiamo adesso implementare il server. In realtà avremo due diverse classi: il server vero e proprio, AppServer, e l'esecutore di comandi, AppServerExec. In tabella 3 sono riassunti i metodi di queste due classi. Le due classi sono pensate per essere usate insieme.
 
AppServer 
AppServer(AppServerExec exec, int port, Logger logger)  Costruttore, richiede un esecutore 
AppServer(AppServerExec exec, int port)  Costruttore senza logger 
AppServerExec 
AppServerExec()  Costruttore di un esecutore nullo (da usare con detach
void detach(int id, Socket socket, Logger logger)  Attiva una nuova istanza autonoma di esecutore di comandi 
void run()  Il ciclo principale dell'esecutore, attivato da detach
 
Tabella 3: le classi  AppServer e AppServerExec.

L'AppServer ha il compito di accettare nuove connessioni e di mandare in esecuzione un nuovo AppServerExec per gestirle.
Quando l'AppServer viene costruito, occorre fornire la porta in cui ascolta, un logger e un esecutore, ovvero un elemento di classe AppServerExec.
I metodi pubblici di AppServerExec sono quelli che servono per interagire con AppServer: il costruttore fornisce una istanza, mentre il metodo detach viene invocato per attivare una nuova istanza. Il metodo run è fornito perché l'esecutore implementa l'interfaccia Runnable che consente di avviare un thread differente per ogni esecutore attivato. Per comprendere meglio, osserviamo che il JRC.main è semplicemente:

Cioè viene costruita una istanza di AppServer alla quale viene passato il Logger e una istanza "campione"di AppServerExec. Da questo momento in poi, ogni volta che verrà accettata una nuova connessione, AppServer attiverà un nuovo esecutore invocando AppServerExec.detach, al quale passerà il logger e il socket su cui è stata attivata la connessione.
In figura 1 possiamo vedere il sistema in azione: vediamo la finestra di logging principale, e si può osservare che via telnet ci siamo collegati all'esecutore. Il telnet è un ottimo modo di testare i server, ma naturalmente prevediamo che la connessione verrà fatta da una apposita applet. Nelle fig sono mostrati due telnet diversi poiché sono state attivate due diverse istanze dell'esecutore (come si può vedere dal log nella finestra).
AppServer è progettato per essere usato così com'è, mentre AppServerExec è pensato per essere esteso.
Attualmente implementa soltanto un esecutore che si limita a fare l'eco di quanto si digita, e ad accettare soltanto il comando quit. Tuttavia in tabella 4 possiamo vedere i molti membri protetti di AppServerExec dell'esecutore che possono essere utilizzati e ridefiniti dalle classi derivate.
 
 
Membri protetti di AppServerExec 
protected Socket socket  il socket su cui è attiva la connessione 
protected AppServerExec(int id, Socket socket, Logger logger)  crea un nuovo esecutore completo 
protected void log(String s)  log delle operazioni 
protected String getLine()  legge una lina dall socket 
protected void putLine(String s)  scrive una linea nel socket 
 
 
Tabella 4: le funzioni membro della  classe  AppServerExec.

L'obiettivo della progettazione è di fare in modo che si possa definire un nuovo esecutore semplicemente ridefinendo il metodo run ed ereditando tutto il necessario per interagire facilmente con il socket: abbiamo infatti getLine e putLine per leggere e scrivere linea per linea. Sfortunatamente ci sono altri due aspetti da tenere presenti: innanzitutto occorre implementare esplicitamente il metodo detach (se non lo si fa, il detach attiverà un nuovo thread che esegue AppServerExec.run()) e poi occorre ricordarsi che il socket deve essere chiuso esplicitamente dal metodo run() prima di terminare. Per il resto si può operare in maniera abbastanza semplice e naturale, leggendo con geLine e scrivendo con puLine.

Conclusioni
Abbiamo appena cominciato. Comunque abbiamo già analizzato alcuni elementi interessanti come i socket e il multithreading. Nella prossima puntata renderemo utilizzabile il server, definendo il protocollo per il chat e cominceremo ad implementarlo.
 

Bibliografia
[1] Michele Sciabarrà "Lezioni di Java" Computer Programming 53, 54, 55, 56, 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, Edizioni Infomedia 1997
[4] James Gosling "The Java Programming Language", Addison - Wesley 1996
[5] Gary Cornell "Core Java", Addison-Wesley 1996
[6] David Flanagan "Java in a Nutshell", O'Reilly & Associates 1996
 
 
 


MokaByte Web  1998 - www.mokabyte.it

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