MokaByte Numero 14 - Dicembre 1997      

.....

Client Server
in Java
    

    di Lorenzo Bettini   

Iniziamo a vedere alcuni programmi che implementano i paradigmi per le applicazioni distribuite: questa volta vediamo il Client-Server.  

   

La volta scorsa [1] avevamo visto una panoramica di alcuni paradigmi per le applicazioni distribuite, ed in particolare i mezzi che Java mette a disposizione per la realizzazione di questi paradigmi. Questa volta iniziamo ad vedere l’implementazione del più semplice di questi: il Client - Server.

Ricordiamo brevemente che in questo paradigma un programma chiamato Client richiede dei servizi ad un altro programma chiamato Server. Quest’ultimo è in ascolto di richieste da parte dei client, esegue tali richieste con le risorse che ha a disposizione, e rispedisce, se richiesto i risultati al client. Tali programmi possono risiedere anche su computer diversi: tutto questo è trasparente ad entrambi.

Sebbene questo paradigma sia abbastanza datato, ed in confronto agli altri meno potente, esistono tuttora molte applicazioni, soprattutto in Internet, che fanno uso di questo paradigma:

L’astrazione dalla locazione fisica di tali computer è ottenuta tramite il paradigma delle Socket, presentato per la prima volta nella Berkeley Software Distribution (BSD) della University of California a Berkeley. Una socket è simile ad una presa elettrica; tutto ciò che è in grado di comunicare tramite il protocollo TCP/IP può collegarsi ad una socket e comunicare tramite queste porta di comunicazione. Ovviamente Java mette a disposizione alcune classi per l’utilizzo delle socket (si veda a proposito l’articolo introduttivo [2]), tra cui la classe Socket. Usando questa classe un client può stabilire un canale di comunicazione con un host remoto. Si potrà comunicare attraverso questo canale utilizzando stream particolari, specializzati per le socket.

Quindi un client, per comunicare con un host remoto usando il protocollo TCP/IP, dovrà creare per prima cosa un oggetto Socket con tale host. Si dovrà specificare l’indirizzo IP dell’host, e il numero di porta. Sull’host remoto dovrà esserci un server che è in "ascolto" su tale porta. La classe Socket mette a disposizione due costruttori:

Socket( String host, int port ) throws IOException
Socket( InetAddress address, int port ) throws IOException

InetAddress è una classe di utilità per la gestione di indirizzi Internet (ad esempio ottenere l’indirizzo IP numerico, dato un indirizzo sotto forma di stringa).

Una volta creato un oggetto Socket, si possono ottenere gli stream ad esso associati tramite i metodi della classe Socket:

InputOutputStream getInputStream() throws IOException
OutputOutputStream getOutputStream() throws IOException

A questo punto la comunicazione può avere inizio: il client può scrivere sull’outputstream, come si fa con un normale stream, e ricevere dati dal server leggendo dall’inputsream.

 

Un semplice programma Client

Vediamo adesso un semplice programma Client: si tratta di un client HTTP che dato un URL richiede un file al server HTTP di quell’host.

 

/* HHTPClient              *
 * Esempio di Client HTTP  */

import java.net.*;
import java.io.*;

public class HTTPClient {
  public HTTPClient (String textURL) throws IOException {
    Socket socket = null;
    dissectURL (textURL);
    socket = connect ();
    try {
      getPage ();
    } finally {
      socket.close ();
    }
  }

  protected String host, file;
  protected int port;

  protected void dissectURL (String textURL) throws MalformedURLException {
    URL url = new URL (textURL);
    host = url.getHost ();
    port = url.getPort ();
    if (port == -1)
      port = 80;
    file = url.getFile ();
  }

  protected DataInputStream in;
  protected DataOutputStream out;

  protected Socket connect () throws IOException {
    System.err.println ("Connessione a " + host + ":" + port + "...");
    Socket socket = new Socket (host, port);
    System.err.println ("Connessione avvenuta.");

    BufferedOutputStream buffOut = new BufferedOutputStream (socket.getOutputStream ());
    out = new DataOutputStream (buffOut);
    in = new DataInputStream (socket.getInputStream ());

    return socket;
  }

