MokaByte Numero 15 - Gennaio 1997      

..

Client Server
in Java:
Nodi e Reti (I parte)

    di Lorenzo Bettini   

Iniziamo a vedere un piccolo package con classi estendibili, per la creazione di applicazioni distribuite, secondo il paradigma Client-Server.  

   

 

La volta scorsa [1] avevamo visto alcuni semplici esempi di programmi client e programmi server, ed in più un esempio un po' più complesso che realizzava un semplice sistema di chat, costituito appunto da un server che riceveva i vari messaggi dai client, e provvedeva a ridistribuire ogni messaggio a tutti i client collegati.

Il package NetNode

Stavolta vedremo un piccolo framework, senza tante pretese, comunque abbastanza utile per realizzare piccole applicazioni distribuite, e comunque estendibile per realizzare qualcosa di più complesso.

Le classi per questo framework sono state racchiuse in un pacchetto chiamato appunto NetNode.

Vogliamo ricreare la situazione in cui ci sono diversi programmi, in esecuzione su macchine diverse (che chiameremo Nodi), tutte collegate tramite una Rete. Ogni nodo può comunicare con tutti gli altri nodi collegati alla stessa rete. La comunicazione avverrà, non direttamente, ma passando attraverso il server che gestisce la rete stessa. Si hanno quindi comunicazioni one-to-many server-based. Ovviamente anche questo è un esempio di Client-Server: abbiamo un programma che gestisce la rete (il server), e tanti nodi collegati alla rete (i client). Quindi siamo nella situazione in cui tanti utenti sono collegati ad un server centrale, come avviene ad esempio nel Web, in cui più client si collegano allo stesso Web server centrale, e collaborano comunicando.

