Développons en Java
v 2.40   Copyright (C) 1999-2023 .   
32. JMX (Java Management Extensions) Partie 4 : Le système de modules Imprimer Index Index avec sommaire Télécharger le PDF

 

33. L'API Service Provider (SPI)

 

chapitre    3 3

 

Niveau : niveau 4 Supérieur 

 

L'API Service Provider propose un moyen simple d'offrir un découplage entre des fournisseurs d'implémentation d'une interface d'un service et un consommateur d'une ou plusieurs de ses implémentations. Elle permet de découvrir et obtenir dynamiquement des instances de classes qui implémentent une interface particulière.

Java 6 propose une fonctionnalité pour découvrir et charger les implémentations d'un service : Service Provider Interface (SPI). La classe ServiceLoader permet de facilement mettre en oeuvre des fonctionnalités de type service qui permettent un découplage entre un fournisseur et un consommateur.

Son utilisation n'est pas forcement beaucoup répandue sauf pour quelques besoins particuliers comme les drivers JDBC.

La classe ServiceLoader permet de charger et d'utiliser une ou plusieurs implémentations de manière dynamique au runtime. Un cas d'usage typique est la mise en oeuvre de plugins dans une application ou l'utilisation optionnelle d'une ou plusieurs implémentations d'une fonctionnalité comme un cache par exemple.

Ce chapitre contient plusieurs sections :

 

33.1. Introduction

Un service offre des fonctionnalités définies grâce à une interface ou une classe abstraite pour laquelle il existe zéro, une ou plusieurs implémentations.

Un service provider ou fournisseur est une implémentation d'un service : c'est donc une classe qui implémente l'interface ou hérite de la classe abstraite qui définit le service.

 

33.1.1. La différence entre une API et une SPI

Basiquement, la différence entre API et SPI peut être résumé ainsi : le but d'une API est d'être invoquée, le but d'une SPI est d'être implémentée.

API est l'acronyme d'Application Programming Interface : une API est un ensemble de fonctionnalités (classes et interfaces) qu'il est possible d'utiliser pour atteindre un objectif par programmation.

L'ajout de fonctionnalités dans une API ne posent généralement pas de soucis au client qui l'utilise. Par contre, la modification ou la suppression de fonctionnalités doit être documentée et les clients qui l'utilise doivent être informés des impacts possibles.

SPI est l'acronyme de Service Provider Interface : c'est une technique de programmation qui permet la substitution de composants qui respectent une même interface.

Une SPI est un ensemble de classes et interfaces qu'il est possible d'étendre ou implémenter pour atteindre un objectif. C'est un moyen d'injecter, d'étendre ou de fournir des implémentations dédiées.

Une SPI est une API qui doit être implémentée ou étendue par un fournisseur tiers. Une SPI est généralement utilisée pour étendre un framework ou permettre le remplacement de composants. Elle permet à une API d'être évolutive en proposant des implémentations différentes ou supplémentaires.

L'ajout de fonctionnalités dans une SPI peut engendrer des incompatibilités dans les implémentations existantes.

Généralement, API et SPI sont séparées. Par exemple avec JDBC, l'interface Driver fait partie de son SPI. Son utilisation n'est pas obligatoire pour utiliser JDBC mais elle doit être implémentée par chaque fournisseur de pilote JDBC.

Parfois une classe ou une interface peut faire partie de l'API et de la SPI : c'est par exemple le cas pour l'interface Connection de l'API JDBC. C'est un des éléments primordiaux à utiliser pour accéder à une base de données et elle doit aussi être implémentée par le fournisseur d'un pilote JDBC.

Généralement l'utilisation de l'API ne nécessite pas d'utiliser des types de la SPI et vice versa.

Autre exemple avec JNDI : JNDI propose des interfaces et des classes pour rechercher des objets dans un contexte. Cette recherche se fait par défaut en utilisant la classe InitialContext. Cette classe utilise en interne des interfaces d'une SPI pour obtenir des implémentations spécifiques.

 

33.1.2. Un framework pour service provider

Un framework pour fournisseur de services (service provider) est un système dans lequel plusieurs fournisseurs de services implémentent un service, et le système permet à des consommateurs d'obtenir des instances des implémentations en découplant les fournisseurs des consommateurs.

Un framework pour fournisseur de services comporte plusieurs éléments principaux :

Exemple avec JDBC :

 

33.1.2.1. Un exemple d'implémentation basique

Une SPI peut être de différentes manières plus ou moins compliquées notamment en utilisant certains design patterns et/ou l'API Introspection.

L'exemple de cette section propose une implémentation très basique.

Le service est décrit dans l'interface Service

Exemple :
public interface Service {
        
  void afficher();
}

Deux implémentations sont proposées dont celle qui sera utilisée par défaut.

Exemple :
public class ServiceA implements Service {
 
  @Override
  public void afficher() {
    System.out.println("Service A");
  }
}

Exemple :
public class ServiceParDefaut implements Service {
 
  @Override
  public void afficher() {
    System.out.println("Service par défaut");
  }
}

Le fournisseur d'une implémentation du service doit implémenter l'interface Fournisseur. Elle ne définit qu'une seule méthode qui permet d'obtenir une instance de l'implémentation du service.

Exemple :
public interface Fournisseur {
  Service getService();
}

Deux implémentations sont proposées dont celle qui sera utilisée par défaut.

Exemple :
public class FournisseurParDefaut implements Fournisseur {
 