  protected void getPage () throws IOException {
    System.err.println ("Richiesta del file " + file + " inviata...");
    out.writeBytes ("GET " + file + " HTTP/1.0\r\n\r\n");
    out.flush ();
    
    System.err.println ("Ricezione dati...");

    String input ;
    while ((input = in.readLine ()) != null)
        System.out.println (input);
  }

  public static void main (String args[]) throws IOException {
    if( args.length < 1 )
        throw new IOException( "Sintassi : HTTPClient URL" ) ;

    try {
        new HTTPClient (args[0]);
    } catch (IOException ex) {
        ex.printStackTrace ();        
    }

    System.out.println ("exit");
  }
}

Il funzionamento del programma è molto semplice: sulla riga di comando si passa un normale URL. Tale URL viene passato al costruttore della classe HTTPClient. Nel metodo dissectURL viene utilizzata una comoda classe messa a disposizione da Java, URL: passata al costruttore una stringa rappresentante l’url (ad esempio "http://www.myhost.com:80/mydir/myfile.html), è possibile ottenere l’host, la porta ed il nome del file dall’url, rispettivamente coi metodi: getHost, getPort, getFile. Come si vede se non viene specificata una porta si assume quella di default per i server web: la porta 80.

Nel metodo connect effettuiamo la connessione vera e propria aprendo una socket con l’host e sulla porta specificati:

Socket socket = new Socket (host, port);

Effettuata la connessione possiamo ottenere gli stream associati con i metodi della classe Socket getOutputStream e getInputStream. Creiamo poi un DataOutputStream ed un DataInputStream su tali stream ottenuti (effettivamente per ottimizzare la comunicazione in rete prima viene creato uno stream bufferizzato sullo stream di output, ma questi dettagli al momento non ci interessano).

A questo punto si deve richiedere il file al server web e quindi si spedisce tale richiesta tramite lo stream di output:

out.writeBytes ("GET " + file + " HTTP/1.0\r\n\r\n");

A questo punto non resta che mettersi in attesa, sullo stream di input, dell’invio dei dati da parte del server:

while ((input = in.readLine ()) != null)

Il contenuto del file viene stampato sullo schermo una riga alla volta.

Questo semplice programma illustra un esempio di client che invia al server una richiesta, e riceve dal server i dati richiesti. E’ questo che avviene quando tramite il nostro browser quando visitiamo una pagina web, anche se in modo senz’altro più complesso!

Lo "spirito" però rimane questo: il client apre una connessione col server, comunica al server quello che desidera tramite un protocollo di comunicazione (nell’esempio l’HTTP), ed attende la risposta del server (comunicata sempre tramite lo stesso protocollo). Il comando GET infatti fa parte del protocollo HTTP.

Per testare il programma non è necessaria una connessione ad Internet, basta avere un web server installato ed attivo e lanciare il programma in questo modo

java HTTPClient http://localhost/index.html

E sullo schermo verrà stampato il contenuto dell’intero file index.html (se il file viene trovato, ovviamente, altrimenti si otterrà il tipico errore di file non trovato a cui ormai la navigazione web ci ha abituati).

 

Un semplice programma Server

Vediamo adesso un esempio di programma Server. Un server rimane in attesa di connessioni su una certa porta, ed ogni volta che un client si connette a tale porta, il server ottiene una socket, tramite la quale può comunicare col client. Il meccanismo messo a disposizione da Java per questo è la classe ServerSocket, tramite la quale il server può appunto accettare connessioni dai client attraverso la rete.I passi tipici di un server saranno quindi:

Infatti il metodo accept della classe ServerSocket crea un oggetto Socket per ogni connessione. Il server potrà poi comunicare, come fa un client: estraendo gli stream di input ed output dalla socket.

I costruttore della classe ServerSocket sono:

ServerSocket(int port) throws IOException
ServerSocket(int port, int count) throws IOException

A parte il parametro che specifica la porta (già spiegato), il parametro count permette di specificare il numero di richieste di connessioni messe in coda dal sistema operativo (il default per questo parametro è 50). Infatti dopo che il ServerSocket è stato creato il sistema operativo si mette subito in attesa di connessioni; queste richieste saranno messe in una coda e saranno rimosse una per volta, ad ogni chiamata del metodo

Socket accept() throws IOException

Quindi count non limita il numero di connessioni che un server è in grado di mantenere, ma il numero di richieste di connessioni che verranno messe in coda, se il server ci mette molto per accettare nuove connessioni. Infine se come numero di porta viene specificato 0 il sistema operativo seleziona un numero di porta valido e non occupato. E’ comunque possibile ottenere il numero di porta sul quale il ServerSocket è in ascolto di connessioni utilizzando il metodo

int getLocalPort()

L’ultimo metodo della classe ServerSocket è

void close() throws IOException

Che chiude il ServerSocket, ma non le connessioni che sono stare stabilite (queste dovranno essere chiuse chiamando il metodo close della classe Socket). Tipicamente quando una socket viene chiusa da un lato, sull’altro lato si avrà una IOException.

Segue un semplice esempio di Server (non a caso chiamato SimpleServer)

/* SimpleServer.java */

import java.net.*;
import java.io.*;

public class SimpleServer {
  public static void main (String args[]) throws IOException {
    int port = 0 ;
    if (args.length == 1)
      port = Integer.parseInt( args[0] ) ;

    System.out.println ("Server in partenza sulla porta " + port);
    ServerSocket server = new ServerSocket (port);
    System.out.println ("Server partito sulla porta " + server.getLocalPort() ) ;

    System.out.println ("In attesa di connessioni...");
    Socket client = server.accept ();
    System.out.println ("Richiesta di connessione da " + client.getInetAddress ());

    /* il server viene chiuso, ma la connessione col client rimane attiva */
    server.close ();

    try {
      InputStream i = client.getInputStream ();
      OutputStream o = client.getOutputStream ();
      PrintStream p = new PrintStream (o) ;
      p.println("BENVENUTI.");
      p.println("Questo e' il SimpleServer :-)") ;
      p.println () ;
      p.println("digitare HELP per la lista di servizi disponibili" ) ; 

      int x;
      ByteArrayOutputStream command = new ByteArrayOutputStream() ;
      String HelpCommand = new String( "HELP" ) ;
      String QuitCommand = new String( "QUIT" ) ;
      while ((x = i.read ()) > -1) {
        o.write (x);        
        if( x == 13 ) { /* newline */
            p.println() ;
            if( HelpCommand.equalsIgnoreCase( command.toString() ) ) {
                p.println( "Il solo servizio disponibile e' l'help," ) ;
                p.println( "e QUIT per uscire." ) ;
                p.println( "Altrimenti che SimpleServer sarebbe ;-)" ) ;                
            } else if ( QuitCommand.equalsIgnoreCase( command.toString() ) ) { 
                p.println( "Grazie per aver usato SimpleServer ;-)" ) ;
                p.println( "Alla prossima. BYE" ) ;
                try { 
                    Thread.sleep( 1000 ) ;
                } finally {
                    break ;
                }
            } else {
                p.println( "Comando non disponibile |-(" ) ;
                p.println( "Digitare HELP per la lista dei servizi" ) ;
            }
            command.reset() ;
        } else if ( x != 10 ) /* carriage return */
            command.write(x) ;
      }
    } finally {
      System.out.println ("Connessione chiusa");
      client.close ();
    }
  }
}

L’esempio mostra l’utilizzo di tutti i metodi suddetti della classe ServerSocket. In particolare accetta da linea di comando un parametro che specifica la porta su cui mettersi in ascolto di richieste di connessioni. Se non viene passato nessun argomento si userà la porta scelta dal Sistema Operativo. Dopo la creazione dell’oggetto ServerSocket ci si mette in ascolto di connessioni, e appena se ne riceve una, si chiude il ServerSocket (cioè si accetta una sola connessione). Tramite l’oggetto Socket restituito dal metodo accept si ottengono i due stream per comunicare col client. Si attende poi che il client invii dei comandi: ogni volta che viene letto un carattere, questo viene rispedito al client, in modo che quest’ultimo possa vedere quello che sta inviando. Appena viene digitato un newline (cioè un INVIO o ENTER) si controlla se il servizio richiesto (memorizzato via via in un buffer) è disponibile, e si risponde in modo opportuno. Si noti come tutte le comunicazioni fra il server ed il client siano racchiuse in un blocco try...finally: se nel frattempo avviene un’eccezione si è comunque sicuri che la connessione verrà chiusa (cioè non rimane pendente). L’eccezione in questione è tipicamente una IOException dovuta alla disconnessione da parte del client.

A proposito di client: il client dov’è in questo esempio? Come nell’altro esempio avevamo usato un server già esistente (web server) per testare il nostro client, questa volta per testare il nostro server utilizzeremo un client classico: il telnet.

Quindi se si è lanciato il server con la seguente riga di comando

java SimpleServer 9999

Basterà utilizzare da un’altro terminale (ad esempio un’altra shell del DOS in Windows 95, o un altro xterm sotto Linux) il seguente comando

telnet localhost 9999

Nella seguente figura si può vedere una sessione di esempio del programma.

 

Come detto il server dell’esempio precedente chiude il ServerSocket, appena ha ricevuto una richiesta di connessione, quindi tale server funziona una sola volta! Un server che "si rispetti", invece deve essere in grado di accettare più connessioni, ed inoltre dovrebbe essere in grado di soddisfare più richieste contemporaneamente. Per risolvere questo problema si deve ricorrere al multithreading, per il quale Java offre diversi strumenti. Il programma sarà così modificato:

In effetti è questo quello che avviene nei server di cui si è già parlato. Se si prende un programma scritto in C che utilizza le socket si potrà vedere che appena viene ricevuta una richiesta di connessione, il programma si duplica (esegue una fork()), ed il programma figlio lancia un programma che si occuperà di gestire la connessione appena ricevuta. Nel nostro caso basterà creare un nuovo thread e passargli la socket della nuova connessione.

Segue il programma modificato per trattare più connessioni contemporaneamente:

/* SimpleMTServer.java */

import java.net.*;
import java.io.*;

public class SimpleMTServer extends Thread {
  protected Socket client ;

  public SimpleMTServer( Socket socket ) {
    System.out.println( "Arrivato un nuovo client da " + socket.getInetAddress ()) ;
    client = socket ;
  }

  public void run() {
    try {
      InputStream i = client.getInputStream ();
      OutputStream o = client.getOutputStream ();
	.
	.
	.
	<come sopra>
	.
	.
	.
      }
    } catch (IOException e) {
        e.printStackTrace() ;
    } finally {
      System.out.println ("Connessione chiusa con " + client.getInetAddress ());
      try {
        client.close ();
      } catch (IOException e) {
        e.printStackTrace() ;
      }
    }
  }

  public static void main (String args[]) throws IOException {
    int port = 0 ;
    Socket client ;
    
    if (args.length == 1)
      port = Integer.parseInt( args[0] ) ;

    System.out.println ("Server in partenza sulla porta " + port);
    ServerSocket server = new ServerSocket (port);
    System.out.println ("Server partito sulla porta " + server.getLocalPort() ) ;
        
    while( true ) {
        System.out.println ("In attesa di connessioni...");
        client = server.accept() ;
        System.out.println ("Richiesta di connessione da " + client.getInetAddress ());
        (new SimpleMTServer( client )).start() ;
    }
  }
}