Ovviamente, quando più programmi diversi comunicano fra di loro, lo devono fare attraverso un protocollo prestabilito, conosciuto da tutti i partecipanti alla comunicazione. Ancora una volta il Web è un esempio: la comunicazione fra browser e server Web avviene tramite il protocollo HTTP (si veda l'esempio del client che richiede una pagina ad un certo Web server, nel precedente articolo [1]). In questo caso può essere utile comunicare tramite messaggi, che abbiano un formato comune (e conosciuto da tutti), in cui è possibile specificare un'operazione tramite un codice (stabilito dai vari programmi che intendono comunicare), e da un contenuto che può variare a seconda del tipo di messaggio e del tipo di operazione. Tramite l'uso di messaggi omogenei (ma con contenuti eterogenei), ogni applicazione può analizzare il messaggio ricevuto, capire il tipo di operazione richiesta o il tipo di informazione comunicata, e recuperare il contenuto del messaggio in modo opportuno. E' stata quindi creata una classe apposita per i messaggi: NodeMessage, costituita semplicemente da 5 campi membro:


package NetNode ;

public class NodeMessage implements java.io.Serializable {
	// campi del messaggio
    	public String Source ; // loc di chi spedisce
    	public String D'est ; // loc del destinatario
	public String ProcessName ; // nome del processo che spedisce
	public int OpCode ; // operazione richiesta
	public Object Content ; // Tupla o Processo

	public NodeMessage( String source, String d'est,
				String processname, int opcode, Object content ) {
		Source = source ;
		D'est = d'est ;
		ProcessName = processname ;
		OpCode = opcode ;
		Content = content ;
	}	

	public String toString() {
		return "< " + Source + ", " + D'est + ", " + ProcessName + 
			", " + OpCode + ", " + Content + " >" ;
	}
}

Come si può vedere la classe implementa l'interfaccia java.io.Serializable, in quanto la serializzazione ([2]) è utilizzata estensivamente per la comunicazione: non ci dovremo preoccupare di come spedire i vari dati, basterà che quello che vogliamo spedire implementi tale interfaccia (tra l'altro l'interfaccia non richiede la scrittura di nessun metodo, basta dichiarare che la si implementa). Da notare che i tipi messi a disposizione da Java, come String, Integer, implementano tutti questa interfaccia.

I nodi che vogliono comunicare con altri nodi connessi ad una certa rete, devono per prima cosa collegarsi al server di quella rete (quindi aprire una socket con tale server). Per registrarsi dovrà fornire un nome, che lo rappresenterà e lo identificherà all'interno della rete. Ovviamente tale nome dovrà essere univoco all'interno della rete, per non creare inconsistenze. Infatti quando un nodo vuole comunicare con un altro nodo, nel messaggio dovrà specificare sia il proprio nome (il mittente) che il nome del destinatario. Quindi per prima cosa si deve conoscere il nome del nodo col quale quest'ultimo si è registrato nella rete.

Il meccanismo dei nomi permette di astrarre dal particolare indirizzo internet del nodo (e quindi dalla vera posizione fisica del nodo all'interno della rete). In questo modo anche se due nodi che comunicavano con certi nomi, vengono eseguiti su macchine diverse, possono tranquillamente continuare a comunicare con gli stessi nomi, senza che i programmi vengano cambiati. Tutto sommato l'unico indirizzo internet che un nodo deve conoscere è quello della macchina dove è in esecuzione il server, e tale indirizzo può essere passato come parametro sulla riga dei comandi. Ovviamente anche il nome di un nodo dovrebbe essere passato sulla riga dei comandi; in questo modo, quando un programma è già stato testato, non dovrebbe necessitare di modifiche, anche se se ne volesse cambiare il nome (oppure eseguirne più copie con nomi, ovviamente diversi), o eseguirlo su un'altra rete.

La classe Net

Abbiamo già visto la classe per i messaggi, e adesso vediamo la classe Net che rappresenta il server che gestisce i nodi collegati alla rete. Si tratta, come uno si può immaginare, di un server multithreaded, costituito da un ciclo infinito in cui si è in attesa di richieste di connessioni (tramite un ServerSocket, [1]), e per ogni nuova richiesta di connessione viene creato un nuovo thread, che si occuperà di tale connessione (nodo). Tale procedimento è classico nel Client - Server. ed è stato illustrato anche nello scorso articolo [1].

public class Net extends Thread {
        // costanti
        public static final int NEWNODE_NOTIFY = -1 ;
        public static final int NODEREMOVED_NOTIFY = -2 ;
        public static final int BROADCAST = -3 ;
        public static final int BROADCASTBUTNOTME = -4 ;
        public static final int NOSUCHNODE_ERROR = -5 ;
        public static final int GETALLNODES = -6 ;
		
	protected final static int defaultPort = 9999 ;
	protected ServerSocket serversocket ;
        // nodi della rete : ( nome nodo, nodehandler )
	protected Hashtable nodes = new Hashtable() ;

	public Net() throws IOException {
	        super( "Net su porta " + Net.defaultPort ) ;
			serversocket = new ServerSocket( Net.defaultPort ) ;
	}

	public Net( int port ) throws IOException {
	        super( "Net su porta " + port ) ;
		serversocket = new ServerSocket( port ) ;			
	}

Come si può vedere nella classe sono definite alcune costanti negative. Queste andranno a far parte del campo OpCode di un messaggio, e sono codici speciali, che servono per la gestione della rete. Si è assunto la seguente

Convenzione : i messaggi rappresentati da un codice negativo sono riservati alla gestione della rete. Messaggi definiti dall'utente devono essere positivi.

Da ora in poi quando parleremo di messaggi, intenderemo spesso il campo OpCode di NodeMessage. In effetti questo campo è molto importante, perché rappresenta il messaggio vero e proprio. In base a questo si può utilizzare il contenuto del messaggio. Questo ricorda un po' la gestione dei messaggi di Windows (o comunque di un qualsiasi sistema event-driven). In questo caso siamo di fronte ad una sorta di Sistema distribuito event-driven message-based.


	public void run() {
		Socket socket ;
		NodeHandler nhandler ;
		try {
			while( true ) {
				socket = serversocket.accept() ;
        	            	Print( "Nuovo nodo da " + 
					socket.getInetAddress().getHostAddress() +
					    ":" + socket.getPort() ) ;
					nhandler = new NodeHandler( this, socket.getInputStream(),
					    socket.getOutputStream(), nodes ) ;
					nhandler.start() ;
			}
		} catch ( IOException e ) {
                	e.printStackTrace() ;
		}
	}

Questo è il main loop. Si tratta di un loop infinito che può essere interrotto soltanto interrompendo il programma (quindi con un Ctrl-C ad esempio). Per ogni nuova connessione viene lanciato un Thread apposito che si occuperà di tale connessione. Si tratta di un oggetto di classe NodeHandler e verrà trattato dopo.


        public boolean isNode( String l ) {
		synchronized ( nodes ) {
				return nodes.containsKey( l.toString() ) ;
		}
	}	

        public void addNode( String l, NodeHandler nhandler ) {
		synchronized ( nodes ) {
			notifyNodes( "Net", nhandler, Net.NEWNODE_NOTIFY, 
			    nhandler.getHandledNode(), false ) ;
			nodes.put( l.toString(), nhandler ) ;				
		}
		PrintNewNode( l ) ;
	}

        public void removeNode( String l ) {
		synchronized ( nodes ) {
			NodeHandler nhandler = (NodeHandler)nodes.remove( l.toString() ); 
			notifyNodes( "Net", nhandler, 
			    Net.NODEREMOVED_NOTIFY, nhandler.getHandledNode(), false ) ;
		}
		PrintRemovedNode( l ) ;
	}

L'oggetto Net tiene memorizzate le varie connessioni in una Hashtable. La chiave è il nome del nodo ed il valore è il NodeHandler per quel nodo. Come si può vedere ci sono dei metodi per l'aggiunta di un nodo alla rete, per la rimozione, e per controllare se un nodo è già stato registrato con un certo nome.

	public void notifyNodes( String source, NodeHandler nhandler, int notify, 
		        Object content, boolean meToo ) {
		NodeHandler n ;
		Enumeration en = nodes.elements() ;
		while ( en.hasMoreElements() ) {
			n = ( NodeHandler )en.nextElement() ;
			if ( n != nhandler || meToo ) {
				n.Enqueue( new NodeMessage( source, n.getHandledNode(),
					Thread.currentThread().getName(), 
				        notify, content ) ) ;
			}
		}
	}

Il metodo notifyNode è molto importante, perché permette di mandare un messaggio a tutti i nodi della rete. Il parametro meToo è un booleano che permette di stabilire se mandare il messaggio anche al mittente del messaggio stesso o no. Come si può notare tale metodo viene richiamato quando viene aggiunto o rimosso un nodo. Comunque trattandosi di un metodo pubblico può essere utilizzato anche da altre classi, in particolare da NodeHandler (come vedremo dopo).


        public NodeHandler getNodeHandler( String l ) {
		return ( NodeHandler ) nodes.get( l.toString() ) ;
	}
		
	public Vector getAllNodesNames() {
		Vector allNodes = new Vector() ;
		Enumeration en = nodes.elements() ;
		while ( en.hasMoreElements() )
			allNodes.addElement( 
		        	((NodeHandler)en.nextElement()).getHandledNode() ) ;
		return allNodes ;
	}
		

Questi due metodi vengono usati da NodeHandler.

	protected void Print( String s ) {
		System.out.println( s ) ;
	}

        protected void PrintNewNode( String l ) {
		Print( "Nuovo nodo alla localita' : " + l ) ;
	}

        protected void PrintRemovedNode( String l ) {
		Print( "Tolto nodo alla localita' : " + l ) ;
	}

Questi metodi servono per stampare alcune informazioni sullo schermo. Potrebbero essere ridefiniti, ad esempio per creare una versione grafica di questa classe. L'utilizzo di un metodo Print verrà usato anche nelle altre classi, proprio per il motivo dell'estendibilità e personalizzazione.

	public static void main( String args[] ) throws IOException {
		if ( args.length > 1 )
			throw new IOException( "Sintassi : Net <porta>" ) ;
		int port = Net.defaultPort ;
		if ( args.length == 1 )
			port = Integer.parseInt( args[ 0 ] ) ;
        	System.out.println( "Net sull porta " + port ) ;
		Net net = new Net( port ) ;
		net.start() ;
	}
}

L'unico parametro di cui un oggetto Net ha bisogno per partire è il numero di porta su cui mettersi in ascolto per richieste di connessione.

La classe NodeHandler

Come si è visto un oggetto Net crea un nuovo thread per ogni nuova connessione. In questo caso si tratta di un oggetto NodeHandler, la cui classe discende appunto da Thread. Ogni NodeHandler gestirà un solo nodo della rete, in particolare riceverà i messaggi che il nodo invia alla rete, e provvederà ad instradarli al nodo destinatario.

Un NodeHandler si "prende cura" di un nodo fin dall'inizio: quando un oggetto Net ottiene una richiesta di connessione passa direttamente la socket (effettivamente gli passa gli stream di input/output della socket)ad un nuovo NodeHandler; sarà il il NodeHandler ad occuparsi di effettuare la registrazione del nodo presso la rete

public class NodeHandler extends Thread {
	static private int handlerNumber = 0 ;
	protected Queue messageQueue ; // coda dei messaggi
	// streams per la comunicazione in rete
	protected InputStream iStream ; 
	protected OutputStream oStream ;
	protected Net net ; // la rete a cui appartiene
	protected Hashtable nodes ; // i nodi della rete
	protected String handledNode ;

	public NodeHandler( Net net, InputStream i, OutputStream o, Hashtable n ) throws IOException {
		super( "NodeHandler - " + NextHandlerNumber() ) ;
		this.net = net ;
		nodes = n ;
		iStream = i ;
		oStream = o ;
	}

	static private synchronized int NextHandlerNumber() {
		return handlerNumber++ ;
	}

Ogni NodeHandler viene creato con un nome unico, ottenuto incrementando un membro statico di tale classe, tramite un metodo sincronizzato.


	public void run() {
		try {
			ObjectInputStream iObjStream = new ObjectInputStream( iStream ) ;
			try {
				String name = ( String ) iObjStream.readObject() ;
				register( name ) ;
			} catch ( ClassNotFoundException nfe ) {
				System.err.println( nfe ) ;
			}
        	} catch ( IOException e ) {
		} finally {
			try {
				oStream.close() ;
			} catch ( IOException e1 ) {
                		e1.printStackTrace() ;
			}
		}
	}

	protected void register( String name ) throws IOException {
		messageQueue = new Queue() ;
		boolean registered = true ;
		
		synchronized ( nodes ) {
			if ( net.isNode( name ) ) {
				registered = false ;
			} else {
				handledNode = name.toString() ;
				net.addNode( name, this ) ;			
			}
		}

		try {
			ObjectOutputStream O = new ObjectOutputStream( oStream ) ;
			O.writeObject( new Boolean( registered ) ) ;			
			if ( registered ) {				
				execute() ;				
			}
		} finally {
			if ( registered ) {
				net.removeNode( name ) ;				
			}
		}
	}

Come prima cosa si prende dalla rete il nome con cui il nodo si vuole registrare (come si vedrà successivamente dopo avere aperto la socket con la Net, il nodo spedisce il nome con cui intende registrarsi e si mette in attesa di una conferma). Se il nome non è già presente nella rete (si chiama il metodo di Net isNode), si registra definitivamente il nodo con tale nome. Da notare che queste operazioni vengono fatte all'interno di un synchronized( nodes ) per evitare che dopo aver controllato che il nome non esiste già, qualcun altro registri un nodo con lo stesso nome, prima che l'abbia registrato il NodeHandler che ha effettuato il controllo. Si tratta semplicemente di un problema di sincronizzazione e di race condition, dovuto al multithreading, essendo la tabella dei nodi condivisa da tutti i NodeHanlder. Se la registrazione ha esito positivo, lo si notifica al nodo, spedendogli un booleano con valore true. Il NodeHandler ha questo punto sta per entrare nel main loop.

	protected void execute() throws IOException {
		MessageDeliverer deliverer = new MessageDeliverer(
			this, messageQueue, new ObjectOutputStream( oStream ) ) ;

		try {
			deliverer.start() ;
			listen() ;
		} finally {
			deliverer.stop() ;
		}
	}
	
	protected void listen() throws IOException {
		ObjectInputStream iObjStream = new ObjectInputStream( iStream ) ;
		NodeMessage message ;

		while( true ) {
		    try {
			    message = ( NodeMessage ) iObjStream.readObject() ;
			    handleMessage( message ) ;
			} catch ( ClassNotFoundException e ) {
			    e.printStackTrace() ;
			}
		}
	}

Nel metodo execute viene fatto partire un Thread parallelo, il cui scopo sarà chiaro fra un po'. Il metodo listen rappresenta il main loop del NodeHandler, da cui si potrà uscire solo se la connessione col nodo cadrà. A tal proposito è importantissima l'eccezione IOException. Tale eccezione infatti viene lanciata se una connessione cade. Come si può notare in register, execute ed in run vengono eseguite le operazioni all'interno di blocchi try...catch...finally (effettivamente il catch non c'è, ma l'importante è la parte finally). Come si sa, il blocco finally viene eseguito comunque, anche dopo che si è verificata un'eccezione. Si sfrutta tale opportunità per effettuare le tipiche operazioni di chiusura: stoppare il thread che si era fatto partire, e deregistrare il nome del nodo dalla rete.

Nel main loop si leggono i messaggi dall'ObjectInputStream, che si ricorda è collegato allo stream del socket, quindi si leggono i messaggi che invia il nodo che stiamo gestendo. Attenzione: in questo caso sappiamo che tale stream è collegato ad una socket, ma effettivamente questa classe non lo sa. Questo perchè il costruttore della classe riceve un InputStream e un OutputStream, che sono le classi da cui derivano tutti gli stream; quindi un NodeHandler in realtà non sa di leggere e scrivere in rete, perciò può essere utilizzato con qualsiasi tipo di stream. Una volta che si è letto un messaggio si passa tale messaggio al metodo handleMessage che provvederà, appunto a gestire il messaggio. L'utilizzo di un metodo per la gestione dei messaggi favorisce l'estendibilità della classe NodeHandler, permettendo una gestione personalizzata dei messaggi.


	protected void handleMessage( NodeMessage message ) throws IOException {
		NodeHandler nHandler ;
		String name ;
		
		switch ( message.OpCode ) {
			case Net.BROADCAST :
				net.notifyNodes( handledNode, this, message.OpCode, 
    				        message.Content, true ) ;
    				break ;
    			case Net.BROADCASTBUTNOTME :
    				net.notifyNodes( handledNode, this, message.OpCode, 
					message.Content, false ) ;
    				break ;
    			case Net.GETALLNODES :
    				message.Content = net.getAllNodesNames() ;
				Enqueue( message ) ;
                        	break ;
                    	default :    				
        			name = message.D'est ;
                        	nHandler = net.getNodeHandler( name ) ;                        
				if ( nHandler != null )
            		        	nHandler.Enqueue( message ) ;
                        	else {
                            		Print( name + " non esiste!" ) ;
        			        message.OpCode = Net.NOSUCHNODE_ERROR ;			        
        			        Enqueue( message ) ;
                		}
        		}		
	}

	.
	.
	.
}

Il metodo handleMessage è composto da un costrutto switch, per gestire alcuni possibili messaggi: questi messaggi sono quelli che interessano la gestione dei nodi nella rete, in particolare il broadcasting di un messaggio a tutti i nodi della rete, con la possibilità di escludere il mittente (Net.BROADCASTBUTNOTME). Questi messaggi erano definiti come costanti nella classe Net.

Se non si tratta di uno di questi messaggi, il comportamento di default è quello di inserire il messaggio nella coda dei messaggi del NodeHandler del nodo di destinazione. Ogni NodeHandler ha infatti una coda in cui vengono inseriti i messaggi da inviare al proprio nodo. Il meccanismo della coda dei messaggi in uscita sarà spiegato dopo. Ovviamente sbagliare è umano, e quindi viene gestito anche il caso in cui il nodo destinatario non sia presente nella rete. Si potrebbe aver sbagliato a specificare il nome del nodo, oppure il nodo in quel momento non è collegato alla rete. In tal caso si spedisce un messaggio di errore (Net.NOSUCHNODE_ERROR) al nodo che si gestisce (ed infatti il messaggio viene inserito nella propria coda dei messaggi in uscita).

Ovviamente questo comportamento di default può essere cambiato in modo personalizzato, magari per gestire diversamente alcuni messaggi, o per ignorarli volutamente. Basterà ridefinire questo metodo. Magari alla fine della gestione di alcuni messaggi si può richiamare il handleMessage della classe base, per la gestione di default.

Per chi ha programmato in Windows (specialmente direttamente utilizzando l'SDK) noterà una netta somiglianza sia teorica che pratica con questo approccio. Del resto questo è quello utilizzato in un sistema orientato agli aventi (o ai messaggi).

Nella seguente figura è illustrato graficamente la situazione descritta. Il quadrato coi bordi rotondi rappresenta la separazione fisica fra le macchine su cui vengono eseguiti i programmi, e come si vede la comunicazione fra i nodi è virtuale (linea tratteggiata), in quanto comunicano passando dal server (cioè da Net).

La gestione della spedizione dei messaggi e la classe MessageDeliverer

Per i messaggi in uscita si è visto che si utilizza una coda. La gestione della spedizione dei messaggi avviene quindi in modo asincrono: quando si spedisce un messaggio questo non verrà inviato immediatamente, ma sarà messo in una coda. Ci sarà un Thread parallelo (che come si è detto viene fatto partire nella fase di inizializzazione della classe NodeHandler), che leggerà i messaggi dalla coda e li spedirà al nodo gestito. In questo modo il NodeHandler non viene bloccato da possibili (anzi probabili) problemi di rete intasata: potrà tranquillamente continuare a gestire altri messaggi in arrivo. Si sfrutta così appieno le caratteristiche del multithreading, cioè quello di tenere sempre occupata la CPU, con vantaggi nello sfruttamento del processore: quando il thread che si occupa dell'effettiva spedizione dei messaggi si bloccherà perché la rete è occupata, il NodeHandler potrà continuare a svolgere le proprie operazioni.

Il thread parallelo che si occupa di questo è un oggetto della classe MessageDeliverer, che appunto, semplicemente nel proprio main loop, legge i messaggi dall coda (passata come parametro al costruttore) e li scrive in un ObjectOutputStream (anche questo passato al costruttore). Anche stavolta, un MessageDeliverer scrive in uno stream senza sapere che sta inviando i messaggi in rete, quindi anche questa classe può essere utilizzata per scrivere in un qualsiasi ObjectOutputStream collegato ad un qualsiasi stream.

public class MessageDeliverer extends Thread {
    protected Thread parent;
	protected Queue messageQueue ;
	protected ObjectOutputStream oObjStream ;
  
	public MessageDeliverer (Thread parent, Queue q, ObjectOutputStream o) {
		super ();
		this.parent = parent;
		messageQueue = q ;
		oObjStream = o ;
	}

	public void run () {
		NodeMessage message ;
		try {
			while ( true ) {
				message = (NodeMessage) messageQueue.remove() ;
				Print( "Spedisco: " + message ) ;
				oObjStream.writeObject( message ) ;
			}
		} catch (IOException ex) {
			ex.printStackTrace() ;
			if( parent != null ) 
			    parent.stop ();
		}
	}
}

Al costruttore viene passato anche un Thread (parent): si tratta del Thread che creato quest'oggetto (si veda a proposito come un NodeHandler crea un MessageDeliverer). Se si hanno problemi con l'ObjectOutputStream (ad esempio se la connessione cade), e si ottiene la classica IOException, si potrà stoppare anche il Thread che ha creato l'oggetto, in modo che questo non continui l'esecuzione pensando che tutto sia a posto. Anche nel metodo execute della classe NodeHandler si gestisce un problema simile, ed infatti nella parte finally viene stoppato il MessageDeliverer creato.

Nella seguente figura viene illustrata la situazione graficamente: nella coda vengono inseriti i messaggi, sia dal NodeHandler stesso, che da altri NodeHandler; il MessageDeliverer estrae i messaggi dalla coda e li inserisce nell'ObjectOutputStream.

La classe Queue è una semplice classe per gestire una struttura FIFO (First In First Out) ed ingloba, come ci si può aspettare un Vector ed utilizza i metodi di questa classe. Quindi non presenta niente di particolare e non verrà trattata.

La prossima volta vedremo la classe più interessante del framework, anche perché è quella che si dovrà estendere nelle applicazioni. Si tratta della classe Node. Inoltre vedremo degli esempi di utilizzo di tale framework. E saranno presenti tutti i listati completi.

A presto :-)

Lorenzo Bettini

  Riferimenti Bibliografici

[1] L.Bettini "Client-Server in Java" MokaByte - Dicembre ’97
[2] M.Carli "La Serializzazione" MokaByte - Aprile ’97