Codes de déploiement, services

Un "produit" applicatif doit être adaptable à des contraintes locales sur site: il est donc de bonne pratique de mettre en place des mécanismes de paramétrisation des applications.

Le programmeur de déploiement est en charge de l’adaptation du produit à un client ou un site particulier. Son travail doit être anticipé, préparé et les principes des opérations d’adaptation doivent être documentés. Le programmeur applicatif doit définir des scripts et des outils de tests spécifique à cette phase.

Cette ouverture à des codes ultérieurs concerne toutes les formes d’adaptation d’une application. On utilise ici la notion de service : l’application définit des principes de réalisation (par ex. des interfaces) et des codes qui implantent ces services sont attachés a posteriori à l’application.

Dans tous les cas il va nous falloir distinguer ici les codes qui s’appliquent dans le cadre de modules et ceux qui sortent de ce cadre.

images/bluebelt.png

Dans le cadre d’une architecture modulaire il faut bien se souvenir des éléments suivants:

Codes de déploiement: chargement explicite

Comme certains comportements de détail de l’application sont fixés a posteriori , ceci peut être fait au travers de codes de déploiement.

Ces codes peuvent changer le comportement par défaut de l’application En général il s’agit de classes qui héritent d’une classe abstraite ou qui implantent un service abstrait défini par une interface.

Codes hors modules

Exemple: supposons que la classe Produit utilise les services d’un StockManager . StockManager est une interface largement utilisée dans le code applicatif mais c’est au code de déploiement d’en fixer le comportement effectif (par ex. via une base de données).

Dans le code applicatif le nom de la classe effective peut être récupéré d’une manière ou d’une autre (voir chapitre suivant) puis:

     Class clazz ;
        try {
        clazz =  Class.forName(nomClasseQuiSertDeStockManager);
         } catch (ClassNotFoundException ex) {
      // RAPPORT et action
        }

Ce code va permettre au ClassLoader de charger la classe correspondante….

Les initialisations statiques du code vont être exécutées à ce moment là. (On peut donc écrire un testeur de déploiement en appelant forName sur chaque classe qui pourrait avoir du code statique de déploiement présentant des risques -ressource absente par ex-.)

Attention: c’est le nom canonique de la classe qu’il faut fournir à forName (et bien sûr la classe doit être trouvée par le ClassLoader ).

Maintenant on peut créer dynamiquement une instance correspondante de la classe (par ex. si elle dispose d’un constructeur sans argument)

   // pour
   try {
      StockManager manager = (StockManager) clazz.getConstructor().newInstance() ;
   } catch (Exception exc) {
      //....
   }

Ici il est possible d’invoquer getDeclaredConstructor et de passer des arguments à cet appel de constructeur.

clazz.getDeclaredConstructor(String.class).newInstance("shadok");

Codes avec modules

Ici il va falloir:

  • soit que le module qui abrite la classe demandée dynamiquement fasse l’objet d’une directive requires de la part du module demandeur
  • soit que le module de la classe demandée soit passé à l’option --add-modules de la commande java d’exécution.

Dans le cas où on cherche à invoquer le constructeur il faut que le module qui abrite la classe fasse un exports du package. Une autre option est d’avoir une directive opens qui ouvre le code à l’introspection (voir la leçon correspondantes)

Déploiement standard de services

Il y a des manières standard de déployer des services (c’est à dire des codes qui rendent un service décrit par une interface applicative et dont la réalisation est laissée "ouverte" à des codes divers qui enrichiront ultérieurement l’application).

Comme nous l’avons dit il faut distinguer:

  • La définition d’un service : en général une interface ou une classe abstraite.
  • Les codes qui implantent le service (en général situés dans des jars de déploiement) Ces codes sont en général des classes qui implantent l’interface de service (ou héritent de la classe de définition du service). Une autre possibilité est offerte: celle d’un fournisseur qui dispose d’une méthode provider. Voir la note ci-après.
  • Les codes qui recherchent les implantations d’un service (il peut y avoir plusieurs implantations disponibles)