Come si vede si sono dovuti fare alcuni cambiamenti:

Notare che poiché il metodo run della classe Thread, che viene ridefinito dalla nostra classe, non lancia nessuna eccezione, dobbiamo intercettare tutte le eccezione all’interno del metodo: in questo caso l’eccezione in questione è IOException che può essere lanciata anche quando si cerca di chiudere la comunicazione.

 

Un esempio un po’ più pratico

Veniamo adesso ad un esempio un po’ più interessante, senz’altro più utile: un sistema per "chattare". Si vuole costruire sia il Server Chat, che il Client Chat. In questo caso si capisce chiaramente che il server dovrà essere multithreaded (chattare da soli non ha molto senso, no?). Per motivi di spazio non sono stati inseriti i listati di quest'ultimo esempio direttamente nell'articolo, ma sono comunque scaricabili. Il server (classe ChatServer) segue lo schema dell’esempio precedente, e anche il client (ChatClient) non si discosta molto dal primo esempio di client visto all’inizio. Nella classe del server è presente un vettore condiviso da tutti i thread (infatti si tratta di un membro statico) in cui vengono registrati tutti i thread attivati per gestire le connessioni. Ovviamente tutte le modifiche che si faranno a tale vettore dovranno avvenire in modo sincronizzato (tramite un blocco synchronized). In questo modo quando arriva un messaggio dal client, il thread che se ne occupa ha la possibilità, scorrendo tale vettore, di diffondere il messaggio a tutti gli altri thread, che a questo punto spediranno il messaggio ai client che gestiscono (non ha caso il metodo che si occupa di questo si chiama broadcast).