  @Override
  public Service getService() {
    return new ServiceParDefaut();
  }
}

Exemple :
public class FournisseurA implements Fournisseur {
 
  @Override
  public Service getService() {
    return new ServiceA();
  }
}

La classe Services permet d'enregistrer les fournisseurs et d'obtenir des instances de leurs implémentations du service.

Exemple :
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
 
public final class Services {
 
  public static final String NOM_FOURNISSEUR_PAR_DEFAUT = "<def>";
 
  private Services() { }
 
  private static final Map<String, Fournisseur> fournisseurs =
    new ConcurrentHashMap<String, Fournisseur>();
 
    public static void registerDefaultProvider(Fournisseur fournisseur) {
      registerProvider(NOM_FOURNISSEUR_PAR_DEFAUT, fournisseur);
    }
 
    public static void registerProvider(String nom, Fournisseur fournisseur){
      fournisseurs.put(nom, fournisseur);
    }
 
    public static Service getInstance() {
      return getInstance(NOM_FOURNISSEUR_PAR_DEFAUT);
    }
 
    public static Service getInstance(String nom) {
      Fournisseur p = fournisseurs.get(nom);
      if (p == null) {
        throw new IllegalArgumentException(
          "Impossible de trouver le fournisseur " + nom);
      }
      return p.getService();
    }
}

La classe Client permet de tester en enregistrant deux fournisseurs et en obtenant leurs implémentations du service.

Exemple :
public class Client {
 
  public static void main(String[] args) {
    Services.registerDefaultProvider(new FournisseurParDefaut());
    Services.registerProvider("A", new FournisseurA());
 
    Service service = Services.getInstance();
    service.afficher();
               
    service = Services.getInstance("A");
    service.afficher();               
  }
}

 

33.1.3. Le chargement dynamique de classes

La manière dont Java utilise les classes introduit un niveau d'abstraction avec les ClassLoaders.

Un ClassLoader a la responsabilité de charger une classe dans la JVM, sans que la JVM sache d'où elle provient. La classe n'est d'ailleurs pas forcement lue à partir d'un fichier et peut par exemple l'être à partir du réseau ou de la mémoire dans le cas d'un proxy dynamique.

Dans ce cas, il est nécessaire d'utiliser un ClassLoader dédié qui est en mesure de charger dynamiquement une classe qui n'est pas incluse dans le classpath.

Historiquement, un ClassLoader est utilisé pour charger les classes. Une fois chargée, il est possible de créer des instances d'une classe.

Exemple :
MaClasse instance = Class.forName("fr.jmdoudoux.dej.MaClasse", 
                      true, this.getClassLoader()).newInstance();

Il est nécessaire de connaître le nom pleinement qualifié de la classe à utiliser.

Il n'est pas possible d'obtenir à partir d'un ClassLoader la liste de toutes les classes contenues dans le classpath. Aucune méthode de la classe ClassLoader ne renvoie un tableau, une collection ou un Stream de classes. Il est possible d'obtenir un tableau des packages accessibles via le ClassLoader mais il n'est pas possible d'obtenir la liste des classes des packages.

Le JDK doit donc proposer un mécanisme dédié pour permettre de trouver les implémentations fournies dans le classpath et dans le module path à partir de Java 9.

 

33.1.4. L'utilisation par Java SE et Java EE

Le JDK lui-même utilise également ce mécanisme pour certaines fonctionnalités.

Une SPI est utilisée par plusieurs fonctionnalités du JRE (JDBC, JCE, JNDI, JAX-P, NIO, ...). Généralement par convention dans le JDK, les packages de classes qui pourront être étendues pour définir des services sont suffixées par spi.

Java SE Core contient plusieurs SPI notamment dans les packages :

Java EE propose aussi plusieurs SPI notamment :

L'intérêt de ce mécanisme est de proposer un découplage entre un consommateur et une ou plusieurs implémentations chargées dynamiquement.

Ce mécanisme est utilisé par exemple par la classe java.sql.DriverManager pour trouver les implémentations de l'interface java.sql.Driver. Historiquement, le chargement de la classe d'un driver JDBC impliquait l'exécution d'un bloc de code static qui enregistre le driver de type java.sql.Driver dans le DriverManager.

A partir de Java 6, il suffit d'ajouter le jar du driver proposé sous la forme d'un service dans le classpath et celui-ci sera automatiquement utilisable. Il n'est alors plus nécessaire de charger la classe en utilisant la méthode forName() de la classe Class. Ce mécanisme reste cependant utilisable pour des raisons de compatibilité.

La classe abstraite System.LoggerFinder de Java 9 peut être implémentée en tant que service. S'il existe une implémentation, la méthode System.getLogger() utilise le ServiceLoader pour la trouver. De cette manière, la journalisation n'est pas liée au JDK, ni à une bibliothèque à la compilation. Il suffit de fournir une implémentation du Logger à l'exécution et l'application, les bibliothèques utilisées par l'application et le JDK utiliseront tous cette implémentation de la journalisation.

 

33.2. La mise en oeuvre

Les fonctionnalités d'un service sont définies grâce à une interface ou une classe abstraite.

Un fournisseur de service est une implémentation d'un service. Plusieurs implémentations d'un service peuvent être proposées par un ou plusieurs fournisseurs (providers).

Un consommateur (consumer) ou client peut utiliser une ou plusieurs implémentations d'un service.

Les services permettent un découplage entre fournisseurs et consommateurs. Le consommateur ne connait que l'interface du service.

