ServiceLoader et annotations

Lors d’une formation Java 9-11 que j’animais cette semaine, on m’a demandé un exemple d’usage de ServiceLoader, de modules, et de filtrage des implémentations par annotation. Voilà un cas d’usage qui j’espère vous plaira.

Use Case

Je suis éditeur, je vends une librairie capable de réaliser différents traitements. Je la vends sous trois versions différentes :

  • une version gratuite, qui ne nécessite aucune licence, mais qui ne fournit pas tous les services
  • une version professionnelle, soumise à licence, et qui apporte toutes les fonctionnalités, mais certaines dégradées par rapport à la version entreprise,
  • et enfin une version entreprise, soumise à licence, et qui offre toutes les fonctionnalités.

Ce modèle est déjà courant chez d’autres éditeurs, je ne fais ici que m’en inspirer pour avoir un cas d’usage cohérent.

Pour des questions de distributions et de déploiement, je fournis mon logiciel sous forme de différents fichiers jars :

  • un jar général, qui comporte l’ensemble du moteur, et qui est systématiquement nécessaire
  • un jar qui contient le contrat de service qui sera implémenté
  • 3 fichiers jars, un par licence, qui comportent les fonctionnalités soumises à licence.

En fonction de la licence dont je dispose et des fichiers jars disponibles dans la JVM, au démarrage du moteur, il doit utiliser l’implémentation de plus haut niveau qui correspond à ma licence.

Ainsi, sans licence, quels que soient les jars présents, l’implémentation gratuite sera chargée ; avec une licence entreprise, si le jar gratuit et celui sous licence professionnelle sont présents, c’est l’implémentation professionnelle qui sera chargée.

Comme je n’aime pas écrire de code métier complexe, il faudra que j’ai quelque chose de simple, et d’extensible, si par hasard je souhaitais un jour changer mon modèle de licence.

Enfin, tout doit fonctionner sous Java 17, et tout doit être modulaire.

Implémentation

Je réalise un projet Maven, multi-module. Un module qui contient la définition du contrat de service, un par implémentation de service, soit 3, et un qui est le point d’entrée de ma librairie. Et depuis quelques années, je développe en TDD, donc j’appliquerai ici ce principe.

Le contrat

Le contrat de service sera définit comme une interface entièrement publique. C’est parfaitement volontaire, je ne veux pas masquer quoi que ce soit sur le contrat de service. Ici, j’aurai simplement 3 méthodes, une par niveau de licence, qui écriront dans la console ce qu’elles font, et dans quelles conditions on peut y accéder ; chacune de ces méthodes déclarera jeter une InvalidLicenseException si on tente d’y accéder avec une licence invalide, afin de bloquer les tentatives de crack. Il faudra que les implémentations jettent effectivement les exceptions.

La license doit pouvoir être transmise par mail, car je suis une petite entreprise, et que je ne veux pas avoir une grosse infrastructure pour gérer ces licences. Et elle doit être au format texte, pour conserver des choses simples, et pouvoir être facilement comprise par un être humain. Enfin, elle ne doit pas pouvoir être falsifiée trop facilement, mais la crypto n’est pas le sujet de ce billet de blog.

Je dois pouvoir identifier si une licence est valide, et elle doit comporter le nom du client, le niveau d’implémentation autorisé. Enfin, elle aura un code cryptique, histoire de participer à la sécurité. Ce code cryptique sera une empreinte des informations de la licence et d’une autre information cachée dans le code.

Licensee: Christophe Marchand
Level: Professional
Checksum: 51URaSb83g6OaJsSxTYERFhuTpw/APS2jfhRLeT409s=

Comme une licence ne variera pas – elle est immuable – j’utiliserai un record pour la modéliser.

Ensuite, je voudrais profiter de la modularité de Java pour protéger les choses qui pour moi sont réellement stratégiques ; et en particulier, pour protéger la méthode qui sait dire si une licence est valide ; en effet, je n’ai pas envie qu’on puisse injecter une licence invalide dans une implémentation entreprise, et qu’on utilise des fonctionnalités pour lesquelles on n’a pas payé la licence.

La classe LicenseValidator doit être exposée, pour que les implémentations du service puissent vérifier si la license est valide ou non. Donc cette classe sera dans un package exporté pour tous les autres modules de mon logiciel. Mais je ne veux pas que le code de validation soit directement dans un package exporté, donc la classe LicenseValidator sera un proxy sur la classe qui contient effectivement le code de validation, ShadedLicenseValidator. Et celle-ci se trouve dans un package non exporté, non ouvert.

