MokaByte Numero 22 - Settembre 1998
 
 
 
 

di 
Michele Vignali, 
Giovanni Rimassa 

Giulio Destri 

Applicazioni 3-tier
Socket o RMI?
 

 



In un'applicazione Java distribuita esistono diversi modi per far interagire tra loro gli oggetti attraverso una rete. Le comunicazioni con i socket sono più adatte alle applicazioni dove i componenti devono scambiare solamente dati fra loro, mentre RMI è preferibile quando esista la necessità di invocare i metodi di oggetti remoti o di trasmettere oggetti lungo la rete

Introduzione
In un'applicazione distribuita esistono molti modi per far interagire gli oggetti attraverso una rete. I sistemi di Remote Procedure Call (RPC), ovvero la possibilità di richiamare procedure di programmi operanti su un diverso host, hanno migliorato le capacità di interazione, mascherando la spedizione esplicita dei dati necessaria con i socket.
Con Java si possono seguire due strade per realizzare l'interazione fra oggetti distribuiti: la prima è usare Remote Method Invocation (RMI) che è incorporato nel JDK 1.1 e può funzionare solo fra oggetti Java; la seconda è rappresentata dal fatto che lo standard CORBA consente di sviluppare applicazioni distribuite che integrano Java ed altri linguaggi.
In questo articolo, dopo una breve descrizione di RMI (consultate [1] per approfondimenti) viene presentata una applicazione three-tier, derivata da quella descritta in [2], in cui le comunicazioni fra client e middle tier avvengono con il modello di RMI. Sono analizzate ed confrontate due diverse implementazioni di interazione con metodi remoti, una che usa i socket e una basata su RMI.

RMI in Java
Il concetto base di RMI è molto simile al meccanismo di RPC, in uso in linguaggi come il C: la sigla RMI esprime l'analogo ad oggetti del concetto espresso dalla sigla RPC. Una differenza è che un metodo deve essere sempre associato ad un oggetto; in particolare RMI richiede che l'oggetto invocato (attraverso il metodo opportuno) sia già operativo. Lo schema utilizzato da Java RMI per collegare oggetti in spazi di indirizzamento diversi è detto publish and subscribe: chi ha dei servizi da offrire li espone pubblicamente, mentre chi necessita di un servizio si iscrive presso il fornitore. Il "documento" che specifica i servizi disponibili è l'interfaccia remota, cioè un'interfaccia Java che estende java.rmi.Remote.
Un oggetto che attraverso l'interfaccia remota mette a disposizione dei metodi è un server, mentre un oggetto che invoca tali metodi è un client. Un dato oggetto può essere contemporaneamente sia server sia client, questi termini individuano soltanto il chiamante ed il chiamato in relazione ad una particolare azione. Il client invoca il metodo del server RMI usa lo stub o rappresentante remoto ([1]), ossia una "vista remota" dell'oggetto server, che contiene i metodi che dovranno essere invocati. Lo stub viene creato sull'host dove opera il client e viene considerato come se si trattasse a tutti gli effetti di un elemento locale. I canali di comunicazione di RMI si occupano in modo trasparente di trasmettere le richieste di esecuzione dei vari metodi all'oggetto server RMI, che può risiedere su un'altra macchina. In tal modo il client interagisce con una risorsa in apparenza locale, ma in realtà il codice viene eseguito dal processore del server remoto.
Una caratteristica molto importante di RMI è che gli oggetti possono essere passati come parametri alle chiamate ai metodi remoti, trasmettendo uno stub degli oggetti originari. L'oggetto reale rimane sempre sulla macchina dove è stato inizialmente creato. Lo stub passato come parametro inoltra le chiamate che riceve all'oggetto originale attraverso i meccanismi di comunicazione di RMI.
In questo modo un client può trasmettere un proprio stub al server, permettendo ad esso di invocare i propri metodi. Ma con RMI si possono anche trasmettere interi oggetti attraverso la rete, con l'unica condizione che siano serializzabili (per esempio bottoni ed oggetti AWT); questa capacità di migrazione dinamica del codice, simile al meccanismo delle applet, è una speciale caratteristica di Java che diviene molto utile nelle applicazioni distribuite.
Si veda [3] per approfondimenti sull'importante concetto di serializzazione. In un sistema distribuito un client deve avere modo di trovare i server di cui ha bisogno; RMI fornisce per questo un naming service, noto anche come RMI registry, attraverso cui i client possono ottenere gli stub per un dato server a partire dal nome del server stesso. L'invocazione del server viene effettuata tramite la sintassi URL RMI, ossia con un'istruzione del tipo
 