Ce type de fonctionnalité permet de proposer une ou plusieurs implémentations ou de mettre en place un système de plugins.

Le principe de fonctionnement est similaire à celui utilisé par un framework d'injection de dépendances.

Les implémentations déclarées dans les sous-répertoires META-INF/services ou dans des modules peuvent être trouvées par la classe ServiceLoader : elle permet d'obtenir la liste des implémentations d'un service et d'en obtenir des instances.

L'API SPI utilise quatre composants :

La mise en oeuvre de l'API repose sur 3 éléments :

L'implémentation d'un service peut être encapsulé :

La classe ServiceLoader permet de rechercher les services disponibles indifféremment dans le classpath et le module path et permet de charger les implémentations au runtime pour fournir des instances au consommateur.

A la compilation d'un consommateur, l'API ServiceLoader n'a besoin de connaitre que l'interface du service.

 

33.2.1. La définition d'un service

Un service est décrit dans un type : une interface ou une classe abstraite.

Un service peut avoir autant de méthodes que le requière ses fonctionnalités. Chacune de ces méthodes pourra être invoquées lors de l'obtention d'une instance du service.

Exemple :
package fr.jmdoudoux.dej.spi;
 
public interface MonService {
 
  public void afficher();
}

 

33.2.2. L'implémentation d'un service

Un service provider est une implémentation d'un service.

La classe d'implémentation d'un service doit être publique et ne peut pas être une classe interne.

Un fournisseur peut proposer une ou plusieurs implémentations du service sous la forme de classes concrètes qui implémentent l'interface ou héritent de la classe abstraite.

Exemple dans un jar serviceimplA

Exemple :
package fr.jmdoudoux.dej.spi;
 
public class MonServiceImplA implements MonService {
 
  @Override
  public void afficher() {
    System.out.println("MonServiceImplA");
  }
}

Exemple dans un jar serviceimplB

Exemple :
package fr.jmdoudoux.dej.spi;

public class MonServiceImplB implements MonService{
 
  @Override
  public void afficher() {
    System.out.println("MonServiceImplB");
  }
}

 

33.2.3. Le fichier de configuration du Provider

La configuration des implémentations du service provider se fait dans un fichier texte dans le sous-répertoire META-INF/services.

Pour chaque service dont une ou plusieurs implémentations sont proposées, il faut définir un fichier texte qui est un fichier de description des implémentations fournies dans le sous-répertoire META-INF/services

Le nom de ce fichier doit correspondre au nom pleinement qualifié du type du service. Il doit contenir le nom pleinement qualifié de chaque implémentation, chacune sur une ligné dédiée.

Ce fichier doit contenir le nom pleinement qualifié de la ou des classes d'implémentation du service, chacune sur une ligne dédiée.

Plusieurs règles doivent être appliquées :

Il est possible d'utiliser des commentaires qui débutent par un caractère dièze '#'. Tous les caractères qui suivent un caractère # sont ignorés.

Exemple dans un jar serviceimplA, le fichier META-INF/services/fr.jmdoudoux.dej.spi.MonService

Résultat :
fr.jmdoudoux.dej.spi.MonServiceImplA 

Exemple dans un jar serviceimplB, le fichier META-INF/services/fr.jmdoudoux.dej.spi.MonService

Résultat :
fr.jmdoudoux.dej.spi.MonServiceImplB 

 

33.3. La consommation d'un service

Le consommateur n'a pas à connaître l'emplacement de l'instance obtenue : c'est la classe ServiceLoader qui se charge de trouver et de fournir des instances des implémentations disponibles.

Un consommateur peut obtenir une instance :

 

33.3.1. Le déploiement d'un service dans le classpath

Généralement, la ou les classes d'implémentation sont packagées dans un fichier jar qu'il faut ajouter dans le classpath.

La classe d'implémentation d'un service indiqué dans un fichier de configuration peut se trouver dans le même fichier JAR que le fichier de configuration ou dans un fichier JAR différent.

La classe d'implémentation doit pouvoir être chargée par le ClassLoader initialement utilisé pour trouver la liste des implémentations disponibles.

La JVM scanne les jars dans le classpath à la recherche des fichiers de configuration présents dans les sous-répertoires META-INF/services et enregistre les classes trouvées dans un registre.

Comme le chargement est dynamique, pour permettre la prise en compte d'une nouvelle implémentation d'un service, il suffit de l'ajouter ou de le retirer dans le classpath sans avoir à modifier le code.

 

33.3.2. La classe ServiceLoader

Pour trouver et obtenir une instance d'une ou de toutes les implémentations d'un service proposées par un ou plusieurs fournisseurs, il faut utiliser la classe java.util.ServiceLoader. La classe java.util.ServiceLoader<S> permet de découvrir dynamiquement et charger au runtime les implémentations d'un service de type S.

La classe ServiceLoader du JDK permet d'injecter une instance dynamiquement au runtime sans avoir recours à un framework d'injection de dépendances.

La classe ServiceLoader a été introduite en Java 6 et mise à jour en Java 9. Elle est final et elle implémente l'interface Iterable.

Les implémentations trouvées par le ServiceLoader doivent être préalablement enregistrées grâce au mécanisme particulier détaillé précédemment.

La classe ServiceLoader possède plusieurs méthodes :

Méthode

Rôle

Optional<S> findFirst()

Obtenir la première instance disponible s'il en existe une (depuis Java 9)

Iterator<S> iterator()

Renvoyer un Iterator pour obtenir les instances du service