Dans le même module, on trouvera évidemment l’interface qui portera les méthodes du service ; je l’appelle simplement Service.

Enfin, on trouvera une annotation @Implementation. Elle permettra de marquer les classes d’implémentation du service, et de porter dans l’annotation le niveau de licence exigé par cette implémentation. L’annotation ne pourra être portée que par un type, et elle doit avoir une rétention RUNTIME car c’est à l’exécution qu’on ira consulter cette annotation. Enfin, le level a une valeur par défaut à FREE, ce qui m’assure qu’il ne sera probablement jamais null.

package top.marchand.demo.java17.modules.contract;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Implementation {
  License.LicenseLevel level() default License.LicenseLevel.FREE;
}

J’ai donc un module avec deux packages, le premier exporté qui contient les éléments contractuels, la License, le contrat de service et la façade de validation de licence ; et un second package, non exporté, encore moins ouvert, qui contient le code de validation de licence. Il n’y a aucune intelligence particulière dans ce module.

Les implémentations de service

Une implémentation de service n’est qu’une classe qui implémente l’interface Service. Dans mon cas, elles s’appellent toutes ServiceImpl, mais elles sont dans des modules différents, pour pouvoir éviter de diffuser le code qui n’est pas nécessaire. Par contre, chaque méthode du service doit impérativement vérifier que la licence utilisée est valide. Pour faire simple, ici, on fera cette vérification à chaque appel de méthode, mais dans la vraie vie, on ne ferait le contrôle qu’aléatoirement, pour ne pas plomber les performances.

La classe d’implémentation doit donc avoir un attribut qui contient la licence qui est fournie pour le moteur. Mais il n’y a pas de setter dans l’interface, là encore pour tenter de limiter les tentatives de fraudes. La licence sera donc injectée par réflection, et il faudra donc ouvrir le package de l’implémentation à l’introspection, et ce seulement pour le module qui fera l’instanciation du service et qui installera la licence.

Comme je ne souhaite pas que qui que ce soit instancie ces classes, le package ne sera ouvert qu’au module chargé de l’instanciation.

Enfin, pour indiquer à quel niveau de licence est associé la classe, les classes seront annotées @Implementation, et cette annotation portera le niveau de licence.

Le code est très simple, et identique dans les trois modules free, professional et enterprise, à la valeur prêt du niveau de l’annotation et du contenu des méthodes.

package top.marchand.demo.java17.modules.free;

import top.marchand.demo.java17.modules.contract.*;

@Implementation(level = License.LicenseLevel.FREE)
public class ServiceImpl implements Service {
  private License license;

  @Override
  public void freeService() throws InvalidLicenseException {
    throwExceptionIfInvalidLicense();
    System.out.println("<FREE> freeService()");
  }

  @Override
  public void professionalService() throws InvalidLicenseException {
    throwExceptionIfInvalidLicense();
    System.out.println("<FREE> ILLEGAL professionalService()");
  };

  @Override
  public void enterpriseService() throws InvalidLicenseException {
    throwExceptionIfInvalidLicense();
    System.out.println("<FREE> ILLEGAL enterpriseService()");
  };

  private void throwExceptionIfInvalidLicense() throws InvalidLicenseException {
    if(!LicenseValidator.getInstance().isLicenseValid(license)) throw new InvalidLicenseException();
  }
}

Et le module-info.java

import top.marchand.demo.java17.modules.free.ServiceImpl;
module top.marchand.demo.java17.modules.free {
  exports top.marchand.demo.java17.modules.free to top.marchand.demo.java17.product;
  requires top.marchand.demo.java17.product.contract;
  provides top.marchand.demo.java17.modules.contract.Service with ServiceImpl;
  opens top.marchand.demo.java17.modules.free to top.marchand.demo.java17.product;
}

Le moteur, qui fournit le service

Dernier module, et non des moindres, celui qui implémente la gestion de la licence, et qui fournit le Processor, celui qui sait fournir une instance de service adaptée à la licence !

Tout d’abord, un LicenseManager, donc le rôle est de charger le fichier de licence et de fournir une instance de licence ; c’est un singleton, car je ne veux pas partager la gestion de la licence au sein de la JVM. Je n’ai pas de raison particulière d’exposer ces classes, donc elles sont dans un package qui n’est pas exporté.

