I. Introduction▲
Nous allons premièrement expliquer aux néophytes l'implémentation RMI de Sun ainsi que son utilisation, puis nous allons implémenter notre propre version dans un but purement pédagogique.
II. Le RMI▲
Le RMI permet d'appeler du code à distance. Plus précisément, nous allons exposer une partie du code sur le client au travers d'interfaces. L'implémentation proprement dite se trouvera du côté du serveur et le rôle de RMI sera de faire le nécessaire afin de lier les interfaces aux implémentations distantes. En production nous aurons un JAR contenant uniquement les interfaces et un autre les implémentations. Cette pratique nous permettra d'importer uniquement les interfaces sur le client, et de déployer les implémentations sur le serveur.
II-A. Fonctionnement▲
Afin de pouvoir lier l'implémentation présente sur le serveur avec les interfaces du client, il existe plusieurs alternatives. Nous pouvons générer des classes Java qui vont implémenter les interfaces, se connecter au serveur, puis déléguer l'appel vers le serveur au travers d'un socket.
Nous pouvons aussi éviter de générer du code grâce au proxy dynamique pour rediriger l'appel du code client vers le serveur dynamiquement. Quelle que soit l'alternative choisie, nous voyons là émerger deux agents importants :
- l'objet côté client qui va rediriger l'appel vers le serveur (le Stub) ;
- l'objet côté serveur qui sera atteint par l'objet dynamique côté client (le Skeleton).
Un annuaire appelé registry nous permettra de publier les services au niveau du serveur. Il associera une clé (un identifiant) à une instance publiée et accessible au travers d'un socket.
II-B. Avantages▲
Les avantages de cette approche sont multiples, mais nous en retiendrons principalement deux :
- le client peut être mis à jour de manière transparente puisqu'un simple redéploiement au niveau du serveur permet de modifier le comportement du client ;
- l'exécution distante de la méthode est masquée au code client, le code appelant travaillant avec les instances implémentant les interfaces comme si l'exécution était locale.
II-C. Comment utiliser RMI▲
II-C-1. L'interface▲
La première chose à faire est de définir les méthodes que nous voulons exposer au travers de RMI : nous pouvons faire ceci grâce à une interface.
Cette interface devra étendre l'interface java.rmi.Remote, et toutes les méthodes exposées devront être capables de propager une exception de type java.rmi.RemoteException. En effet, même si l'utilisation du socket sous-jacent est masquée, nous ne sommes pas pour autant à l'abri d'un problème réseau pendant l'utilisation de notre stub, et le code client devra en tenir compte.
import
java.rmi.Remote;
import
java.rmi.RemoteException;
/**
*
@author
Alain Defrance
*/
public
interface
AddInterface extends
Remote {
public
Integer add
(
Integer nb1, Integer nb2) throws
RemoteException;
}
Cette interface sera partagée aussi bien sur le serveur que sur le client. L'implémentation existera uniquement sur le serveur.
II-C-2. L'implémentation▲
Nous allons maintenant implémenter notre interface. Cette implémentation sera référencée par notre registry côté serveur. Cette implémentation ne devra en aucun cas être présente côté client sous peine de perdre l'utilité de RMI.
import
java.rmi.RemoteException;
/**
*
@author
Alain Defrance
*/
public
class
AddImpl implements
AddInterface {
public
Integer add
(
Integer nb1, Integer nb2) throws
RemoteException {
return
nb1 +
nb2;
}
}
Il ne nous reste plus qu'à publier une instance de cette classe afin de permettre l'invocation distante des méthodes de cette classe.
II-C-3. Le serveur▲
Notre serveur sera une simple classe contenant une méthode main. Dans cette dernière, nous instancierons notre implémentation. Ensuite, nous créerons notre skeleton que nous publierons grâce à notre registry.
import
java.rmi.registry.LocateRegistry;
import
java.rmi.registry.Registry;
import
java.rmi.server.UnicastRemoteObject;
/**
*
@author
Alain Defrance
*/
public
class
Server {
public
static
void
main
(
String[] argv) {
try
{
// 10000 est le port sur lequel sera publié le service. Nous devons le préciser à la fois sur le registry et à la fois à la création du stub.
AddInterface skeleton =
(
AddInterface) UnicastRemoteObject.exportObject
(
new
AddImpl
(
), 10000
); // Génère un stub vers notre service.
Registry registry =
LocateRegistry.createRegistry
(
10000
);
registry.rebind
(
"Add"
, skeleton); // publie notre instance sous le nom "Add"
}
catch
(
Exception e) {
e.printStackTrace
(
);
}
}
}
À partir de maintenant, notre serveur RMI est disponible, nous pouvons créer un client qui se connectera à ce serveur pour invoquer le code présent sur le serveur.
II-C-4. Le client▲
Pour nous connecter au serveur, nous allons utiliser RMI afin de récupérer une instance stub. Cette dernière fera le nécessaire afin de déléguer l'appel de la méthode au travers d'un socket.
import
java.rmi.registry.LocateRegistry;
import
java.rmi.registry.Registry;
/**
*
@author
Alain Defrance
*/
public
class
Client {
public
static
void
main
(
String[] argv) {
try
{
Registry registry =
LocateRegistry.getRegistry
(
10000
);
AddInterface stub =
(
AddInterface) registry.lookup
(
"Add"
);
System.out.println
(
stub.add
(
1
, 2
)); // Affiche 3
}
catch
(
Exception e) {
e.printStackTrace
(
);
}
}
}
Le calcul n'a pas été fait côté client, mais côté serveur. Si nous changeons l'implémentation bind à « Add » sur le serveur, et que cette nouvelle implémentation implémente toujours l'interface AddInterface, alors le code client pourra rester identique.
III. Notre implémentation▲
Nous allons à présent développer la couche faisant le lien entre le serveur et le client. Plus précisément, nous allons écrire le code permettant de déléguer l'appel fait depuis le client au travers d'un socket. Nous allons devoir coder un registry basique ainsi que de quoi instancier nos stub et skeleton.
III-A. Nos services▲
Nous allons publier le même service qu'avec l'implémentation RMI de Sun : nous allons donc garder AddInterface et AddImpl. Notre implémentation ne nécessitera pas d'utiliser l'interface Remote ni d'expliciter RemoteException comme exception propageable. Nous allons donc légèrement modifier AddInterface.
package
service;
/**
*
@author
Alain Defrance
*/
public
interface
AddInterface {
Integer add
(
Integer nb1, Integer nb2);
}
L'implémentation quant à elle reste strictement identique
package
service.impl;
import
service.AddInterface;
/**
*
@author
Alain Defrance
*/
public
class
AddImpl implements
AddInterface {
public
Integer add
(
Integer nb1, Integer nb2) {
return
nb1 +
nb2;
}
}
III-B. L'InvocationContext▲
À terme, nous allons devoir faire traverser un socket à notre invocation. Le principe sera de regrouper tout ce dont nous avons besoin au bout du socket pour réaliser notre invocation (nom du service, méthode à invoquer, et les valeurs des paramètres). Nous allons regrouper cela dans une classe sérialisable qui traversera le socket. Cette classe sera instanciée par notre stub, envoyée dans le socket, et traitée par le skeleton.
package
micrormi;
import
java.lang.reflect.Method;
import
java.io.Serializable;
/**
*
@author
Alain Defrance
*/
public
class
InvocationContext implements
Serializable {
private
Object[] args;
private
String method;
private
String name;
public
InvocationContext
(
) {
}
public
InvocationContext
(
String name, Object[] args, String method) {
this
.name =
name;
this
.args =
args;
this
.method =
method;
}
public
Object[] getArgs
(
) {
return
args;
}
public
void
setArgs
(
Object[] args) {
this
.args =
args;
}
public
String getMethod
(
) {
return
method;
}
public
void
setMethod
(
String method) {
this
.method =
method;
}
public
String getName
(
) {
return
name;
}
public
void
setName
(
String name) {
this
.name =
name;
}
}
III-C. Le RMIHandler▲
Notre InvocationHandler (utilisé par le proxy dynamique) sera une implémentation dynamique de notre stub. Ce dernier instanciera simplement un InvocationContext, l'enverra dans le socket, et retournera ce qui revient par le socket.
package
micrormi;
import
java.lang.reflect.InvocationHandler;
import
java.lang.reflect.Method;
import
java.io.ObjectInputStream;
import
java.io.ObjectOutputStream;
/**
*
@author
Alain Defrance
*/
public
class
RMIHandler implements
InvocationHandler {
private
ObjectInputStream ois;
private
ObjectOutputStream oos;
private
String name;
public
RMIHandler
(
String name, ObjectInputStream ois, ObjectOutputStream oos) {
this
.name =
name;
this
.ois =
ois;
this
.oos =
oos;
}
public
Object invoke
(
Object proxy, Method method, Object[] args) throws
Throwable {
oos.writeObject
(
new
InvocationContext
(
name, args, method.getName
(
)));
return
ois.readObject
(
);
}
}
Ici nous parlons de socket, mais nous ne manipulons que des ObjectStream. Rien ne nous empêche de réutiliser ces classes pour faire du RMI au travers d'un autre type de flux (pourquoi pas un flux vers un fichier).
III-D. L'exception métier : RMIInvocationException▲
Nous allons passer par de la réflexion, écrire dans des flux, et donc être amenés à gérer de multiples erreurs différentes (notamment des problèmes réseau, mais aussi des exceptions au niveau de la réflexion). Nous allons donc créer une exception métier qui nous permettra d'agréger les diverses exceptions que nous aurons à gérer.
package
micrormi.exception;
/**
*
@author
Alain Defrance
*/
public
class
RMIInvocationException extends
RuntimeException {
public
RMIInvocationException
(
) {
super
(
);
}
public
RMIInvocationException
(
String message) {
super
(
message);
}
public
RMIInvocationException
(
String message, Throwable cause) {
super
(
message, cause);
}
}
III-E. Le Registry▲
Le registry sera la classe qui va nous permettre d'enregistrer un service, écouter un port et créer un stub. C'est cette dernière qui encapsulera la complexité de l'invocation distante, et proposera une interface simple pour accéder à notre service.
III-E-1. Enregistrer un service▲
Pour gérer la liste de nos services, nous allons utiliser une Map. Cette map associera un nom à une instance de service.
private
Map<
String, Object>
services =
new
HashMap<
String, Object>(
);
public
Registry register
(
String name, Object service) {
services.put
(
name, service);
return
this
; // On return this pour pouvoir chaîner les appels.
}
III-E-2. Écouter un port▲
Afin d'écouter un port, nous allons utiliser la classe ServerSocket, puis ouvrir un thread pour chaque client connecté. Ce thread nous permettra de récupérer l'InvocationContext, exécutera la méthode grâce à de la réflexion, et enverra le résultat de l'invocation dans le socket.
private
Socket server;
private
ObjectInputStream ois;
private
ObjectOutputStream oos;
private
ServerSocket ss;
public
void
publish
(
int
port) {
if
(
server !=
null
) {
throw
new
InvalidStateException
(
"Socket is already connected to a server"
);
}
try
{
ss =
new
ServerSocket
(
port);
while
(
true
) {
// Pour chaque connecté
final
Socket s =
ss.accept
(
);
new
Thread
(
) {
// On alloue un thread
public
void
run
(
) {
try
{
// Récupération des flux
ObjectOutputStream oos =
new
ObjectOutputStream
(
s.getOutputStream
(
));
ObjectInputStream ois =
new
ObjectInputStream
(
s.getInputStream
(
));
// Récupération du context
InvocationContext ic =
(
InvocationContext) ois.readObject
(
);
// Récupération du service
Object targetObject =
services.get
(
ic.getName
(
));
// Invocation
Object result =
targetObject.getClass
(
).getMethod
(
ic.getMethod
(
), args2Class
(
ic.getArgs
(
))).invoke
(
targetObject, ic.getArgs
(
));
// Valeur de retour envoyée dans le socket vers le client
oos.writeObject
(
result);
}
catch
(
Exception e) {
throw
new
RMIInvocationException
(
e.getMessage
(
), e);
}
}
}
.start
(
);
}
}
catch
(
Exception e) {
throw
new
RMIInvocationException
(
e.getMessage
(
), e);
}
}
La méthode args2Class permet de construire la liste des types des arguments de l'invocation.
private
Class[] args2Class (
Object[] objs) {
List<
Class>
classes =
new
ArrayList<
Class>(
);
for
(
Object o : objs) {
classes.add
(
o.getClass
(
));
}
return
classes.toArray
(
new
Class[]{}
);
}
III-E-3. Créer un stub▲
La création du stub n'est autre que l'utilisation d'un proxy dynamique. Nous utiliserons une méthode factory générique afin d'éviter des casts au niveau du code client.
public
<
T>
T get
(
String name, Class<
T>
clazz) {
return
(
T) Proxy.newProxyInstance
(
ClassLoader.getSystemClassLoader
(
),
new
Class[]{
clazz}
,
new
RMIHandler
(
name, ois, oos)
);
}
Nous réutilisons notre RMIHandler qui ne fait que déléguer l'appel vers le socket qu'il contient, c'est-à-dire celui dédié au client demandant l'invocation.
III-E-4. Le registry complet▲
Notre implémentation est terminée, il reste à regrouper ces méthodes dans une même classe, et utiliser cette classe dans un client/serveur afin de tester notre code.
package
micrormi;
import
micrormi.exception.RMIInvocationException;
import
sun.plugin.dom.exception.InvalidStateException;
import
java.util.Map;
import
java.util.HashMap;
import
java.util.List;
import
java.util.ArrayList;
import
java.net.ServerSocket;
import
java.net.Socket;
import
java.io.ObjectInputStream;
import
java.io.ObjectOutputStream;
import
java.lang.reflect.Proxy;
/**
*
@author
Alain Defrance
*/
public
class
Registry {
private
Map<
String, Object>
services =
new
HashMap<
String, Object>(
);
private
Socket server;
private
ObjectInputStream ois;
private
ObjectOutputStream oos;
private
ServerSocket ss;
public
Registry register
(
String name, Object service) {
services.put
(
name, service);
return
this
;
}
public
void
publish
(
int
port) {
if
(
server !=
null
) {
throw
new
InvalidStateException
(
"Socket is already connected to a server"
);
}
try
{
ss =
new
ServerSocket
(
port);
while
(
true
) {
final
Socket s =
ss.accept
(
);
new
Thread
(
) {
public
void
run
(
) {
try
{
ObjectOutputStream oos =
new
ObjectOutputStream
(
s.getOutputStream
(
));
ObjectInputStream ois =
new
ObjectInputStream
(
s.getInputStream
(
));
InvocationContext ic =
(
InvocationContext) ois.readObject
(
);
Object targetObject =
services.get
(
ic.getName
(
));
Object result =
targetObject.getClass
(
).getMethod
(
ic.getMethod
(
), args2Class
(
ic.getArgs
(
))).invoke
(
targetObject, ic.getArgs
(
));
oos.writeObject
(
result);
}
catch
(
Exception e) {
throw
new
RMIInvocationException
(
e.getMessage
(
), e);
}
}
}
.start
(
);
}
}
catch
(
Exception e) {
throw
new
RMIInvocationException
(
e.getMessage
(
), e);
}
}
public
Registry connect
(
String host, int
port) {
if
(
server !=
null
) {
throw
new
InvalidStateException
(
"Socket is already connected"
);
}
if
(
ss !=
null
) {
throw
new
InvalidStateException
(
"Registry is listening"
);
}
try
{
server =
new
Socket
(
host, port);
ois =
new
ObjectInputStream
(
server.getInputStream
(
));
oos =
new
ObjectOutputStream
(
server.getOutputStream
(
));
}
catch
(
Exception e) {
throw
new
RMIInvocationException
(
e.getMessage
(
), e);
}
return
this
;
}
public
<
T>
T get
(
String name, Class<
T>
clazz) {
return
(
T) Proxy.newProxyInstance
(
ClassLoader.getSystemClassLoader
(
),
new
Class[]{
clazz}
,
new
RMIHandler
(
name, ois, oos)
);
}
private
Class[] args2Class (
Object[] objs) {
List<
Class>
classes =
new
ArrayList<
Class>(
);
for
(
Object o : objs) {
classes.add
(
o.getClass
(
));
}
return
classes.toArray
(
new
Class[]{}
);
}
}
Comme toujours, rien n'est magique ni très compliqué, il suffit de traiter le problème par le bon bout.
III-F. Le serveur▲
Le serveur n'est autre qu'une méthode main qui enregistre un service puis le publie.
package
main;
import
micrormi.Registry;
import
service.impl.AddImpl;
/**
*
@author
Alain Defrance
*/
public
class
Server {
public
static
void
main
(
String[] argv) {
new
Registry
(
)
.register
(
"Add"
, new
AddImpl
(
))
.publish
(
10000
);
}
}
III-G. Le client▲
Le client, quant à lui, n'est autre qu'une méthode main qui se connecte à un registry, récupère un stub vers un service, et appelle normalement l'instance comme si elle avait été instanciée localement.
package
main;
import
micrormi.Registry;
import
service.AddInterface;
/**
*
@author
Alain Defrance
*/
public
class
Client {
public
static
void
main
(
String[] argv) {
Registry r =
new
Registry
(
).connect
(
"localhost"
, 10000
);
AddInterface cs =
r.get
(
"Add"
, AddInterface.class
);
System.out.println
(
cs.add
(
2
, 3
));
}
}
Il est important de noter que nous avons atteint notre but : aucune implémentation n'est présente au niveau du client.
IV. Conclusion▲
Nous avons assez rapidement implémenté notre version allégée de RMI. Bien évidemment, notre prétention n'était pas d'implémenter une spécification de Sun, mais uniquement d'imiter le comportement de RMI afin de mieux comprendre ses mécanismes.
Il serait intéressant de rendre abstraite la classe Registry, et de créer plusieurs implémentations comme SocketRegistry, FileRegistry ou HttpRegistry.
Si vous êtes débutant en Java il peut être intéressant de réaliser ces implémentations.
V. Remerciements▲
Merci à Alp, Adrien DAO-LENA, krachik, Hikage, Baptiste Wicht, fred.perez et eusebe19 pour leurs relectures et idées.