static <S> ServiceLoader<S> load(Class<S> service)

Renvoyer une instance pour le type fourni en paramètre. Les classes sont chargées en utilisant le ClassLoader du contexte du thread courant. Cette méthode est équivalente à ServiceLoader.load(service, Thread.currentThread().getContextClassLoader())

static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader)

Renvoyer une instance pour le type fourni en paramètre chargé via le ClassLoader précisé

static <S> ServiceLoader<S> load(ModuleLayer layer, Class<S> service)

Renvoyer une instance pour le type fourni en paramètre. Les classes sont chargées uniquement parmi celles contenues dans le ModuleLayer. Aucun service n'est donc chargé à partir de l'unnamed module. Contrairement aux autres méthodes, le type du service est en second paramètre (depuis Java 9)

static <S> ServiceLoader<S> loadInstalled(Class<S> service)

Renvoyer une instance pour le type fourni en paramètre. Les classes sont chargées en utilisant le ClassLoader de la plateforme. Cette méthode est équivalente à ServiceLoader.load(service, ClassLoader.getPlatformClassLoader()). Cette méthode ne permet donc pas de charger des services contenus dans le classpath ou le module path

void reload()

Vider le cache et recharger les services

Stream<ServiceLoader.Provider<S>> stream()

Renvoyer un Stream<ServiceLoader.Provider> pour traiter de manière lazy les services (Depuis Java 9)

String toString()

Renvoyer une description du service

 

33.3.2.1. L'obtention d'un ServiceLoader

Pour obtenir une instance de type ServiceLoader, il faut utiliser les méthodes load() ou loadInstalled() en leur passant en paramètre le type du service. Plusieurs surcharges sont proposées pour utiliser le ClassLoader par défaut ou celui fournit en paramètre.

La classe ServiceLoader propose plusieurs méthodes qui sont des fabriques pour charger les implémentations du service :

La méthode statique load() de la classe ServiceLoader permet obtenir une instance de type ServiceLoader qui permet de parcourir la liste des implémentations enregistrées pour l'interface du service passée en paramètre.

Exemple :
ServiceLoader<MonService> services = ServiceLoader.load(MonService.class);

La méthode load(), qui attend en paramètre le type du service, recherche les implémentations et charge les types en utilisant le ClassLoader par défaut. Une autre surcharge permet de préciser le ClassLoader à utiliser : celui-ci peut être utilisé pour personnaliser la recherche des implémentations du service.

La méthode loadInstalled() cherche des implémentations dans le répertoire d'extension du JRE, le sous-répertoire jre/lib/ext. Les jars qu'il contient peuvent être utilisés par toutes les applications exécutées par le JRE.

 

33.3.2.2. La recherche des implémentations par le ServiceLoader

Un ServiceLoader permet de trouver et instancier des implémentations d'un service.

Le ServiceLoader n'est capable de détecter que les implémentations définies dans les descripteurs de modules avec provides et dans le classpath avec des fichiers dans le sous-répertoire META-INF/services.

Les méthodes iterator() et stream() de la classe ServiceLoader recherche les fournisseurs d'implémentations dans le classpath et à partir de Java 9 aussi dans les descripteurs de module présents dans le module-path.

 

33.3.2.2.1. La recherche dans le classpath ou les unamed modules

Les fournisseurs de services dans le classpath sont localisés si leurs noms de classe sont listés dans les fichiers de configuration du fournisseur trouvés par la méthode getResources() du ClassLoader.

Le ServiceLoader utilise la méthode getResources() du ClassLoader pour trouver le fichier dont le nom correspond au nom pleinement qualifié du service dans le sous-répertoire META-INF/services. La lecture du fichier permet d'obtenir le ou les noms pleinement qualifiés des classes d'implémentation du service.

L'ordre est basé sur l'ordre dans lequel la méthode getResources() du ClassLoader trouve les fichiers de configuration du service et dans celui-ci sur l'ordre dans lequel les noms de classe sont listés dans le fichier.

Les fournisseurs de service présents dans un fichier de configuration, sont ignorés lorsque leurs implémentations sont dans des modules nommées. Cela permet d'éviter les doublons qui se produiraient autrement lorsqu'un module nommé possède à la fois une directive "provides" et un fichier de configuration qui mentionne le même fournisseur de services.

 

33.3.2.2.2. La recherche dans les modules nommés

Le ServiceLoader recherche d'abord les modules définis par le ClassLoader puis recherche en remontant la hiérarchie des ClassLoaders jusqu'au bootstrap ClassLoader. L'ordre des modules dans le même ClassLoader n'est pas défini.

Si un module déclare plus d'un fournisseur, les fournisseurs sont recherchés dans l'ordre dans lequel le descripteur du module énumère les fournisseurs.

Les fournisseurs ajoutés dynamiquement en utilisant la méthode redefineModule() de l'interface java.lang.instrument.Instrumentation sont toujours situés après les fournisseurs déclarés dans le descripteur de module.

 

33.3.2.3. L'obtention des implémentations par le ServiceLoader

Le ServiceLoader permet d'obtenir les implémentations du service trouvées. Il est possible d'utiliser la première implémentation trouvée ou de parcourir l'ensemble des implémentations trouvées pour un service donné.

Les implémentations d'un service sont chargées et instanciées de manière lazy donc uniquement lorsqu'on en a besoin.

Chaque implémentation doit proposer un constructeur par défaut, donc sans argument, qui sera utilisé par le ServiceLoader pour créer dynamiquement des instances des implémentations trouvées via l'API Reflexion.