desiredObject=(cast a desiredObject) Naming.lookup("rmi://ServerHostName:port/oggettoServer");
I sistemi distribuiti non sempre sono ben descrivibili attraverso un modello client-server puro. Se in molte applicazioni, come le transazioni bancarie, tale modello funziona bene (il client inizia la transazione con la opportuna richiesta, poi il server compie le operazioni e invia i dati indietro al client a transazione completata), per altri casi tale modello non è più sufficiente. In queste applicazioni peer-to-peer, dove entrambi gli oggetti che prendono parte alla operazione svolgono, di volta in volta, il ruolo di server e di client, l'interazione deve essere completamente bidirezionale.
Il metodo di passaggio di uno stub come argomento di una chiamata remota consente di realizzare i sistemi peer-to-peer. All'inizio della interazione uno dei due oggetti funge da server e l'altro da client. Ad un certo punto il client invoca un opportuno metodo del server passandogli come argomento un proprio stub, diventando a sua volta un server. Da questo momento in poi il sistema è peer-to-peer: entrambi gli oggetti possiedono uno stub dell'altro per invocarne i metodi.

Applicazione gestionale 3-tier
Come esempio viene ora descritta un'applicazione di accesso ad un database secondo il modello three-tier, derivata da quella presentata in [2], il cui sorgente lo potete torvare sul  sito Ftp di Infomedia. Il primo livello dell'applicazione (client tier) contiene l'interfaccia utente, lo strato intermedio (middle tier) contiene le regole di accesso ai dati e la definizione delle interfacce utente possibili, e il terzo livello è costituito dal database con i dati veri e propri memorizzati su file. Le comunicazioni fra il livello intermedio ed il database avvengono mediante JDBC, come in [2]. Per quanto riguarda l'interazione fra client e middle tier, si vuole realizzare una struttura attraverso cui sia possibile l'invocazione di metodi remoti. Per meglio comprendere i meccanismi di funzionamento di tale interazione, vengono presentate due diverse realizzazioni: nella prima tutti i meccanismi di comunicazione fra oggetti sono costruiti "dal basso" a partire dai socket, mentre nella seconda viene sfruttato RMI. L'interazione attraverso metodi remoti suggerisce l'uso del design pattern Proxy ([4]): si deve cioè costruire un oggetto che faccia le veci del middle tier entro il client tier e che trasmetta le chiamate ai metodi attraverso la rete. Per far gestire molti client al middle tier, seguendo la struttura tradizionale dei programmi client-server di Internet, ad ogni connessione l'applicazione crea una nuova istanza del middle tier, seguita dalla creazione di un nuovo oggetto in un thread Java separato. Pertanto vi sarà un oggetto MiddleTierFactory con cui il client tier si connette all'inizio e che crea in un thread separato un oggetto MiddleTier, ogni volta che arriva un nuovo client. Da questo momento è l'oggetto MiddleTier che serve le richieste del client, accedendo al database con JDBC ed il MiddleTierFactory rimane libero per i client futuri. Tale meccanismo è mostrato nella figura seguente
 

 

Figura 1

 

Possiamo descrivere le funzionalità che devono essere accessibili remotamente con due interfacce Java, riportate di seguito:
 
interface MiddleTier {

        Rule getRule(String companyRole);

        String queryDB(String SQLText);

        void quit();

}
 
 

interface MiddleTierFactory {

        MiddleTier create();

}

Il metodo MiddleTier.getRule() accetta come parametro la qualifica dell'utente (che nell'applicazione esempio può essere solo direttore o fattorino) e restituisce una classe Rule, che dovrebbe contenere l'interfaccia grafica e le regole di accesso ai dati corrispondenti alla qualifica; nel nostro semplice esempio Rule è solo un codice numerico. Il metodo MiddleTier.queryDB() interagisce con il database ed il metodo MiddleTier.quit() chiude la connessione. Infine, il metodo MiddleTierFactory.create() restituisce un nuovo oggetto MiddleTier a richiesta.
Realizzazione con i socket
Le due interfacce descritte collegano il client tier con il middle tier e devono essere presenti su due macchine diverse, pur rappresentando un'unica entità. Siccome con i socket solo i dati possono passare da uno spazio di indirizzamento all'altro, dobbiamo dividere sia MiddleTier che MiddleTierFactory in due parti collegate da un Socket. Questa soluzione, nota come Half Object Plus Protocol, produce quattro classi: MiddleTierProxy, MiddleTierServer, MiddleTierFactoryProxy, MiddleTierFactoryServer, con le classi "proxy" residenti sul client tier e le classi "server" sul middle tier. La Figura 2 mostra un sequence diagram che descrive in dettaglio quanto accade durante la connessione.
 
 