In questo esempio viene fatto uso della serializzazione ([3]), cioè vengono creati degli ObjectInputStream e ObjectOutputStream sui rispettivi stream delle socket, ovviamente sia dal client che dal server (altrimenti la comunicazione non potrebbe essere effettuata).

L’esempio è molto semplice, però è funzionante: basta lanciare il server, specificando la porta su cui si metterà in ascolto:

java ChatServer 9999

e poi ci si può collegare a tale server col ChatClient da qualsiasi macchina

java ChatClient localhost 9999 (in locale)
java ChatClient <nome host> 9999 (ad un server remoto)

Comparirà una finestra con un’area di testo, dove verranno visualizzati via via i messaggi, ed una casella di input dove è possibile scrivere i propri messaggi. Nel momento in cui si preme invio, il messaggio viene spedito (anche il mittente riceve il messaggio che ha scritto). Non sono presenti, sia nel server, che nel client, tutti i servizi messi a disposizione dai client e server chat professionali, ma è anche vero che queste classi sono solo uno scheletro, che può essere personalizzato a piacere: basterà derivare dalle due suddette classi e anche da ChatFrame, il frame che si occupa della visualizzazione.

Nella seguente figura è visualizzato un possibile output dei due programmi (eseguiti in locale)

 

 

Conclusioni

Questa volta abbiamo iniziato a vedere alcune semplici implementazione dei paradigmi presentati in [1]. Come si può vedere i programmi scritti sono molto piccoli e compatti; è vero che non fanno molto, però è anche vero che scritti in un altro linguaggio (vedi C) sarebbero stati molto più lunghi e complessi. Senza contare che per utilizzare il multithreading e la sincronizzazione si sarebbe dovuti ricorrere a chiamate di libreria (che il più delle volte sono fortemente dipendenti dal sistema operativo), mentre qui le abbiamo a disposizione a livello di linguaggio.

La prossima volta continueremo a vedere alcune implementazioni degli altri paradigmi.

Stay tuned ;-)

Listati

HTTPClient.java
SimpleServer.java
SimpleMTServer.java
ChatClient.java
ChatServer.java

Riferimenti Bibliografici

[1] L.Bettini "L’unione fa la forza - La programmazione distribuita in Java" MokaByte - Novembre ’97
[2] L.Bertoncello "Introduzione alla programmazione in rete - Sockets" MokaByte - Ottobre ’96
[3] M.Carli "La Serializzazione" MokaByte - Aprile ’97