Un ServiceLoader contient un cache des implémentations obtenues pendant leur parcours.

Pour obtenir les différentes implémentations utilisables, il est possible d'utiliser :

La classe ServiceLoader implémente l'interface Iterable, ce qui permet un parcours des différentes implémentations trouvées.

La méthode iterator() permet d'obtenir un Iterator pour parcourir les implémentations trouvées par la JVM et d'obtenir des instances qu'il sera possible d'utiliser. Ces instances sont mises en cache pour des raisons de performances. La méthode iterator() renvoie un Iterator : chaque instance obtenue renvoie d'abord les instances mises en cache lors des invocations précédentes puis trouve et instancie de manière lazy les implémentations restantes après les avoir mises en cache.

L'utilisation de l'Iterator obtenu peut lever une exception de type ServiceConfigurationError en cas de soucis. Les invocations suivantes de l'Iterator ne sont alors pas garanties de succès.

L'Iterator renvoyé par la méthode iterator() ne permet pas de retirer un élément : l'invocation de la méthode remove() lève une exception de type UnsupportedOperationException.

L'utilisation de l'Iterator obtenu en invoquant la méthode iterator() permet facilement de parcourir les instances. Cependant son inconvénient est qu'elle oblige la création d'une instance de chacune des implémentations trouvées même si elle n'est pas utilisée.

Exemple ( code Java 6 ) :
     ServiceLoader<MonService> loader = ServiceLoader.load(MonService.class);
     for (MonService service : loader) {
        // ... utilisation de l'instance obtenue
     }

Depuis Java 9, la méthode stream() renvoie un Stream<ServiceLoader.Provider> permettant de manipuler de manière lazy les implémentations du service. Elle permet de parcourir les services de manière lazy sans avoir à créer une instance de ces services.

Les implémentations déjà chargées dans le cache sont traitées dans leur ordre de chargement puis les implémentations restantes sont traitées au besoin.

Chacune de ces implémentations est encapsulée dans une instance de type de l'interface ServiceLoader.Provider. Cette interface propose deux méthodes :

Méthode

Rôle

S get()

Obtenir une instance du provider

Class< ? extends S> type()

Obtenir le type du provider


Pour obtenir une instance de l'implémentation du service, il faut obligatoirement invoquer la méthode get() de la classe Provider. Si l'implémentation du service ne peut être chargée, alors une exception de type ServiceConfigurationError est levée.

La méthode stream() renvoie un Stream qui permet de sélectionner ou filtrer les implémentations trouvées de manière lazy donc sans avoir à en créer une instance.

Par exemple pour obtenir les instances dont le type se termine par « B »

Exemple ( code Java 9 ) :
    List<MonService> ser = services.stream()
        .filter(p -> p.type().getName().endsWith("B"))
        .map(Provider::get)
        .collect(Collectors.toList());

Ou pour obtenir les instances dont le type n'est pas annoté avec @Deprecated

Exemple ( code Java 9 ) :
    List<MonService> ser = services.stream()
        .filter(p -> !p.type().isAnnotationPresent(Deprecated.class))
        .map(Provider::get)
        .collect(Collectors.toList());

Un consommateur ne connait que l'interface ou la classe qui définit le service : elle ne connait pas la ou les implémentations qu'elle va utiliser au travers du type du service. Un consommateur utilise un ServiceLoader pour charger une ou plusieurs implémentations. Cela implique que le client sache :

A partir de Java 9, il est aussi possible d'obtenir uniquement la première implémentation trouvée en invoquant la méthode first() qui renvoie un Optional<S>.

Exemple ( code Java 9 ) :
    Optional<MonService> monService = ServiceLoader
                .load(MonService.class)
                .findFirst();
    monService.ifPresent(MonService::afficher);

 

33.3.2.4. L'utilisation d'un cache par le ServiceLoader

Les implémentations sont trouvées et chargées à la demande. La classe ServiceLoader possède un cache des implémentations qui ont été chargées. Le cache est rempli à la première recherche d'un service.

Le ServiceLoader utilise ce cache : l'Iterator renvoie d'abord les éléments du cache, dans l'ordre dans lequel ils ont été chargés. Il charge et instancie ensuite tous les fournisseurs de services restants, en ajoutant chacun d'eux au cache.

Le cache peut être réinitialisé en invoquant la méthode reload() de la classe ServiceLoader. Suite à l'invocation de la méthode reload(), le cache de la liste des classes d'implémentation sera reconstruit lors du parcours suivant son invocation.

Si la méthode reload() est invoquée, alors il ne faut plus utiliser d'Iterator obtenu pour le service avant l'invocation de la méthode reload() sinon ces méthodes lève une exception de type ConcurrentModificationException.

La méthode reload() est par exemple utile dans le cas où un nouveau service est ajouté dans l'environnement d'exécution.

 

33.3.3. Les exceptions lors de l'utilisation de la classe ServiceLoader

Une exception de type ServiceConfigurationError peut être levée par les méthodes hasNext() et next() de l'Iterator du ServiceLoader si une erreur survient durant la recherche, le chargement ou la création d'une instance d'un service. Cette exception peut aussi être levée lors de l'exploitation du Stream retournée par la méthode stream().

Plusieurs situations peuvent engendrer une erreur, notamment :

 

33.3.4. Les limitations de la classe ServiceLoader

La classe ServiceLoader n'est pas thread-safe.

