MokaByte Numero 16 - Febbraio 1997      

. Client Server
in Java:
Nodi e Reti (II parte)

    di Lorenzo Bettini   

Continuiamo a vedere un piccolo package con classi estendibili, per la creazione di applicazioni distribuite, secondo il paradigma Client-Server. Questa volta vedremo anche un esempio funzionante: un sistema Chat (un po' più complesso). 

   

 

La volta scorsa [1] avevamo iniziato a vedere un piccolo framework, costituito dal package NetNode, per scrivere piccole applicazioni distribuite. Ovviamente il package potrà essere esteso e personalizzato, comunque, anche così come è, può essere utilie per scrivere qualche applicazione, anche abbastanza complessa. Alla fine dell'articolo vedremo infatti come utilizzare questo package per costruire un sistema di chat, più complesso e più completo di quello visto in [2].

La classe Node

Questa è la classe principale del package, ed è quella che interessa più da vicino l'utente. Come già accennato la scorsa volta, infatti, un Nodo rappresenta un'applicazione, registrata con un certo nome presso una rete (il server), che può interagire con le altre applicazioni (Nodi) collegate con la stessa rete. un Node rappresenta quindi un client.

Quello che l'utente dovrà fare è derivare una classe da Node e ridefinire alcuni metodi in modo da ottenere un'applicazione personalizzata. Ovviamente non si dovrà preoccupare dei dettagli di comunicazione: questi verranno gestiti direttamente dalla classe Node.

Anche le classi che avevamo visto la scorsa volta possono essere personalizzate, ma è più raro che si abbia l'esigenza di modificare tali comportamenti di default.

Iniziamo subito a vedere la classe Node:

public class Node extends Thread {
  
  protected String nodeName ;
  protected Queue outMessageQueue ; // coda dei messaggi in uscita
  protected Queue inMessageQueue ; // coda dei messaggi in entrata
  protected InputStream iStream ; // per la comunicazione in rete
  protected OutputStream oStream ;

  protected String netHostName ;
  protected int netPort ;

  public Node( String Name, String NetHostName, int NetPort )
      throws IOException {
    super( "Node - " + Name ) ;   
      nodeName = Name ;
    netHostName = NetHostName ;
    netPort = NetPort ;
    
    connect( netHostName, netPort ) ;
    login( nodeName ) ;
    outMessageQueue = new Queue() ;
    inMessageQueue = new Queue() ;
  }

La stringa nodeName tiene memorizzato il nome del Nodo (col quale è stato registrato presso la rete). I due stream verranno utilizzati per comunicare; ancora una volta sono utilizzati InputStream e OutputStream, e quindi nonostante un Node verrà utilizzato molto sicuramente per comunicazioni in rete, potrebbe anche essere utilizzato per ricevere e spedire i dati da un qualsiasi canale di comunicazione, trasparentemente, senza necessità di modifiche.

Il costruttore prenderà come parametri

Vengono poi eseguiti i metodi connect e login, che servono rispettivamente per collegarsi al server Net e per registrarsi col nome passato al costruttore.

  protected void connect( String host, int port ) throws IOException {
    System.out.println( "Connessione con " + host + ":" + port + " ...") ;
    Socket s = new Socket( host, port ) ;
    System.out.println( "Socket creata..." ) ;
    iStream = s.getInputStream() ;
    oStream = s.getOutputStream() ;
    System.out.println( "Connessione stabilita." ) ;    
  }

  protected void login( String nodeName ) throws IOException {
    try {
      System.out.println( "Login " + nodeName + " ..." ) ;
      ObjectOutputStream o = new ObjectOutputStream( oStream ) ;
      o.writeObject( nodeName ) ;     
      ObjectInputStream i = new ObjectInputStream( iStream ) ;
      Boolean registered = (Boolean)i.readObject() ;
      if ( ! registered.booleanValue() )
        throw new IOException( "Nome " + nodeName + " già in uso" ) ;
      System.out.println( "Login con successo!" ) ;
    } catch ( ClassNotFoundException ce ) {
      ce.printStackTrace() ;
    } catch ( IOException ex ) {
      try {
        ex.printStackTrace() ;
        oStream.close() ;
      } catch ( IOException ex2 ) {
        ex.printStackTrace() ;
      }
      // rilancia la prima eccezione
      throw ex ;
    }
  }

Viene così creata la socket col server e gli viene passato il nome (sempre tramite la serializzazione). Si resterà poi in attesa della risposta del server. In realtà tale risposta è spedita dal NodeHandler (si veda lo scorso articolo), ma questo è trasparente a Node.

Si noti tutti i costrutti try...catch...finally; questi sono necessari anche per la chiusura di una socket: anche in quel momento qualcosa può andare storto, e tale eccezione può essere lanciata.

A questo punto l'oggetto è stato costruito, e quindi è pronto per l'"uso"; questo vuol dire che si può eseguire il metodo start() (si ricordi infatti che Node deriva da Thread).

  public void run() {
    MessageDeliverer deliverer ;
    MessageReceiver receiver ;

    try {
      deliverer = new MessageDeliverer(
        this, outMessageQueue, new ObjectOutputStream( oStream ) ) ;
      receiver = new MessageReceiver(
        this, inMessageQueue, new ObjectInputStream( iStream ) ) ;
    } catch ( IOException ioe ) {
      System.err.println( ioe ) ;
      return ;
    }

    try {
      deliverer.start() ;
      receiver.start() ;  
      listen() ;
    } catch ( Exception e ) {
      System.err.println( e ) ;
    } finally {
      deliverer.stop() ;
      receiver.stop() ;
      closeConnection() ;
      System.exit(0) ;
    }
  }

  protected void closeConnection() {
    System.out.println( "Chiusura della connessione..." ) ;
    try {
      oStream.close() ;
    } catch ( IOException e ) {
      System.err.println( e ) ;
    }
  }

Come si può notare anche in questo caso viene sfruttato il meccanismo del MessageDeliverer (si vede lo scorso articolo), per ottimizzare le prestazioni; inoltre viene utilizzato anche il meccanismo simmetrico: un MessageReceiver: mentre il MessageDeliverer estrae i messaggi da una coda e li scrive in uno stream di output (con la serializzazione), un MessageReceiver legge da un input stream (ancora tramite la serializzazione) e li memorizza da una coda. Quindi un Node invece di leggere direttamente dallo stream di input (collegato, in questi casi ad una connessione di rete), estrae i messaggi da una coda. Questo può risultare utile: altri thread possono "spedire" dei messaggi ad un Node semplicemente inserendoli nella coda. Il Node in questione non sa da dove porovengono i messaggi: semplicemente li estrare dalla coda e li processa. Ancora una volta tale meccanismo ricorda quello di un sistema event-driven come ad esempio Windows.

Viene poi chiamato il metodo listen che rappresenta il main loop: il ciclo infinito in cui il Node ascolta dei messaggi dalla rete e li processa. Il ciclo verrà interrotto o perchè l'utente stesso decide di interrompere l'esecuzione dell'applicazione, o a causa di problemi di rete (il caso più frequente è quello in cui il server Net cessa l'esecuzione).

  protected void listen() throws IOException {
    startNode() ;
    Print( "In ascolto di messaggi..." ) ;

    NodeMessage message ;
    while ( true ) {
      message = ( NodeMessage )inMessageQueue.remove() ;
      PrintMessage( "Ricevuto: "" + message ) ;        
      handleMessage( message ) ;
    }
  }

Il loop è molto semplice: appena è presente un messaggio nella coda, questo viene estratto, e viene processato chiamando il metodo handleMessage (simile al ciclo di prelievo dei messaggi di un'applicazione Windows).

Fin qui non si dovrebbe avere la necessità di ridefinire nessun metodo, a meno che non si voglia modificare qualche comportamento o personalizzare a basso livello le comunicazioni.

Quello che deve essere personalizzato è la gestione dei messaggi, e cioè proprio il metodo handleMessage. L'implementazione di default di questo metodo consiste semplicemente nello stampare il messaggio ricevuto Un'implementazione alternativa poteva essere quella di lasciare tale metodo astratto, e quindi l'intera classe Node sarebbe astratta, ma in questo modo l'implementazione di default potrebbe essere utilizzata semplicemente per testare i messaggi spediti ad un nodo.

Un altro metodo che dovrebbe essere ridefinito è startNode che, come si può intuire, viene chiamato all'inizio di listen, poco prima di entrare nel main loop. Tale metodo può essere ridefinito per eseguire delle operazioni di start up. La versione di default semplicemente si limita a stampare un messaggio.

  protected void startNode() {
    Print( "Nodo attivo..." ) ;        
  }

Prima di vedere un esempio di utilizzo di tale pacchetto è interessante vedere i metodi per spedire i messaggi (si veda lo scorso articolo per una descrizione della struttura dei messaggi):

  public void sendMessage( String dest, int opCode, Object content ) {
    sendMessage( nodeName, dest, Thread.currentThread().getName(),
      opCode, content ) ;
  }
    
  public void sendMessage( String source, String dest,
      String processname, int opcode, Object content ) {
    NodeMessage mesg = new NodeMessage( source, dest, processname, opcode, content ) ;
    PrintMessage( "Sending " + mesg ) ;
    outMessageQueue.add( mesg ) ;
  }

La seconda versione permette di specificare più dati, che nella prima versione invece vengono ricavati automaticamente.

E' inoltre possibile mandare un messaggio a tutti i nodi della rete (si può anche specificare se noi stessi dobbiamo ricevere una copia di tale messaggio).

  public void broadcast( Object content, boolean meToo ) {
    if ( meToo )
      sendMessage( null, Net.BROADCAST, content ) ;
    else
      sendMessage( null, Net.BROADCASTBUTNOTME, content ) ;
  }

Dall'implementazione si può vedere che viene spedito effettivamente un solo messaggio: sarà il server che spedirà più copie, una per ogni nodo della rete. Questo accade anche nella posta elettronica, quando mandiamo un messaggio a più persone contemporaneamente. Per avere la conferma si può analizzare il codice NodeHandler e Net.

Un semplice esempio

Il primo esempio che vediamo è molto semplice: si tratta di un'applicazione che "gioca a ping pong": un nodo spedisce PING ed attende PONG, l'altro nodo, attende PING e spedisce PONG. L'esempio non è molto intelligente, comunque è molto usato come semplice esempio per testare la comunicazione fra applicazioni distribuite. Volendo far proseguire questo precesso all'infinito si può togliere i commenti al ciclo while.

class PingPongProcess extends Thread {
    Node parentNode ;
    String pingpong ;
    String answer ;
    String destNode ;
    
    public PingPongProcess( Node n, String pp, String dest ) {
        parentNode = n ;
        pingpong = pp ;
        if ( pp.equals( "PING" ) )
            answer = new String( "PONG" ) ;
        else
            answer = new String( "PING" ) ;
        destNode = dest ;
    }
    
    public void run() {
        if ( pingpong.equals( "PING" ) )
            parentNode.sendMessage( destNode, 0, pingpong ) ;
        // while ( true ) {
            waitForAnswer() ;
        //}
    }
    
    synchronized protected void waitForAnswer() {
        try {
            wait() ;
            // attende un po'
            Thread.sleep( 1000 ) ;
            parentNode.sendMessage( destNode, 0, pingpong ) ;
        } catch ( InterruptedException e ) {
            e.printStackTrace() ;
        }
    }
    
    synchronized public void takeMessage( NodeMessage message ) {
        if ( ((String)message.Content).equals( answer ) )
            notify() ;
    }
}

Nel metodo run() viene spedito il messaggio e si rimane in attesa (tramite wait). takeMessage, risveglierà il thread (tramite notify). Tale metodo sarà richiamato dal Nodo (il cui codice segue) quando sarà ricevuto il messaggio. Ovviamente il thread sarà risvegliato solo se il messaggio contiene proprio la stringa cercata.

import java.io.* ;
import NetNode.* ;

public class NodePingPong extends Node {
    String pingpong ;
    String destNode ;
    PingPongProcess P ;
    
    public NodePingPong( String pp, String dest, String name, String host, int port ) 
            throws IOException {
        super( name, host, port ) ;
        pingpong = pp ;
        destNode = dest ;
    }
    
    public void startNode() {
        P = new PingPongProcess( this, pingpong, destNode ) ;
        P.start() ;
    }
    
    public void handleMessage( NodeMessage m ) {
        P.takeMessage( m ) ;
    }
    
    public static void main( String args[] ) throws IOException {
        if ( args.length != 5 ) {
            System.err.println( "Uso : NodePingPong (PING|PONG) dest name host port" ) ;
            System.exit(1) ;
        }
            
        NodePingPong n = new NodePingPong( args[0], args[1], args[2], args[3],
            Integer.parseInt( args[4] ) ) ;
        n.start() ;
    }               
}

Nel metodo startNode si fa fa partire il processo (o PING o PONG, a seconda di cosa è stato passato sulla linea di comando) e handleMessage semplicemente richiama il metodo takeMessage del processo (che dovrebbe risvegliare il thread).

Per testare l'applicazione basterà far partire il server Net, ed in altre due shell far partire i due nodi specificando in un caso PING e nell'altro PONG. Si dovrà anche specificare il nome dell'altro nodo con cui comunicare ed il nome con cui ci registriamo presso la rete.

Importante: perchè non ci sia deadlock si deve far partire prima il nodo PONG e dopo quello PING (infatti PING spedisce subito il messaggio, e l'altro nodo deve essere quindi già connesso alla rete).

Quindi le istruzioni per testare l'esempio sono:

  1. java NetNode.Net 9999 (per far partire il server)
  2. java NodePingPong PONG pingNode pongNode localhost 9999 (fa partire il nodo PONG)
  3. java NodePingPong PING pongNode pingNode localhost 9999 (fa partire il nodo PING)

Nella figura è illustrato l'output. I vari messaggi indicano il traffico dei messaggi. Da notare che "sending" viene scritto da SendMessage, mentre spedisco è scritto dal MessageDeliverer e corrisponde al momento dell'effettiva spedizione del messaggio.

 

Un esempio più complesso: un Chat System

Già in [2] avevamo visto un semplice sistema chat; questo però non era dotato delle classiche caratteristiche a cui siamo abituati quando usiamo i programmi per chattare. In particolare mancavano le seguenti caratteristiche:

Sfruttando questo package sarà possibile realizzare un tale sistema molto semplicemente. Tra l'altro non sarà necessario implementare un server chat: basterà utilizzare Net come server per questo scopo; quindi si dovrà solo scrivere una classe derivata da Node e gestire i vari messaggi in modo opportuno.

Sarà utilizzata una finestra simile a quella dell'altra volta, ma in più sarà presente una lista con tutti gli utenti momentaneamente collegati al server. Ovviamente tale lista sarà aggiornata in tempo reale, per visualizzare nuovi utenti o per mostrare quelli che si sono scollegati. Non sarà mostrato il codice della frame (che comunque è presente nella sezione dei listati). Tale frame ha l'aspetto mostrato in figura:

Il metodo startNode visualizza semplicemente la finestra e richiede i nomi di tutti i nodi collegati alla rete tramite il metodo getAllNodesName di classe Node:

  public void startNode() {
      frame.show ();      
      getAllNodesNames() ;
  }

Il metodo handleMessage è stato ridefinito in modo da gestire in modo opportuno i vari messaggi della rete:

  public void handleMessage( NodeMessage message ) {
    switch ( message.OpCode ) {
        case Net.NEWNODE_NOTIFY :
            frame.AddText( "--- " + (String)message.Content + 
                " e' entrato in chat" ) ;
            frame.AddName( (String)message.Content ) ;
            break ;
        case Net.NODEREMOVED_NOTIFY :
            frame.AddText( "--- " + (String)message.Content + 
                " ha lasciato la chat" ) ;
            frame.RemoveName( (String)message.Content ) ;
            break ;
        case Net.GETALLNODES :
            frame.FillList( (Vector) message.Content ) ;
            break ;
        default :
            frame.AddText( 
                ( message.Source.equals( nodeName ) ? "> " : 
                "(" + message.Source + 
                ( message.OpCode == ChatNode.PERSONAL ? 
                " PERSONAL " : "" ) +") " ) + 
                (String) message.Content ) ;
    }
  }

Quando si riceve un messaggio NEWNODE_NOTIFY vuol dire che un nuovo nodo si è connesso alla rete, e quindi visualiziamo questa informazione. Allo stesso modo, se si riceve NODEREMOVED_NOTIFY, vuol dire che un nodo si è sconnesso dalla rete, e anche in questo caso visualiziamo l'informazione. Il messaggio GETALLNODES conterrà la lista di tutti i nodi connessi alla rete (si ricordi che tale messaggio è la risposta alla chiamata di getAllNodesNames). In tutti gli altri casi si visualizza il messaggio; in particolare se il mittente siamo noi stessi il messaggio sarà preceduto da un ">", altrimenti il mittente sarà specificato tra parentesi. Inoltre si visualizza la stringa "PERSONAL" se tale messaggio non è stato spedito a tutti gli utenti.

Infatti se nella lista degli utenti se ne seleziona alcuni, il messaggio sarà spedito solo agli utenti selezionati; il metodo SendString di classe ChatNode, controlla se ci sono alcuni elementi selezionati nella lista ed in tal caso spedisce un messaggio particolare (il codice PERSONAL è definito all'interno della classe ChatNode):

  public void SendString( String s ) throws IOException {
    String[] names = frame.GetSelectedNames() ;
    if ( names.length > 0 ) {
        for ( int i = 0 ; i < names.length ; i++ )
            sendMessage( names[i], ChatNode.PERSONAL, s ) ;
    } else
        broadcast( s, true ) ; 
  }

In questo modo si ha a disposizione un sistema di chat, (non completo come quelli che si utilizzano), ma semplice e funzionante. Eventualmente si può estendere ulteriormente ChatNode ed aggiungere magari la gestione di altri messaggi; gli altri si possono lasciare gestire alla classe base semplicemente chiamando come prima cosa all'interna del metodo handleMessage la versione della classe base, semplicemente con

super.handleMessage( message )

Anche l'interfaccia può essere migliorata: basterà lasciare gli stessi memebri presenti in quella attuale (la frame è rappresentata dalla classe ChatFrame) e ridefinire il metodo newFrame della classe ChatNode che viene chiamata quando si deve creare la frame.

Nella seguente figura è illustrato un esempio di esecuzione del programma; come si può notare l'utente pippo ha spedito un messaggio privato all'utente lorenzo (per convenzione il nome degli utenti è sempre preceduto da ch).

Questo secondo articolo conclude per ora la trattazione di questo pacchetto: sarà ripreso ed esteso con nuove features via via che vedremo nuove tecniche.

La prossima volta tratteremo il class loader, che ci servirà per realizzare in futuro dei semplici agenti mobili.

A presto :-)

Lorenzo Bettini

Sorgenti

nodesrc.zip

  Riferimenti Bibliografici

[1] L.Bettini "Client-Server in Java, Nodi e Reti (I parte)" MokaByte - Gennaio ’97
[2] L.Bettini "Client-Server in Java" MokaByte - Dicembre ’97