Accueil > Non classé > Plugins annotés et chargés dynamiquement en Java

Plugins annotés et chargés dynamiquement en Java

Attention : cet atricle n’est pas un atricle sur le cyclimse.[?]

Introduction

Je suis un geek. Comme tout bon geek qui se respecte, mes activités extra-professionnelles incluent entre autre des jeux vidéo et de la programmation (tard le soir en buvant un soda quelconque). Alors quand je peux mélanger deux activités favorites entre elles, c’est chouette. Etant fan de Trackmania Nations, il m’arrive de fouiner dans toute source d’information qui s’y rapporte.

C’est ainsi que j’ai appris que le serveur dédié TrackMania peut être contrôlé à distance par des appels XML-RPC. Evidemment, tout ça me donne envie d’ouvrir mon IDE favori pour faire un HelloWorld en Java et voir un peu les possibilités.

Le HelloWorld ayant évolué un peu plus que je ne l’avais initialement prévu, il s’est vu doté d’un système de plugins chargés dynamiquement (parce que j’avais envie d’explorer ce domaine depuis un certain temps déjà).

Assez raconté ma life, je vais maintenant vous faire part de mes réflexions relatives à la mise en place d’un système de plugins sous forme de JARs, pouvant être chargés/déchargés au runtime, et utilisant des annotations personnalisées.


Cahier des charges

La communication avec le serveur dédié Trackmania peut être divisée en deux types d’appels :

  • appels de méthodes depuis le client (c’est nous !) vers le serveur
  • appels de “callbacks” depuis le serveur vers le client

Les callbacks sont en fait des évènements asynchrones auxquels les clients peuvent réagir, comme par exemple “un joueur s’est connecté”, “la partie est terminée”, “un joueur a écrit un message dans le chat”, etc. Ils ne correspondent pas exactement à des fonctions de retour qu’on a l’habitude de passer en paramètre à du code, mais c’est la terminologie employée par le serveur Trackmania, donc je la conserve.

Pour les appels de méthodes client –> serveur, il n’y aura donc pas de contrainte particulière : on envoie la requête et on récupère immédiatement la réponse. Pour les callbacks, en revanche, il va falloir mettre en place un thread qui va écouter les données arrivant sur la socket cliente, et qui va dispatcher ces évènements aux portions de code intéressées.

C’est ici qu’interviennent les plugins : comme il y a pas mal de callbacks possibles, et qu’on peut imaginer un tas d’applications les exploitant (logs, stats, administration depuis le jeu, etc.), il serait intéressant d’avoir un système permettant de brancher à chaud des morceaux de code qui réagiront à ces callbacks.

Interlude : qu’est-ce qu’un plugin ? C’est l’une des questions qui m’ont été posées par un relecteur de l’article. Par plugin, j’entends une extension d’un programme principal qui vient lui ajouter certaines fonctionnalités. Le concept est donc indépendant du contexte des jeux vidéo. Pour plus d’infos, Wikipedia a un article consacré aux plugins.

Une approche simple de gestion de plugins serait une interface un peu comme ça :


1
2
3
4
5
public interface Plugin {
    public String getName();
    public List<String> getInterestedCallbacks();
    public void callbackReceived(ServerCallbackEvent event);
}

Ca reste simple, mais ça ne me plait pas. D’abord parce que c’est assez peu évolutif, si dans la prochaine version on décide de modifier l’interface Plugin, tous les anciens plugins qu’on a récupérés devront être recompilés. Ensuite parce qu’on a une seule méthode centrale qui va réagir à tous les callbacks qui nous intéressent. Niveau separation of concern, ça implique qu’une méthode pourrait avoir 5 ou 10 responsabilités.

Ce que j’aimerais plutôt avoir, c’est des plugins qui ressemblent à ceci :


1
2
3
4
5
6
7
8
9
10
11
12
13
@Plugin
public class TracksPlugin {
    @ServerCallbackHandler(callbacks={“TrackChanged”, “TrackFinished”})
    public void handleTrackChangedEvent(ServerCallbackEvent event){
        …
    }

    @ServerCallbackHandler(callbacks={“AnotherEvent”})
    public void handleAnotherEvent(ServerCallbackEvent event){
        …
    }

}

En remplaçant l’interface par des annotations, on a ainsi beaucoup plus de libertés dans le découpage des responsabilités, le nommage des méthodes etc.

Pour gérer tous ces plugins, on va également avoir besoin d’un gestionnaire de plugins, qui pourra charger/décharger des plugins au runtime (histoire de compliquer l’affaire Sourire), et faire le lien entre la connexion au serveur et les évènement supportés par les plugins.

Annotations For Fun