La classe ServiceLoader est final : il n'est donc pas possible de créer une classe fille dans laquelle, une ou plusieurs de ces méthodes soient redéfinies pour changer son comportement.

Il est cependant possible de préciser un ClassLoader dédié pour trouver et charger les classes.

La classe ServiceLoader ne permet pas de détecter qu'une nouvelle implémentation d'un fournisseur est ajoutée à l'exécution. La classe ServiceLoader ne peut pas nous informer de l'ajout d'une nouvelle implémentation après la première recherche des implémentations d'un service.

La classe ServiceLoader est disponible depuis Java 1.3 pour une utilisation interne dans le JDK et pour une utilisation publique depuis la version 6 de Java.

 

33.4. Un exemple complet

Il faut définir l'interface du service dans un jar nommé monservice

Exemple :
package fr.jmdoudoux.dej.service;
 
public interface MonService {
 
   String traiter(String libelle);
}

Il faut créer une implémentation de l'interface MonService dans un jar nommé monservice_impla

Exemple :
package fr.jmdoudoux.dej.service.impla;
 
import fr.jmdoudoux.dej.service.MonService;
 
public class MonServiceImplA implements MonService {
 
  public MonServiceImplA() {
    System.out.println("Creation instance "+this.getClass().getName());
  }
  
  @Override
  public String traiter(String libelle) {
    return "MonServiceImplA : " + libelle;
  }
}

Il faut aussi ajouter dans ce jar, les sous-répertoires META-INF/services qui contient un fichier nommé du nom pleinement qualifié de l'interface du service, soit fr.jmdoudoux.dej.service.MonService. Ce fichier texte doit uniquement contenir le nom pleinement qualifié de l'implémentation du service :

Résultat :
fr.jmdoudoux.dej.service.impla.MonServiceImplA

Dans un jar nommé MonConsommateur, il faut créer une classe qui va utiliser une implémentation de l'interface MonService obtenue en utilisation la classe ServiceLoader.

Exemple ( code Java 6 ) :
package fr.jmdoudoux.dej.consommateur;
 
import java.util.NoSuchElementException;
import java.util.ServiceLoader;
 
import fr.jmdoudoux.dej.service.MonService;
 
public class MonConsommateur {
 
  public static void main(String[] args) {
    ServiceLoader<MonService> loader;
    loader = ServiceLoader.load(MonService.class);
    MonService service = loader.iterator().next();
    if(service != null) {
      String message = service.traiter("mon libelle");
      System.out.println(message);
    } else {
      throw new NoSuchElementException("Aucune implementation de MonService");
    }
  }
}

Pour exécuter cette classe, il faut ajouter dans le classpath les jar monservice et monservice_impla

Résultat :
Creation instance fr.jmdoudoux.dej.service.impla.MonServiceImplA
MonServiceImplA : mon libelle

Si le jar monservice_impla est retiré du classpath, alors aucune implémentation du service n'est trouvée par le ServiceLoader.

Résultat :
Exception in thread "main" java.util.NoSuchElementException
        at java.base/java.util.ServiceLoader$2.next(ServiceLoader.java:1308)
        at java.base/java.util.ServiceLoader$2.next(ServiceLoader.java:1296)
        at java.base/java.util.ServiceLoader$3.next(ServiceLoader.java:1394)
        at fr.jmdoudoux.dej.consommateur.MonConsommateur.main(MonConsommateur.java:13)

Il est possible de créer dans un jar monservice_implb contenant une seconde implémentation de l'interface du Service.

Exemple :
package fr.jmdoudoux.dej.service.implb;
 
import fr.jmdoudoux.dej.service.MonService;
 
public class MonServiceImplB implements MonService {
 
  public MonServiceImplB() {
    System.out.println("Creation instance "+this.getClass().getName());
  }
  
  @Override
  public String traiter(String libelle) {
    return "MonServiceImplB : " + libelle;
  }
}

Le jar doit aussi contenir un fichier META-INF/services/fr.jmdoudoux.dej.service.MonService contenant :

Résultat :
fr.jmdoudoux.dej.service.implb.MonServiceImplB

En exécutant la classe MonConsommateur avec le jar monservice_implb dans le classpath

Résultat :
Creation instance fr.jmdoudoux.dej.service.implb.MonServiceImplB
MonServiceImplB : mon libelle

Le consommateur peut aussi vouloir utiliser toutes les instances trouvées dans le classpath. Il suffit simplement itérer sur l'instance de type ServiceLoader obtenue

Exemple ( code Java 6 ) :
package fr.jmdoudoux.dej.consommateur;
 
import java.util.NoSuchElementException;
import java.util.ServiceLoader;
 
import fr.jmdoudoux.dej.service.MonService;
 
public class MonConsommateur {
 
  public static void main(String[] args) {
    ServiceLoader<MonService> loader;
    loader = ServiceLoader.load(MonService.class);
 
    for (MonService service : loader) {
      String message = service.traiter("mon libelle");
      System.out.println(message);
    }
  }
}

En exécutant la classe MonConsommateur avec les jars monservice_impla et monservice_implb dans le classpath, les deux instances sont utilisées.

Résultat :
Creation instance fr.jmdoudoux.dej.service.implb.MonServiceImplB
MonServiceImplB : mon libelle
Creation instance fr.jmdoudoux.dej.service.impla.MonServiceImplA
MonServiceImplA : mon libelle 

Il est possible de créer une classe de type Provider dont le rôle est de faciliter l'obtention d'une instance d'un service.

 

