Au coeur du JDK : présentation du Service Provider API

La spécification JAR propose depuis Java 6 une fonctionnalité fort pratique mais relativement méconnue : la Service Provider Interface (SPI). Derrière ce nom peu engageant se cache un mécanisme de découverte et de chargement dynamique de classes répondant à une interface donnée. Son utilisation est heureusement fort simple, et nous verrons ici qu'il est possible de s'en servir comme d'un système de plugins rudimentaire.

Présentation du SPI

Le SPI est composé de 3 éléments :

  • Un service, représenté par une classe abstraite.
  • Un fichier de configuration placé dans META-INF/services, déclarant une ou plusieurs implémentations de ce service
  • Le ServiceLoader, responsable de la découverte et du chargement des implémentations du service.

L'exemple que nous allons développer est un terminal acceptant des commandes via la console standard. Les commandes disponibles seront découvertes dynamiquement grâce au SPI.
Vous trouverez tout le code source de cet article en pièce jointe.

Définition du Service

La première étape consiste à définir l'interface du service qui sera découvert. Dans notre cas, il s'agit d'une commande que l'utilisateur pourra déclencher dans le terminal.
Notez le sous-package "spi". Dans le JDK, on retrouve cette convention partout où il est possible de fournir sa propre implémentation d'un service : java.nio.channels.spi, javax.xml.ws.spi, java.text.spi...

package terminal.command.spi;
 
/**
 * Represents a single command for the command-line system
 */
public abstract class Command
{
	/**
	 * Returns the command's identifier. Letters only.
	 * @return the command's identifier
	 */
	public abstract String getId();
 
	/**
	 * Process the command.
	 * @param input The parameters. May be null.
	 * @return The result. May be null.
	 */
	public abstract String process(String input);
}

Implémentation du Service

Ensuite, développons quelques implémentations de ce service.

Voici une commande echo qui retourne simplement la chaîne passée en paramètre.

package terminal.command;
import terminal.command.spi.Command;
 
/**
 * Command that only sends back the input string
 */
public class EchoCommand extends Command
{
	public static final String COMMAND_NAME = "echo";
 
	public String getId()
	{	return EchoCommand.COMMAND_NAME;
	}
 
	public String process(String input)
	{	return input;
	}
}

Sur le même principe, plusieurs autres commandes ont également développées : reverse et uppercase. N'hésitez pas à jouer avec l'exemple et à rajouter vos propres implémentations !

Déclaration des implémentations

Toute la magie du SPI opère ici.
Pour que le ServiceLoader détecte les implémentations de notre service, il faut en déclarer les implémentations.

La procédure est simple : dans le répertoire "META-INF/services", il suffit de créer un fichier texte encodé en UTF-8 et de même nom (qualifié) que le service -- dans notre cas, "terminal.command.spi.Command" :

Meta-inf.png

Le fichier contient les noms (qualifiés là encore) des implémentations du service (une implémentation par ligne):

terminal.command.EchoCommand
terminal.command.ReverseCommand
terminal.command.UppercaseCommand

Généralement, chaque implémentation d'un service est placée dans sa propre archive JAR, accompagnée de son fichier de configuration. Il suffit ensuite de placer les différentes archives dans le classpath de l'application utilisatrice, et de redémarrer celle-ci.

Export.png Sous Eclipse, il est facile de construire un tel jar : cliquez sur le package terminal avec le bouton droit, puis choisissez "Export..." puis "Java -> JAR file". Dans la boîte de dialogue, sélectionnez ensuite le répertoire META-INF ainsi que le package terminal.command contenant les implémentations du service. N'oubliez pas de cocher également "Export generated class files and resources" afin que les classes compilées soient ajoutées à l'archive. Enfin, choisissez le nom et le chemin de l'archive (ex: "commands.jar"), puis validez.