Figura 2

 

Il client tier istanzia un MiddleTierFactoryProxy, che si collega via socket al MiddleTierFactoryServer presente sul middle tier. Quando arriva il messaggio di connessione le due parti creano le metà corrispondenti di un nuovo oggetto MiddleTier (MiddleTierFactoryProxy crea un MiddleTierProxy e MiddleTierFactoryServer istanzia un MiddleTierServer), che vengono collegate con il socket ottenuto durante la connessione. Il MiddleTierProxy viene poi restituito al client, mentre il MiddleTierServer viene lanciato in un thread separato. Ora ci sono due metà di un oggetto MiddleTier collegate da un socket ed il client tier ha un riferimento alla sua metà; la Figura 3 mostra cosa accade quando il client chiama mt.queryDB, ossia il metodo che produce l'interrogazione del database da parte del MiddleTier.
 
 

 

Figura 3

 
 

Per prima cosa la chiamata viene servita dall'oggetto mt di classe MiddleTierProxy, il quale assembla un messaggio di richiesta e lo spedisce al MiddleTierServer sull'altra macchina. Questo, ricevuto il messaggio, lo analizza e agisce a seconda del suo contenuto, cioè effettua la interrogazione del database tramite il JDBC, poi spedisce indietro il risultato opportuno, ossia i dati ottenuti dal database, attraverso il socket. La comunicazione è analoga nel caso del metodo getRule(), con il MiddleTierServer che stabilisce l'interfaccia appropriata in base alla qualifica trasmessa come argomento.
Realizzazione con RMI
La struttura generale è simile alla versione con i socket, ma in RMI sono già disponibili molti dei meccanismi che, usando i socket, è stato necessario implementare esplicitamente nel codice sorgente.
In particolare, una volta creati i file .class, occorre generare da questi stub e sketeton con il compilatore rmic. In tal modo i proxy nel codice non sono più necessari. Le classi contenenti gli stub (aventi nome nomefile_Stub.class a partire dal file nomefile.class) devono essere copiate sul client host, mentre le classi contententi gli skeleton (aventi nome nomefile_Skel) devono stare sul server. Le classi che hanno originato stub e skeleton devono essere disponibili su entrambi gli host. Il marshaling automatico evita di scrivere nel codice la conversione esplicita degli oggetti in dati trasmissibili sul socket. Per questi motivi il codice della versione RMI risulta più snello. Una volta avviato l'RMI Registry sul server host ed avviato il MiddleTierServer si è pronti per rispondere alle richieste dei client. Per la stesura del codice, poiché RMI specifica i servizi con le interfacce remote, una soluzione naturale sarebbe definire due interfacce remote come immagine corrispondente alla interfaccia "originale" presente nel middle tier, come riportato nel sorgente che segue:

interface MiddleTierRMI extends java.rmi.Remote, MiddleTier {
        // ...
}
interface MiddleTierFactoryRMI extends java.rmi.Remote, MiddleTiereFactory {
        // ...
}
Purtroppo Java vieta di estendere un'interfaccia non remota con una remota e da questo nasce un problema di disadattamento, ossia le classi non possono comunicare a causa delle interfacce diverse. Una soluzione a questo frequente problema è offerta dal pattern Adapter. Si definiscono due classi come segue:
class MiddleTierAdapter implements MiddleTier {

            MiddleTierRMI adaptee;

            public MiddleTierAdapter (MiddleTierRMI remotePeer) {

    adaptee = remotePeer;

    }
 
 

    public Rule getRule(String companyRole) {

        remoteResult = adaptee.RMIgetRule (companyRole);

        return remoteResult;

    }
 
 

    public String queryDB(String SQLtext) {

          String result = null;

          result = adaptee.RMIqueryDB(SQLtext);

          return result;

    }
 
 

    public void quit() {

        adaptee.RMIquit();

    }
 
 

}
 
 

class MiddleTierFactoryAdapter implements MiddleTierFactory {
 
 

        MiddleTierFactoryRMI adaptee;