33.5. Les services de JPMS

Un module est un artefact contenant une description de sa configuration et qui expose des types, encapsule les classes d'implémentation et peut proposer des services.

Un service dans JPMS est un type que le code d'un module souhaite utiliser, dont au moins un autre module propose une implémentation.

A partir de Java 9, il est possible d'utiliser des services dans des modules :

Le système de modules propose une solution élégante pour mettre en oeuvre le découplage entre le ou les fournisseurs et le consommateur d'un service. Cette solution repose sur l'API ServiceLoader de Java 6.

Java 9 permet toujours l'utilisation de fichiers dans le sous-répertoire META-INF/services mais il propose aussi un mécanisme supplémentaire pour les modules de déclarations des services.

Le système de module de Java permet de facilement déclaration la fourniture et la consommation d'un service dans le descripteur de module.

Ce mécanisme utilise une syntaxe particulière pour facilement déclarer la fourniture et la consommation d'un service dans le descripteur de module :

Ce nouveau mécanisme ne repose donc plus sur un fichier texte mais est contenu dans le descripteur de module

L'avantage de l'intégration de la définition dans le code Java est que le compilateur peut faire des vérifications notamment sur les types utilisés.

 

33.5.1. Le module qui contient l'interface du service

Le module contient la définition du service sous la forme d'une interface.

Exemple :
package fr.jmdoudoux.dej.service;
public interface MonService {
   String traiter(String libelle);
}

Le descripteur de module exporte le package qui contient l'interface du service car celle-ci devra être accessible par les classes d'implémentation et les consommateurs.

Exemple ( code Java 9 ) :
module MonService {
  exports fr.jmdoudoux.dej.service;
}

 

33.5.2. Un fournisseur de service dans un module

Dans un module qui fournit une implémentation d'un service, il faut :

 

33.5.2.1. L'implémentation du service dans le module

L'implémentation doit respecter plusieurs règles :

Une instance d'une implémentation d'un service sera créée en invoquant son constructeur par défaut en utilisant l'API Reflection.

Si une implémentation d'un service est déployée dans un automatic module (jar standard mis dans le module path), une instance du service sera aussi obtenue en invoquant son constructeur par défaut.

Dans Java 9, le fournisseur n'a pas l'obligation d'implémenter l'interface sous réserve qu'elle propose une méthode publique statique nommée provider() sans paramètre. Cette méthode est une fabrique dont le but est de fournir une instance du service qui implémente cette interface.

Une implémentation d'un service contenue dans un module peut ainsi avoir un contrôle sur la manière dont l'API ServiceLoader va en créer une instance en proposant une fabrique sous la forme d'une méthode avec une signature spécifique :

public static type_du_service provider()

Cette méthode doit donc :

La classe qui contient une fabrique nommée provider n'est pas obligée d'implémenter l'interface ou d'hériter de la classe du service.

Remarque : l'utilisation d'une fabrique provider n'est possible que dans un jar modulaire mis dans le module path

 

33.5.2.2. La déclaration dans le descripteur de module

Un module qui propose une implémentation d'un service doit le déclarer dans son descripteur de module.

Dans le descripteur de module, il faut deux éléments :

Il n'est pas nécessaire et est même fortement recommandé de ne pas exporter le package contenant l'implémentation d'un service. Cela permet de renforcer le découplage.

Pour permettre l'enregistrement de l'implémentation d'un service encapsulé dans un jar modulaire, il faut utiliser la directive provides dans le descripteur du module. La directive provides permet d'indiquer que le module est un fournisseur pour une implémentation du service. Sa syntaxe est de la forme :

provides interface_service with interface_implementation;

La déclaration se fait avec une directive provides suivi du nom pleinement qualifié du type de l'interface, de l'instruction with puis du nom pleinement qualifié de la classe d'implémentation du service : elle fournit donc les informations nécessaires pour enregistrer l'implémentation dans le registre et ainsi permettre à l'API ServiceLoader de la trouver.

Exemple ( code Java 9 ) :
import fr.jmdoudoux.dej.spi.impla.MonServiceImplA;
import fr.jmdoudoux.dej.spi.service.MonService;
 
module fr.jmdoudoux.dej.spi.impla {
 
  requires fr.jmdoudoux.dej.spi.service;
  
  provides MonService with MonServiceImplA; 
}

Un module peut proposer plusieurs implémentations : dans ce cas, il faut préciser chacun des noms pleinement qualifiés de chaque classe dans la clause with en les séparant par une virgule.

Exemple ( code Java 9 ) :
import fr.jmdoudoux.dej.spi.impla.MonServiceImplA;
import fr.jmdoudoux.dej.spi.impla.MonServiceImplC;
import fr.jmdoudoux.dej.spi.service.MonService;
 
module fr.jmdoudoux.dej.spi.impla {
 
  requires fr.jmdoudoux.dej.spi.service;
  
  provides MonService with MonServiceImplA, MonServiceImplC; 
}

Un module peut aussi proposer des implémentations pour plusieurs services différents.

Un autre module peut contenir une ou plusieurs autres implémentations du service.

Exemple ( code Java 9 ) :
import fr.jmdoudoux.dej.spi.implb.MonServiceImplB;
import fr.jmdoudoux.dej.spi.service.MonService;
 
module fr.jmdoudoux.dej.spi.implb {
 
  requires fr.jmdoudoux.dej.spi.service;
 
  provides MonService with MonServiceImplB;
}

 

33.5.3. La consommation de services d'un module