Note : si cette solution est pratique du point de vue packaging, elle empêche en revanche la redécouverte à chaud des implémentations, car Java impose que les jars soient déclarés nominativement et individuellement dans le classpath des applications à leur lancement (impossible d'importer "*.jar"...). Si vous souhaitez bénéficier de cette fonctionnalité, il est possible de déclarer un répertoire dédié comme faisant partie du classpath de l'application (ex: "commands"), et de déposer dedans les classes d'implémentation. Le fichier de configuration, quant à lui, devra malheureusement être fusionné à la main...

Découverte des implémentations

Il ne nous reste plus qu'à découvrir et instancier nos commandes.
La classe ServiceLoader<S> permet de le réaliser facilement, en deux étapes :

  1. Utiliser la méthode factory load(Class<S>) pour obtenir une instance de ServiceLoader spécialisée dans la découverte d'implémentations de notre service.
  2. Appeler la méthode iterator() de cette instance pour lancer le processus de découverte et obtenir un itérateur permettant de lister et récupérer les implémentations trouvées.

Note : le résultat de l'opération de découverte est mis en cache pour des raisons de performance, mais il est possible de vider ce cache et de forcer une redécouverte en appelant la méthode reload().

Voyons maintenant le ServiceLoader en action dans la classe CommandLine :

private ServiceLoader<Command> commandLoader = ServiceLoader.load(Command.class);
private Map<String, Command> commands = new HashMap<String, Command>();
 
private void initCommands()
{
	// Discover and register the available commands
	commands.clear();
	commandLoader.reload();
	Iterator<Command> commandsIterator = commandLoader.iterator();
	while (commandsIterator.hasNext())
	{
		Command command = commandsIterator.next();
		commands.put(command.getId(), command);
	}
}

La méthode initCommands() fait appel au ServiceLoader pour récupérer les commandes disponibles et les ranger dans une Map afin de pouvoir les retrouver facilement via leur identifiant. On notera qu'à chaque appel de cette méthode, la Map et le cache des implémentations sont vidés (lignes 7-8), afin de pouvoir prendre en compte à chaud l'ajout ou la suppression de commandes (si elles détectées dans le classpath, voir remarque plus haut).

A vous de jouer

A part la classe Command et ses implémentations, l'exemple comprend donc la classe CommandLine dont nous venons de voir un extrait, et qui est le coeur de l'application. Pour des raisons de place, le code source complet ne sera pas donné, mais dans l'ensemble son fonctionnement est très simple :

  • Au lancement, les commandes disponibles sont recherchées et classées selon leur identifiant
  • Puis, pour chaque ligne saisie par l'utilisateur...
    • Le premier mot donne l'identifiant de la commande à appeler; le reste de la ligne sera donné en paramètre à la commande.
    • On recherche dans la Map une commande répondant à cet identifiant, puis on l'appelle avec le paramètre déterminé à l'étape précédente.
    • Si la commande renvoie une valeur non nulle, on l'affiche.

Note : il existe 3 commandes pré-câblées permettant de sortir du programme ("quit"), de lister les commandes disponibles ("help"), et de lancer la redétection des commandes disponibles ("reset").

L'archive en pièce jointe contient deux jars (un pour le coeur de l'application, l'autre pour les commandes) et un répertoire META-INF/services laissé accessible pour que vous puissiez le modifier et tester la découverte à chaud des commandes.
Pour lancer le programme, il suffit d'exécuter le script correspondant à votre système d'exploitation ("run.sh" ou "run.bat"). La variable d'environnement "JAVA_HOME" doit exister et pointer sur le répertoire d'installation d'une JRE ou d'un JDK.

Une fois l'exemple lancé :

  • Entrez "help" dans la console : vous voyez que seule la commande "echo" est disponible.
  • Entrez "echo Hello World" et vérifiez que la commande fonctionne.
  • Examinez le fichier de configuration dans le répertoire META-INF/services : deux autres commandes sont apparemment disponibles mais pas activées. Supprimez les "#" pour décommenter les lignes et activer les commandes.
  • Entrez "reset" dans la console pour relancer le processus de découverte, puis "help" à nouveau pour vérifier que les nouvelles commandes sont disponibles.
  • Utilisez les nouvelles commandes pour vérifier qu'elles fonctionnent.
  • Entrez "quit" pour quitter l'application.

Capture-Terminal.png

Conclusion

Le mécanisme de Service Provider est simple et pratique pour associer une ou plusieurs implémentations à un service donné.
Si la prise en compte à chaud des modifications n'est pas requise, il est simple de packager des implémentations sous forme de jars redistribuables et versionnables, ce qui peut constituer une bonne base pour un système de plugins.


Commentaires

1. Le vendredi 29 janvier 2010, 21:24 par deadalnix

Et bien, ça faisait un moment que je cherche de l'info compréhensible la dessus. Et la j'en trouve en français, impeccable !

2. Le lundi 1 février 2010, 20:59 par Olivier Croisier

Content d'avoir fait un heureux :)

3. Le mercredi 10 février 2010, 17:23 par Pasca LAVAUX

Il y a quelques années, j'ai fait un truc dans ce goût là, moins sophistiqué, un poil plus astucieux (Java 1.4) qui marche à chaud:
C'était pour découvrir des classes de traitement batch dans un répertoire de mon application, l'idée était de faire la liste des classe du répertoire et de vérifier qu'elles implémentent bien l'interface attendue (IJob)
// Dans une boucle, nomClassTmp représente le nom de la classe, scheduler mon répertoire
...
Class classIJob = Class.forName("scheduler.IJob");
nomClassTmp = "scheduler." + nomClassTmp;
Class classTmp = Class.forName(nomClassTmp);
// Test si c'est un job (implément IJob et est une classe concrète)
if (!classTmp.isInterface() && classIJob.isAssignableFrom(classTmp))
{

  IJob job = (IJob)classTmp.newInstance();
  // S'il est commun ou spécifique à la société, on l'ajoute dans la liste
  if (job.getCodeSocieteConcernee().equals("*") || job.getCodeSocieteConcernee().equals(codeSoc))
  {
    listClasse.add(job);
  }

}
...
C'est pas du code blindé qui marche dans tous les cas, mais pour mon besoin c'est en production sur pas mal de sites, et surtout il n'y a pas de fichiers de configuration (très difficiles à maintenir quand vous êtes sur un produit installé sur de multiples sites avec des versions différentes)

4. Le jeudi 12 janvier 2012, 09:29 par armoret

Bonjour, vous indiquez ... Note : si cette solution est pratique du point de vue packaging, elle empêche en revanche la redécouverte à chaud des implémentations, car Java impose que les jars soient déclarés nominativement et individuellement dans le classpath des applications à leur lancement (impossible d'importer "*.jar"...) ...
Or il est possible depuis le JRE 6 (cela marche en tout cas très bien avec 1.6.0_24) d'utiliser le flag suivant : -cp .;.\* qui prends en compte tous les jars présents dans le répertoire (de lancement du JRE dans mon exemple). Le processus de découverte d'implémentation du spi va t-il fonctionner ? Je vais essayer mais si vous avez déjà la réponse, cela me fera gagner du temps...
Merci.

5. Le jeudi 12 janvier 2012, 15:30 par armoret

Suite de mon précédent commentaire: j'ai exporté le code d'exemple en trois jars presque identiques mais chacun ayant dans son "META-INF" une seule commande déclarée (les autres sont en commentaire). J'ai lancé le .bat modifié (java -cp .;.\* terminal.CommandLine) avec les trois jars dans le même répertoire (et sans laisser de répertoire META-INF à la racine) et cela fonctionne, les trois commandes sont bien indiquées comme disponibles.

6. Le vendredi 13 janvier 2012, 00:27 par Olivier Croisier

@armoret : Oui, on peut utiliser des wildcards depuis Java6. J'indique également que le système du SPi fonctionne avec autant de jars que l'on veut.
Par contre, je me rends compte que le paragraphe commençant par "Note:" n'est absolument pas clair. Il me semble que j'ai voulu dire que, effectivement, on pouvait déclarer *.jar dans le classpath, mais que ce n'était qu'un raccourci pour éviter de tous les nommer ; la liste est tout de même établie une fois pour toute au démarrage de la JVM. Du coup, rajouter un jar dans le classpath une fois l'application lancée ne permet pas de le prendre en compte "à chaud", malgré la notation wildcard.

7. Le mardi 29 mai 2012, 18:10 par Antoine Aumjaud

J'ai enfin eu l'occasion de mettre en pratique cette fonctionnalité sur un projet professionnel.
J'en profite pour te féliciter pour cet article, excellent comme toujours ;)

J'ai utilisé cette API pour charger dynamiquement des actions utilisées par un proxy dynamique que j’ai positionné sur tous mes services communiquant avec l’extérieur. Ce proxy intercepte les traitements de mes services en appliquant les actions. C'est très pratique pour fixer un bug en production: je peux modifier simplement les objets entrants ou sortant ou même le traitement en lui-même. Le tout sans relivrer tout le package et sans interruption de service ! De plus le code est compilé ce qui a de nombreux avantage sur d’autres méthode comme ScriptEngine...

Pour le cas présent, il était essentiel de pouvoir charger des "patchs" à chaud. Mais fusionner un fichier de configuration manuellement en production n'était pas envisageable.
J'ai donc mis en place un système de chargement de JAR à chaud : je cherche tous les jar d'un répertoire pour, à partir de leur URL, construire URLClassLoader.
Ensuite j'utilise la méthode ServiceLoader.load prenant un ClassLoader.
Ainsi tous les implémentations sont retrouvées à la demande et sans redémarrage.
Le projet n’étant pas en Java 7, je n'ai pas pu utiliser la nouvelle fonctionnalité de notification du système de fichiers :(

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.