Le fonctionnement de RMI (Remote Method Invocation)

Alain Defrance

Le RMI (ou Remote Method Invocation) est très souvent utilisé, soit directement, soit dans des couches sous-jacentes. RMI est par exemple utilisé pour exposer des EJB SessionBeans. Notre objectif va être de démystifier RMI en comprenant ses mécanismes. Nous allons analyser comment une invocation à distance est possible en allant jusqu'a implémenter notre propre version allégée de RMI. Bien entendu, afin de nous focaliser sur les objectifs de cet article, certains prérequis sont nécessaires. Aucun rappel sur l'utilisation et la gestion d'un réseau en Java ne sera fait. ♪

- Titre : Le fonctionnement de RMI (Remote Method Invocation)

- Auteur : Alain Defrance

- Parution : 15 avril 2010

- Licence : Copyright ® 2010 Developpez LLC. Tous droits réservés Developpez LLC. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents et images sans l'autorisation expresse de Developpez LLC. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Le RMI

- II-A. Fonctionnement

- II-B. Avantages

- II-C. Comment utiliser RMI

- II-C-1. L'interface

- II-C-2. L'implémentation

- II-C-3. Le serveur

- II-C-4. Le client

- III. Notre implémentation

- III-A. Nos services

- III-B. L'InvocationContext

- III-C. Le RMIHandler

- III-D. L'exception métier : RMIInvocationException

- III-E. Le Registry

- III-E-1. Enregistrer un service

- III-E-2. Écouter un port

- III-E-3. Créer un stub

- III-E-4. Le registry complet

- III-F. Le serveur

- III-G. Le client

- IV. Conclusion

- V. Remerciements

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).

Fonctionnement de RMI

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.

AddInterface.java

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.

AddImpl.java

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.

Server.java

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.

Client.java

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.

AddInterface.java

package service;

/**
* @author Alain Defrance
*/
public interface AddInterface {
Integer add(Integer nb1, Integer nb2);
}

L'implémentation quant à elle reste strictement identique

AddImpl.java

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.

InvocationContext.java

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.

RMIHandler.java

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.

RMIInvocationException.java

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.

Enregistrer un 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.

Écouter un port

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.

Récupérer les types d'une liste d'argument

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.

Création d'un proxy dynamique

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.

Registry.java

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.

Server.java

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.

Server.java

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.