L'application cliente n'a pas à connaitre directement la ou les implémentations qu'elle va utiliser. Il suffit simplement de mettre le ou les modules contenant une implémentation dans le module-path. Ces implémentations seront détectées automatique grâce aux informations contenues dans les descripteurs de modules.

Deux actions doivent être mise en oeuvre pour consommer un service :

 

33.5.3.1. La déclaration dans le descripteur de module

Un module qui a besoin d'utiliser un service doit le déclarer dans son descripteur de module.

Dans le descripteur d'un module dont une classe va consommer un service, il faut :

Cette déclaration se fait à l'aide de la directive uses suivi du nom de l'interface ou de la classe abstraite qui définit les fonctionnalités du service.

Exemple ( code Java 9 ) :
import fr.jmdoudoux.dej.spi.service.MonService;
 
module fr.jmdoudoux.dej.spi {
  exports fr.jmdoudoux.dej.spi;
 
  requires fr.jmdoudoux.dej.spi.service;
  
  uses MonService;
}

La directive uses est suivie du nom pleinement qualifié du type de l'interface du service.

Par défaut, il n'est pas nécessaire de déclarer la dépendance vers le ou les modules d'implémentation du service car les implémentations sont gérées en interne dans le ServiceLoader.

 

33.5.3.2. L'utilisation de la classe ServiceLoader

Une classe qui souhaite utiliser une implémentation d'un service doit utiliser la classe ServiceLoader.

L'utilisation de la classe ServiceLoader se fait comme vu dans la section dédiée précédente.

Son implémentation interne permet de trouver des fournisseurs dans le classpath et dans les descripteurs de modules dans le module path.

 

33.5.3.3. Faciliter la consommation d'un service

Une possibilité intéressante, surtout d'un point de vue réutilisation par plusieurs consommateurs, est de définir le module de l'interface du service comme étant lui-même le SPI (Service Provider Interface).

Dans ce cas, il faut :

Exemple ( code Java 9 ) :
import fr.jmdoudoux.dej.spi.service.MonService;
 
module fr.jmdoudoux.dej.spi.service {
  exports fr.jmdoudoux.dej.spi.service;
  
  uses MonService;
}

Exemple ( code Java 9 ) :
package fr.jmdoudoux.dej.spi.service;
 
import java.util.Optional;
import java.util.ServiceLoader;
 
public interface MonService {
 
  public void afficher();
        
  public static Optional<MonService> obtenirPremiereInstance() {
    return ServiceLoader.load(MonService.class).findFirst();
  }
}

Dans les modules consommateur, la directive uses n'est plus nécessaire puisque c'est le module du service lui-même et il suffit alors d'invoquer la méthode static du service.

Exemple ( code Java 9 ) :
module fr.jmdoudoux.dej.spi {
  exports fr.jmdoudoux.dej.spi;
 
  requires fr.jmdoudoux.dej.spi.service;  
}

Il suffit alors d'invoquer la méthode static du service pour obtenir l'instance.

Exemple ( code Java 9 ) :
package fr.jmdoudoux.dej.spi;
 
import fr.jmdoudoux.dej.spi.service.MonService;
 
public class Test9SPI {
  public static void main(String[] args) {
          
    MonService.obtenirPremiereInstance().ifPresent(MonService::afficher);
  }
}

 

33.5.4. La résolution de services

Depuis Java 6, l'API ServiceLoader permet d'enrichir une application en permettant de découvrir les implémentations de certaines interfaces ou classes abstraites et permettre leur chargement pour les utiliser.

Cette API est utilisée pour mettre en oeuvre les services dans les modules.

Avant Java 9, il suffisait de mettre les jars contenant les implémentations dans le classpath.

A partir de Java 9, il suffit de mettre les jars modulaires contenant les implémentations dans le module path.

 

33.5.5. Les options de jlink pour résoudre les services

Jlink est un outil fourni dans le JDK à partir de Java 9 qui permet de créer des JRE personnalisés. Un JRE personnalisé ne contient que les modules requis à l'exécution de l'application pour laquelle il a été créé.

Généralement les modules qui contiennent des implémentations d'un service sont considérés comme optionnel. Jlink ne résout pas automatiquement les modules qui contiennent une ou des implémentations d'un service.

Cependant jlink propose deux options utilisables pour nous aider à résoudre les modules contenant des implémentations de services (ceux déclarés avec l'instruction provides dans le descripteur de module) :

Il est recommandé d'utiliser l'option --suggest-providers qui affiche une liste des fournisseurs trouvées. Il est alors possible d'ajouter les modules utiles à l'application parmi ceux proposés par l'option --suggest-providers.

Dans l'exemple ci-dessous, les modules sont dans le sous-répertoire modules

Exemple ( code Java 9 ) :
C:\java\app>jlink --module-path ./modules --suggest-providers fr.jmdoudoux.dej.spi.service.
MonService
 
Suggested providers:
  fr.jmdoudoux.dej.spi.impla provides fr.jmdoudoux.dej.spi.service.MonService used by 
  fr.jmdoudoux.dej.spi.service
  fr.jmdoudoux.dej.spi.implb provides fr.jmdoudoux.dej.spi.service.MonService used by 
  fr.jmdoudoux.dej.spi.service
C:\java\app>


32. JMX (Java Management Extensions) Partie 4 : Le système de modules Imprimer Index Index avec sommaire Télécharger le PDF    
Développons en Java
v 2.40   Copyright (C) 1999-2023 .