Vous l’aurez peut-être compris, même si les annotations sont loin d’être obligatoires dans notre cas, j’ai fait ce choix pour avoir une idée de comment fonctionne “l’autre côté” des annotations, c’est-à-dire comment on les crée/exploite. En général, on se contente de les apposer dans nos classes et c’est le framework qui se charge du reste sans qu’on sache vraiment ce qu’il fait.

Un très bon article d’Olivier Croisier m’a donné une idée de comment m’y prendre. Basiquement, il s’agit de faire de l’introspection sur des Class :


1
boolean isValidPlugin = (MyPlugin.class.getAnnotation(Plugin.class) != null);

L’annotation @ServerCallbackHandlerque j’ai mentionné plus haut pour identifier les méthodes réagissant aux évènements du serveur est définie ainsi :


1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ServerCallbackHandler {
    String[] callbacks();
}

Ca, c’est la partie facile. Si on veut exploiter l’annotation @ServerCallbackHandler citée dans la partie précédente, il va falloir garder une référence sur l’instance du plugin et la méthode annotée :


1
2
3
4
5
6
7
8
9
public class CallbackInfo {
    public final Method method;
    public final Object instance;

    public CallbackInfo(Method method, Object instance) {
        this.method = method;
        this.instance = instance;
    }
}

On peut maintenant construire une Map qui va associer des noms de callbacks serveur avec des objets CallbackInfo (notez la confusion que je suis en train de semer entre callback – évènement envoyé par le serveur – et callback – méthode d’un plugin à appeler lors de la réception d’un tel évènement).


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    protected List<Method> findMethodsByAnnotation(Class classToScan, Class annotationToFind) {
        List<Method> foundMethods = new ArrayList<Method>();
       
        for (Method method : classToScan.getMethods()) {
            if (method.getAnnotation(annotationToFind) != null) {
                foundMethods.add(method);
            }
        }

        return foundMethods;
    }
   
    public Map<String, CallbackInfo> processAnnotations(Object pluginInstance) {
        List<Method> interestingMethods = findMethodsByAnnotation(pluginInstance.getClass(), ServerCallbackHandler.class);
        Map<String, CallbackInfo> callbacks = new HashMap<String, CallbackInfo>();
       
        for (Method method : interestingMethods) {
            CallbackInfo callback = new CallbackInfo(method, pluginInstance);
            CallbackHandler annotation = callback.method.getAnnotation(ServerCallbackHandler.class);
            for (String callbackName : annotation.callbacks()) {
                callbacks.put(callbackName, callback);
            }
        }
       
        return callbacks;
    }

Note : le code peut sembler contre-productif, mais c’est parce qu’il a été extrait d’un code plus complexe, voir en bas d’article pour accéder au code source complet. A partir de l’objet pluginInstance, on scanne les méthodes portant l’annotation recherchée, puis on construit un CallbackInfo permettant d’appeler par la suite ces méthodes. La Map renvoyée par la méthode processAnnotations() peut ensuite être utilisée lorsqu’on reçoit un évènement de la part du serveur :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    Map<String, CallbackInfo> serverCallbackHandlers = ...

    private void fireServerCallbackEvent(ServerCallbackEvent event) {

        CallbackInfo callback = serverCallbackHandlers.get(event.callbackName);
       
        if (callback == null) {
            return;
        }

        try {
            callback.method.invoke(callback.instance, event);
        } catch (Exception ex) {
            //...
        }
    }

Vous aurez certainement remarqué un design pattern Observer déguisé. Les plugins notifient leur intention de réagir à certains évènements grâce aux annotations. La classe contenant la méthode fireServerCallbackEvent() notifiera alors les observateurs interessés.

Voilà, vous devez à présent être un gourou des annotations, en plus de savoir les poser vous devriez maintenant savoir comment les exploiter. Passons donc à la partie suivante, la gestion des plugins.

Système de plugins chargés dynamiquement

Comment charger un plugin au runtime, sachant que la classe correspondante n’est pas (encore) dans le classpath ? C’est simple, on ajoute la classe au classpath :D. Pour cela, on va faire appel au ClassLoader du JDK, plus précisément nous allons créer notre propre JarClassLoader :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JarClassLoader extends URLClassLoader {

    public JarClassLoader() {
        super(new URL[] {});
    }

    public JarClassLoader(URL[] urls) {
        super(urls);
    }
   
    public void loadJar(String jarPath) throws MalformedURLException {
        String jarUrl = "jar:file://" + jarPath + "!/";
       
        addURL(new URL(jarUrl));
    }
}