Enfin, et surtout, le Processor. Il n’a pas de constructeur public, juste une factory méthode ; ainsi je contrôle entièrement les instanciations. Et il n’a qu’une seule méthode public, qui renvoie l’instance de service adaptée à la licence. Là encore, les sujets de testabilité font qu’il y a plusieurs méthodes qui sont package friend – ou package private, je n’ai jamais su quel était le bon terme – et les tests sont cette fois assez complexes, car il faut mocker pas mal de choses (Grrr… pourquoi j’ai choisi des singletons ?). La recherche du service utilise le ServiceLoader<Service>, qui renvoie toutes les implémentations disponibles, puis ensuite filtre pour ne conserver que celles qui sont valable avec la licence, et enfin trie celles qui restent et renvoie celle de plus haut niveau. Le code ici est relativement simple :

  public Service getService() throws UnparsableLicenseException {
    License license = LicenseManager.getInstance().getLicense();
    Service service = getAllImplementationsValidForLicense(license)
        .sorted(getComparator())
        .findFirst()
        .orElseThrow()
        .get();
    setLicenseInService(service, license);
    return service;
  }

Le filtrage des implémentations valables pour la licence est fait dans la méthode getAllImplementationsValidForLicense. Depuis Java 6, le JDK fournit un ServiceLoader ; son rôle est de charger les implémentations d’une interface donnée, sans que les classes d’implémentations ne soient connues à la compilation. Cela permet d’avoir des systèmes beaucoup plus souples et plus extensibles. Avec Java 9, cette fonctionnalité s’est simplifiée, elle est devenue déclarative, et ne nécessite plus de définir des fichiers texte dans META-INF/services. La méthode getAllImplementationsValidForLicense charge depuis le ServiceLoader toutes les implémentations disponibles, puis filtre, en s’appuyant sur la méthode getLicenseLevel(Class) pour trouver le niveau de licence exigé par l’implémentation.

  Stream<ServiceLoader.Provider> getAllImplementationsValidForLicense(License license) {
    return ServiceLoader.load(Service.class)
        .stream()
        .filter(serviceProvider -> license.level().compareTo(getLicenseLevel(serviceProvider.type())) >= 0);
  }

La méthode getLicenseLevel(Class<? extends Service>) ne contient que du code de réflection standard : on a la classe, on va chercher son annotation Implementation et on regarde son level.

  private License.LicenseLevel getLicenseLevel(Class<? extends Service> type) {
    Implementation annotation = type.getAnnotation(Implementation.class);
    return annotation.level();
  }

Enfin, le comparateur permettant de trier les implémentations par niveau de licence ne présente pas de difficulté, nous savons déjà extraire le niveau de la licence, et les énumérations sont directement comparables en Java.

En fait, il n’y a dans cet exercice que peu de difficulté. La seule chose un tant soit peu compliquée est l’écriture des TU pour le TDD, car il faut mocker beaucoup de choses, à cause des contraintes d’encapsulation forte que j’ai ajouté.

Conclusion

J’ai pris beaucoup de plaisir à écrire ce petit bout de code. J’ai re-découvert les services, que j’avais beaucoup utilisé sur une application avec une architecture de plugins, il y a déjà près de 15 ans, mais grace à JPMS, la déclaration des implémentations est beaucoup plus simple. Il n’y a que dans les TU que ce n’est pas trivial, mais je n’ai pas mis longtemps à trouver la solution.

Le module contract est présent pour éviter d’avoir des dépendances circulaires entre les modules. Je n’aurai pas pu m’en sortir sans ce module au milieu.

Les modules avec les règles d’exportation et d’ouverture à l’introspection apportent une réelle sécurité à notre code, tant qu’on le compile sans les informations de debug. Le byte-code est toujours aisément décompilable, et il faudrait probablement passer par un obfuscateur si on voulait vraiment rendre le code encore plus sécurisé. Mais ce n’est pas la volonté ici. Des discussions avec des éditeurs qui utilisent exactement ce modèle m’ont révélé qu’il y a des gens qui cassent leur système de licence. Mais dans les faits, c’est plus par jeu que réellement pour éviter de payer la licence. Car les apports de la licence avec les mises à jour, le support, sont indispensables pour des applications en production.

Mais il n’y a aucune raison de faciliter la vie de gens qui souhaitent cracker nos sécurités, surtout pour un coût aussi faible.

L’ensemble de ce projet est disponible sur mon GitHub. J’ai ouvert les discussions sur ce projet, n’hésitez pas à lancer une discussion si vous avez des questions, des commentaires, des blâmes ou des félicitations à faire.