Le code applicatif qui charge le service va utiliser java.util.ServiceLoader:

   StockManager vraiManager ;
   ServiceLoader<StockManager> loader = ServiceLoader.load(StockManager.class) ;
   // utiliser un boucle foreach s'il y a plusieurs codes d'implémentation
   Iterator<StockManager> it = loader.iterator() ;
   // ce code peut déclencher une ServiceConfigurationError !
   if(it.hasNext()) {
      vraiManager   = it.next() ;
   }

voir la documentation de ServiceLoader

L’objet maintenant obtenu peut être utilisé selon son "contrat d’interface".

[Note]Une autre option pour fournir un service

Il est possible d’avoir une classe qui implante un service sans toutefois implanter l’interface demandée (ou hériter de la class abstraite qui constitue le service).

Dans ce cas la classe doit avoir une méthode static qui rende un objet conforme au service et qui soit de nom provider() (sans paramètres).

Un intérêt majeur de cette approche est qu’on peut alors faire en sorte que cette méthode rende un singleton. En effet chaque invocation de ServiceLoader.load provoque la création d’un nouvel objet implantant le service. Si ce service dispose d’un état on risque de ne pas pouvoir gérer cet état au travers des diverses instances qui ont été créées. Dans ce cas rendre un objet unique peut s’avérer une bonne solution.

[Note]Positionnement du code de recherche de service

Le code de recherche d’un service peut être situé directement dans une méthode statique de l’interface qui définit le service. Dans ce cas la demande de recherche d’un service sera exécutée chaque fois qu’on invoque la méthode statique de recherche (et donc une instance différente sera rendue!).

Comment le ServiceLoader trouve-t’il les codes qui implantent le service?