Le stockage de classes dans un jar est une technique standard, autant en profiter donc :). En Java, un jar peut être accédé via une URL de type “jar:file://”. Ceci permet par exemple d’accéder à un fichier contenu dans un jar sans avoir à le décompresser, comme le fait Netbeans pour afficher une page javadoc à partir du zip téléchargé sur le site de Sun Oracle. Une fois un jar chargé par notre ClassLoader, il est possible d’utiliser celui-ci pour instancier une classe nouvellement référencée :


1
2
3
4
5
6
7
8
9
        JarClassLoader jarClassLoader = new JarClassLoader();
        String entryPointName = "com.foo.example.PluginClass";

        try {
            jarClassLoader.loadJar("my_wonderful_plugin.jar");
            Object entryPointInstance = jarClassLoader.loadClass(entryPointName).newInstance();
        } catch (Exception ex) {
            throw new InvalidPluginException("Could not instantiate plugin's entry point " + entryPointName, ex);
        }

Selon vos envies, le nom de la classe à charger pourra être explicitement spécifié dans un fichier properties contenu dans le jar, soit déduit en scannant toutes les classes du jar contenant l’annotation @Plugin par exemple. Une fois l’instance du plugin récupérée, on va pouvoir la passer à la moulinette “scan d’annotations”, pour récupérer notre Map contenant les CallbackInfo et attendre patiemment des évènement (callbacks) de la part du serveur pour pouvoir les redispatcher.

Conclusion

Les annotations, c’est bien. Utilisées à bon escient, elles peuvent facilement remplacer de la configuration barbante en XML ou en properties. Nous avons vu comment créer et utiliser nos propres annotations en quelques lignes. Le plus dur est de faire quelque chose de ces annotations une fois qu’on les a trouvées sur une classe ou une méthode.

La seconde partie nous a permis de faire un peu mieux connaissance avec les ClassLoader du JDK, permettant d’ajouter à la volée des sources où charger des classes. Le lecteur averti aura surement remarqué que notre JarClassLoader hérite de URLClassLoader, qui comme son nom l’indique permet même de charger des classes récupérées par une connexion réseau (pratique pour la mise à jour des malwares !).

Si vous souhaitez approfondir un peu le sujet, ou mieux comprendre comment s’assemblent les morceaux de code présentés dans cet article, je vous invite à aller faire un tour dans le code source complet accessible dans la section suivante.

Pour aller plus loin

Epilogue

Noooooon !! J’ai cliqué sur “publier” au lieu d’ “enregister le brouillon”, les lecteurs vont voir un article pas fini !! Vite vite remettre en brouillon ! Bastien J.

Aaaargh, Google Reader l’a déjà aggregé /o\ Bastien J.

Bouletos :-) Pierre-Yves R.

Share
  1. 11/12/2010 à 07:32 | #1

    @Bastien Ouaip, j’étais tellement débordé ces derniers jours que je n’ai même pas eu le temps de te passer un savon :-P, encore moins de faire une relecture.

    J’ai une question : tu dis que le nom de la classe @Plugin pourra être “déduit en scannant toutes les classes du jar contenant l’annotation @Plugin”, aurais-tu un exemple ?

    Autre remarque : ton système ne permet pas d’enregistrer plusieurs méthodes pour le même callback event, si ? A priori, il faudrait utiliser une Map multivaluée plutôt qu’une simple Map.

    Bon article ceci dit, ça donne des idées :-)

  2. 11/12/2010 à 16:39 | #2

    Pour le scan des annotations, je n’ai pas encore eu le temps de faire un PoC, mais il est possible que j’utiliserais la bibliothèque scannotation comme point de départ. Il faudrait je pense parcourir récursivement les dossiers contenus dans le Jar et instrospecter chaque classe.

    Pour l’enregistrement de plusieurs méthodes par callback, c’est en effet impossible dans le code de cet article. J’ai depuis modifié le code de mon projet pour utiliser une Map<String, List<CallbackInfo>> à la place, ce qui est déjà plus satisfaisant (même si je ne suis pas très fan de l’imbrication Map/List…).

    J’ai une autre idée en tête pour l’utilisation des annotations, je verrai quand j’aurai un peu de temps pour la mettre en oeuvre.

  3. 13/12/2010 à 09:31 | #3

    Pour la notion de Multimap (une clé => plusieurs valeurs), tu peux regarder du côté de Guava : http://guava-libraries.googlecode.com/svn/tags/release03/javadoc/com/google/common/collect/Multimap.html

  4. 16/05/2011 à 14:52 | #4

    Où pourrai-je trouver le gestionnaire de plugin qui pourra charger ou décharger les plugins au runtime ?

  1. 21/12/2010 à 13:41 | #1
  2. 10/01/2011 à 10:03 | #2