Plug-in Java sans douleur

Dans cet article, je voudrais vous expliquer comment créer rapidement et facilement un framework d'application Java avec prise en charge du chargement dynamique de plugins. Le lecteur pensera probablement immédiatement qu'une telle tâche a été résolue depuis longtemps, et vous pouvez simplement utiliser des frameworks prêts à l'emploi ou écrire votre chargeur de classe, mais rien de tout cela ne sera requis dans la solution que je propose:

  • Nous n'avons pas besoin de bibliothèques ou de frameworks spéciaux ( OSGi , Guice, etc.)
  • Nous n'utiliserons pas l'analyse de bytecode avec ASM et des bibliothèques similaires.
  • Nous n'écrirons pas notre chargeur de classe.
  • Nous n'utiliserons pas de réflexion et d'annotations.
  • Pas besoin de s'occuper du chemin de classe pour trouver des plugins. Nous ne toucherons pas du tout au chemin de classe.
  • De plus, nous n'utiliserons pas XML, YAML ou tout autre langage déclaratif pour décrire les points d'extension (points d'extension dans les plugins).

Cependant, il y a encore une exigence - une telle solution ne fonctionnera que sur Java 9 ou supérieur. Parce qu'il sera basé sur des modules et des services .

Commençons donc. Nous formulons le problème plus spécifiquement:
Vous devez implémenter un cadre d'application minimal qui, au démarrage, chargera les plugins utilisateur à partir du dossier plugins .

Autrement dit, l'application assemblée devrait ressembler à ceci:

 plugin-app/ plugins/ plugin1.jar plugin2.jar ... core.jar … 

Commençons par le module de core . Ce module est au cœur de notre application, c'est-à-dire en fait notre framework.

Pour ceux qui apprécient le temps, le projet terminé est disponible sur GitHub. Instructions de montage.
Lien

 git clone https://github.com/orionll/plugin-app cd plugin-app mvn verify cd core/target java --module-path core-1.0-SNAPSHOT.jar --module core 

Créez les 4 fichiers Java suivants dans le module:

 core/ src/main/java/ org/example/pluginapp/core/ IService.java BasicService.java Main.java module-info.java 

Le premier fichier, IService.java est le fichier qui décrit notre point d'extension. D'autres plugins pourront alors contribuer à ce point d'expansion («contribution»). Il s'agit du principe standard de création d'applications plug-in, appelé principe d'inversion de dépendance (Dependency Inversion). Ce principe est basé sur le fait que le noyau ne dépend pas de classes spécifiques, mais d'interfaces.

J'ai donné au point d'extension le nom abstrait IService , car je démontre maintenant un concept exclusivement. En réalité, il peut s'agir de n'importe quel point d'extension spécifique, par exemple, si vous écrivez un éditeur graphique, cela peut être l'effet du traitement d'image, par exemple IEffectProvider , IEffectContribution ou autre chose, selon la façon dont vous préférez nommer les points d'extension. Dans le même temps, l'application elle-même contiendra un ensemble d'effets de base, et les développeurs tiers pourront écrire des effets supplémentaires plus sophistiqués et les fournir sous forme de plug-ins. L'utilisateur n'a qu'à placer ces effets dans le dossier des plugins et redémarrer l'application.

Le fichier IService.java est le suivant:

 public interface IService { void doJob(); static List<IService> getServices(ModuleLayer layer) { return ServiceLoader .load(layer, IService.class) .stream() .map(Provider::get) .collect(Collectors.toList()); } } 

Ainsi, IService n'est qu'une interface qui fait un travail abstrait doJob() (je le répète, les détails ne sont pas importants, en réalité ce sera quelque chose de concret).

getServices() également attention à la deuxième méthode getServices() . Cette méthode renvoie toutes les implémentations de l'interface IService trouvées dans cette couche de module et ses parents. Nous en parlerons plus en détail plus tard.

Le deuxième fichier, BasicService.java , est l'implémentation de base de l'interface IService . Il sera toujours présent, même s'il n'y a pas de plugins dans l'application. En d'autres termes, le core n'est pas seulement le noyau, mais aussi en même temps un plugin pour lui-même, qui sera toujours chargé. Le fichier BasicService.java ressemble à ceci:

 public class BasicService implements IService { @Override public void doJob() { System.out.println("Basic service"); } } 

Pour plus de simplicité, doJob() simplement la chaîne "Basic service" et c'est tout.

Ainsi, pour le moment, nous avons l'image suivante:



Le troisième fichier, Main.java , est l'endroit où la méthode main() est implémentée. Il y a un peu de magie dedans, pour comprendre ce dont vous avez besoin pour savoir ce qu'est une couche de module.

À propos des couches de modules


Lorsque Java lance l'application, tous les modules de la plate-forme + modules répertoriés dans l'argument --module-path (et également le --module-path , le cas échéant) tombent dans la couche dite de Boot . Dans notre cas, si nous compilons le module core.jar et java --module-path core.jar --module core partir de la ligne de commande, alors au moins les modules java.base et core seront dans la couche Boot :



La couche de Boot est toujours présente dans n'importe quelle application Java, et c'est la configuration la plus petite possible. La plupart des applications existent dans une seule couche de modules. Cependant, dans notre cas, nous voulons faire un chargement dynamique des plugins depuis le dossier plugins . Nous pourrions simplement forcer l'utilisateur à corriger la ligne de lancement de l'application afin qu'il ajoute lui-même les plugins nécessaires à --module-path , mais ce ne sera pas la meilleure solution. Surtout les gens qui ne sont pas programmeurs et ne comprennent pas pourquoi ils doivent grimper quelque part et réparer quelque chose pour une chose aussi simple.

Heureusement, il existe une solution: Java vous permet de créer vos propres couches de modules lors de l'exécution, ce qui chargera les modules de l'endroit dont nous avons besoin. Pour nos besoins, une nouvelle couche pour les plugins sera suffisante, qui aura une couche de Boot tant que parent (toute couche doit avoir un parent):



Le fait que la couche plug-in ait la couche Boot comme parent signifie que les modules de la couche plug-in peuvent référencer les modules de la couche Boot , mais pas l'inverse.

Donc, sachant maintenant ce qu'est une couche de module, vous pouvez enfin regarder le contenu du fichier Main.java :

 public final class Main { public static void main(String[] args) { Path pluginsDir = Paths.get("plugins"); //      plugins ModuleFinder pluginsFinder = ModuleFinder.of(pluginsDir); //  ModuleFinder      plugins       List<String> plugins = pluginsFinder .findAll() .stream() .map(ModuleReference::descriptor) .map(ModuleDescriptor::name) .collect(Collectors.toList()); //  ,      (   ) Configuration pluginsConfiguration = ModuleLayer .boot() .configuration() .resolve(pluginsFinder, ModuleFinder.of(), plugins); //      ModuleLayer layer = ModuleLayer .boot() .defineModulesWithOneLoader(pluginsConfiguration, ClassLoader.getSystemClassLoader()); //     IService       Boot List<IService> services = IService.getServices(layer); for (IService service : services) { service.doJob(); } } } 

Si c'est la première fois que vous regardez ce code, cela peut sembler très compliqué, mais c'est une fausse sensation en raison du grand nombre de nouvelles classes inconnues. Si vous comprenez un peu la signification des classes ModuleFinder , Configuration et ModuleLayer , alors tout se met en place. Et puis, il n'y a que quelques dizaines de lignes! C'est toute la logique qui est écrite une fois.

Descripteur de module


Il y a un autre (quatrième) fichier que nous n'avons pas considéré: module-info.java . Il s'agit du fichier le plus court contenant la déclaration de notre module et une description des services (points d'extension):

 module core { exports org.example.pluginapp.core; uses IService; provides IService with BasicService; } 

La signification des lignes de ce fichier doit être évidente:

  • Premièrement, le module exporte le package org.example.pluginapp.core que les plugins puissent hériter de l'interface IService (sinon IService ne serait pas accessible en dehors du module core ).
  • Deuxièmement, il annonce qu'il utilise l' IService .
  • Troisièmement, il dit qu'il fournit une implémentation du service IService via la classe BasicService .

La déclaration du module étant écrite en Java, nous bénéficions d'avantages très importants: vérifications du compilateur et garanties statiques . Par exemple, si nous avions fait une erreur dans le nom des types ou indiqué un paquet inexistant, nous l'aurions obtenu tout de suite. Dans le cas de certains OSGi, nous n'aurions aucun contrôle au moment de la compilation, car la déclaration des points d'extension serait écrite en XML.

Ainsi, le cadre est prêt. Essayons de l'exécuter:

 > java --module-path core.jar --module core Basic service 

Qu'est-il arrivé?

  1. Java a essayé de trouver les modules dans le dossier des plugins et n'en a trouvé aucun.
  2. Un calque vide a été créé.
  3. ServiceLoader a commencé à rechercher toutes les implémentations d' IService .
  4. Dans la couche vide, il n'a trouvé aucune implémentation de service, car il n'y a pas de modules.
  5. Après cette couche, il a poursuivi sa recherche dans la couche parent (c'est-à-dire la couche de Boot ) et a trouvé une implémentation de BasicService dans le module core .
  6. Toutes les implémentations trouvées avaient la méthode doJob() appelée. Puisqu'une seule implémentation a été trouvée, seul le "Basic service" été imprimé.

Écrire un plugin


Après avoir écrit le cœur de notre application, il est temps d'essayer d'écrire des plugins pour cela. plugin1 deux plugins plugin1 et plugin2 : laissez le premier imprimer "Service 1" , le second - "Service 2" . Pour ce faire, vous devez fournir deux implémentations plugin1 plugin2 respectivement dans plugin1 et plugin2 :



Créez le premier plugin avec deux fichiers:

 plugin1/ src/main/java/ org/example/pluginapp/plugin1/ Service1.java module-info.java 

Fichier Service1.java :

 public class Service1 implements IService { @Override public void doJob() { System.out.println("Service 1"); } } 

Fichier module-info.java :

 module plugin1 { requires core; provides IService with Service1; } 

Notez que plugin1 dépend du core . C'est le principe d'inversion de dépendance que j'ai mentionné plus tôt: le noyau ne dépend pas des plugins, mais vice versa.

Le deuxième plugin est complètement similaire au premier, donc je ne le donnerai pas ici.

Maintenant, collectons les plugins, mettons-les dans le dossier des plugins et exécutons l'application:

 > java --module-path core.jar --module core Service 1 Service 2 Basic service 

Hourra, les plugins ont été récupérés! Comment est-ce arrivé:

  1. Java a trouvé deux modules dans le dossier plugins .
  2. Une couche a été créée avec deux modules plugins1 et plugins2 .
  3. ServiceLoader a commencé à rechercher toutes les implémentations d' IService .
  4. Dans la couche plugin, il a trouvé deux implémentations du service IService .
  5. Après cela, il a continué à rechercher dans la couche parent (c'est-à-dire la couche de Boot ) et a trouvé une implémentation de BasicService dans le module core .
  6. Toutes les implémentations trouvées avaient la méthode doJob() appelée.

Notez que c'est précisément parce que la recherche de fournisseurs de services commence par les couches enfants, puis passe aux couches parentes, puis "Service 1" et "Service 2" imprimés en premier, puis "Basic Service" . Si vous souhaitez que les services soient triés de manière à ce que les services de base passent en premier, puis les plugins, vous pouvez modifier la méthode IService.getServices() en y ajoutant le tri (vous devrez peut-être ajouter la int getOrdering() à l'interface IService ).

Résumé


J'ai donc montré comment organiser rapidement et efficacement une application plug-in Java qui possède les propriétés suivantes:

  • Simplicité: pour les points d'extension et leurs liaisons, seules les fonctionnalités Java de base (interfaces, classes et ServiceLoader ) sont utilisées, sans frameworks, réflexion, annotations et chargeurs de classe.
  • Déclarabilité: les points d'extension sont décrits dans les descripteurs de module. Regardez simplement module-info.java et comprenez quels points d'extension existent et quels plugins contribuent à ces points.
  • Garanties statiques: en cas d'erreur dans les descripteurs de module, le programme ne compilera pas. De plus, en bonus, si vous utilisez IntelliJ IDEA, vous recevrez des avertissements supplémentaires (par exemple, si vous avez oublié d'utiliser uses et d'utiliser ServiceLoader.load() )
  • Sécurité: le système Java modulaire vérifie au démarrage que la configuration des modules est correcte et refuse d'exécuter le programme en cas d'erreur.

Je le répète, je n'ai montré que l'idée. Dans une véritable application de plug-in, il y aurait des dizaines à des centaines de modules et des centaines à des milliers de points d'extension.

J'ai décidé de soulever ce sujet car depuis 7 ans, j'écris une application modulaire utilisant Eclipse RCP, dans laquelle le fameux OSGi est utilisé comme système de plug-in et les descripteurs de plug-in sont écrits en XML. Nous avons plus d'une centaine de plugins et nous sommes toujours assis sur Java 8. Mais même si nous passons à une nouvelle version de Java, il est peu probable que nous utilisions des modules Java, car ils sont fortement liés à OSGi.

Mais si vous écrivez une application plug-in à partir de zéro, les modules Java sont l'une des options possibles pour sa mise en œuvre. N'oubliez pas que les modules ne sont qu'un outil et non un objectif.

En bref sur moi


Je programme depuis plus de 10 ans (dont 8 en Java), je réponds à StackOverflow et dirige mon propre canal en Telegram dédié à Java.

Source: https://habr.com/ru/post/fr479478/


All Articles