        public MiddleTierFactoryAdapter (String name) {

            // Si collega al name server, 

            // 

            // lo assegna al membro adaptee

        }
 
 

        public MiddleTier create() { 

            MiddleTierRMI remoteResult = adaptee.RMIcreate();

            return new MiddleTierAdapter(remoteResult);

        }

}

Si è scritta una classe MiddleTierAdapter (remota) che incapsula l'oggetto MiddleTierRMI (non remoto) e una classe MiddleTierFactoryAdapter che incapsula MiddleTierFactoryRMI. Nel codice precedente non compare per semplicità tutta la gestione delle eccezioni. Pertanto il funzionamento del sistema è il seguente: la classe Client dispone, attraverso i suddetti incapsulamenti secondo il pattern Adapter, di una istanza locale (ovvero del proxy) sia della classe MiddleTier sia del MiddleTierFactory. Durante la compilazione vengono generati i "collegamenti" RMI con gli skeleton che appartengono al package MiddleTier e che sono istanziati entro la classe servers. All'avvio del client entrambi gli stub sono inizializzati a null. Durante l'esecuzione, al login il client richiede al naming service di RMI di poter collegarsi con il server RMI contenuto nel middle tier. In tale modo viene attivata nel middle tier, con un parametro diverso in funzione della identificazione avvenuta da parte dell'utente, la classe MiddleTierFactory, che crea a sua volta un'istanza del MiddleTier con cui il client interagirà
Questo processo di creazione è sostanzialmente analogo a quello con i socket, rappresentato in Figura 1. Le comunicazioni esplicite con i socket, rappresentate in Figura 3, sono ora totalmente trasparenti per il programmatore (a parte la richiesta iniziale di accesso al server RMI), in quanto avvengono attraverso i canali interni di RMI, ed il resto del sistema non è cambiato. Anche in questo caso il server sfrutta il multithreading nativo di Java e ottiene ogni istanza di MiddleTier in un diverso thread, garantendo le migliori risorse alle richieste dei client.

Conclusioni: confronto RMI/Socket
Nell'applicazione descritta, essendo la trasmissione centrata sui dati, non si ha una necessità effettiva di comunicare a livello oggetti, ossia di potere trasferire oggetti generici o invocare metodi remoti. In generale invece ciò può essere necessario (o quanto meno molto utile), oppure può essere una evoluzione naturale al crescere della complessità della applicazione stessa.
Si è visto quanto implementare una flessibilità di questo genere costi in termini di codice, avendo solo a disposizione i socket, mentre RMI facilita notevolmente questo compito.
RMI dispone di tutti i meccanismi di marshaling necessari per effettuare il trasferimento di oggetti oltre che di "semplici" dati. Quindi, se occorre trasferire oggetti o farli interagire fra loro con metodi, non è necessario dover scrivere esplicitamente tutti i metodi per l'handling di oggetti di vario tipo. Il compilatore rmic garantisce la generazione automatica di stub e skeleton, senza che si debba implementare esplicitamente il meccanismo di proxy e server. RMI consente sia di trasferire lo stub di un oggetto presente sul server, sia di trasferire oggetti veri e propri (migrazione del codice). In generale quindi l'approccio garantito da RMI è molto più object-oriented: le comunicazioni sono "mascherate" ed il programmatore vede solo oggetti che interagiscono fra loro; alcuni di tali oggetti sono dei proxy di oggetti remoti. Esistono però anche alcuni svantaggi.
La comunicazione esplicita tramite socket è più efficiente: anche dovendo implementare i metodi per il marshaling di alcuni oggetti, si può ottenere codice più efficiente, a prezzo però di un notevole impiego di tempo-uomo per la scrittura di meccanismi che RMI rende già disponibili. Non appena la complessità dell'applicazione (ad esempio il numero di classi che devono essere trasferite, e quindi il numero di metodi di marshaling che devono essere scritti) aumenta, l'uso di RMI può divenire indispensabile. Il maggiore difetto di RMI è il fatto di essere solo specifico di Java ([5]). Attraverso i socket si possono ottenere facilmente comunicazioni fra programmi scritti in diversi linguaggi, come per esempio Java, C, C++ , mentre con RMI tutti i componenti di una applicazione distribuita devono essere necessariamente scritti in Java. Per potere disporre di caratteristiche simili a quelle di RMI a fronte di componenti scritti in linguaggi diversi, occorre usare CORBA ([5]).

Bibliografia


 
 

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