Ici tout dépend de l’organisation de déploiement. Historiquement les services étaient "découverts" dans le Class-Path (et peuvent toujours l'être si on l’utilise) l’architecture en modules propose des dispositions différentes.

Déploiement de services en portée locale

Par "portée locale" nous entendons:

  • les codes hors-modules (module sans nom, modules automatiques)
  • les codes à l’intérieur du module courant

Un fournisseur de service est identifié en plaçant un fichier de configuration dans le répertoire META_INF/services.

  • C’est un fichier texte (attention : au format UTF8!) qui porte le nom canonique de l’interface java qui décrit le service. Par exemple le nom du fichier pourrait être com.monbiz.services.StockManager.
  • Chaque ligne du fichier doit donner le nom canonique d’un classe qui implante ce service (dans certains cas on peut en avoir plusieurs!).

Exemple de rédaction:

# classes pour com.monbiz.services.StockManager
com.monbiz.utils.db.DBStockManager

Maintenant le code applicatif qui charge le service va utiliser java.util.ServiceLoader: (voir code précédent de recherche de service)

L’objet maintenant obtenu peut être utilisé selon son "contrat d’interface".

Un exemple classique de ce dispositif était celui du déploiement des drivers JDBC:

  • le package java.sql définit l’interface java.sql.Driver (et ensuite toutes les interfaces dérivées qui permettent un accès aux bases de données relationnelles).
  • il suffisait de déployer dans le CLASSPATH de l’application un jar contenant un tel Driver pour que celui-ci soit pris en compte. Tous ces jars avaient leur propre META-INF/services/java.sql.Driver et les codes correspondants sont ainsi automatiquement pris en compte (dans ce cas il peut y avoir plusieurs Drivers d’accès à des bases de données différentes)

Déploiement de service avec des modules

Ici les descriptifs module-info sont mis à contribution pour gérer l’architecture des services.

Un module qui rend un service doit: - utiliser la description du service (l’interface qui la définit) - déclarer qu’il rend le service (il est recommandé de ne pas "exporter" le package qui contient cette implantation)

Donc a priori on doit avoir quelque chose comme:

module  com.monbiz.utils.db {
   // le module qui "exporte" l'interface
   requires com.monbiz.services ;
   // déclare l'implantation
   provides com.monbiz.services.StockManager
      with com.monbiz.utils.db.DBStockManager ;
   // il peut y avoir plusieurs codes qui fournissent
   // donc "with classe1, classe2, classe3 ;
}

Un module qui consomme un service doit: - connaître la description du service - déclarer qu’il est consommateur de ce service

Exemple:

module  com.monbiz.produits {
   // mon travail habituel
   exports com.monbiz.produits ;
   // je connais les services
   requires com.monbiz.services ;
   // et je suis demandeur d'un service particulier
   uses com.monbiz.services.StockManager
}

On peut avoir de nombreuses variations architecturales.

Variantes architecturales

  • Il est possible d’avoir un module qui exporte une définition de service et qui consomme les réalisations:

    module  com.monbiz.produits {
       exports com.monbiz.produits ;
       // définit les services
       exports com.monbiz.services ;
       // et je suis demandeur
       uses com.monbiz.services.StockManager
    }
  • Il est possible d’avoir des exportations ciblées (à destination exclusive de modules connu du module exportateur).

    module  com.monbiz.services {
       // exportation ciblée
       exports com.monbiz.services to com.monbiz.produits,
              com.monbiz.utils.db, com.monbiz.utils.files ;
    }

    Avantage: le service est connu d’un cercle restreint. Inconvénient: on restreint a priori les modules qui vont rendre le service (pas très conforme à l’idée d’une architecture "ouverte")

    Cas (rare on l’espère) d’interdépendance entre modules (rappel: les vrais cycles de dépendances entre modules sont interdits!):

    module  com.monbiz.produits {
       exports com.monbiz.produits ;
       // définit les services
       exports com.monbiz.services to
              com.monbiz.utils.db, com.monbiz.utils.files ;
       // et je suis demandeur
       uses com.monbiz.services.StockManager
    }

    Ici com.monbiz.utils.db va faire un requires de com.biz.produits! (inter-dépendance explicite!)

  • Il peut exister des cas où le code exploitant des services connait a priori les codes requis et a même des parties de codes spécifiques à ces services. On pourrait ainsi avoir un code qui requiert un service de base de données et, en fonction de la base qui sera choisie à l’exécution, exploite quelques codes particuliers à la base choisie.

    Le problème ici est qu’on aura des codes qui seront nécessairement présent au compile_time mais pas nécessairement présent au run_time (puisque, par exemple, une seule base sera déployée et activée).

    Dans ce cas les directives requires peuvent prendre une forme différente:

    module com.monbiz.produits {
       // diverses directives
       ...
       // ici les modules sont obligatoires à la compilation
       // et optionnels à l'exécution
       requires static com.bigdb ;
       requires static org.commondb ;
    }

--exercices––

Scripts enchassés

On ne peut pas toujours configurer une application par des données (en utilisant un fichier de configuration properties ou xml).

D’un autre coté on ne peut pas toujours avoir un programmeur Java sous la main pour écrire du code de déploiement:

    // code complexe de déploiement
    // règle de gestion programmatique: taxation des voitures anciennes
    @Override
    public double getTauxTaxe() {
        if (année > 1970) {
            return super.getTauxTaxe();
        }
        if (année < 1920) {
            return 1;
        }
        double tauxNormal = super.getTauxTaxe() - 1;
        //
        double ratio = (1970 - this.année) * 0.02;
        return 1 + (tauxNormal * ratio);
    }

Pour réaliser une configuration programmatique une personne moyennement compétente peut écrire un code simple dans un langage de script.

Dans ce cas Java fournit un moyen de communiquer avec des langages de script "enchassés". Voir package javax.script dans le module java.scripting.

Une autre option est d’utiliser Jshell depuis un code java: voir le package jdk.shell dans le module du même nom.