Pourquoi votre classe Java ne voit pas une autre : le classloading des bundles OSGi dans AEM
Pourquoi une classe qui compile sans problème avec Maven peut quand même lever une ClassNotFoundException dans AEM, comment Import-Package/Export-Package contrôlent la visibilité entre bundles, et comment déboguer cela avec la console web Felix.
Dans une application Java classique, dès qu’un jar est sur le classpath,
n’importe quelle classe de ce jar peut voir n’importe quelle autre classe
du classpath. AEM ne fonctionne pas ainsi. AEM tourne sur Apache Felix, un
framework OSGi, et la couche de modules d’OSGi donne à chaque bundle
déployé (un jar OSGi) son propre classloader. Deux bundles qui
cohabitent dans /system/console/bundles, même construits dans le même
reactor Maven, ne peuvent pas voir les classes l’un de l’autre à moins que
les métadonnées du bundle ne le déclarent explicitement. C’est la cause la
plus fréquente du fameux “ça compile, ça se déploie, puis ça lève une
ClassNotFoundException” dans AEM.
Pourquoi le classpath Maven n’est pas le classpath d’exécution
Quand vous lancez mvn package sur un projet AEM, Maven résout tout
l’arbre des dépendances transitives et le place sur un seul classpath pour
la compilation et (si vous en avez) l’exécution des tests unitaires. Ce
classpath n’a aucune notion de bundles, d’imports ou d’exports : il est
plat.
Le runtime d’AEM est un monde différent. Felix installe chaque bundle avec
son propre classloader et ne relie les bundles entre eux qu’à travers les
paquets qu’ils exportent et importent explicitement, tels que
déclarés dans le manifest du bundle (META-INF/MANIFEST.MF). Une classe
du bundle-a est invisible pour bundle-b sauf si :
bundle-aexporte le paquet dans lequel vit cette classe (Export-Package), etbundle-bimporte ce même paquet (Import-Package), ou embarque la classe directement dans son propre jar.
C’est vrai même si les deux bundles proviennent du même module Maven, du même build de reactor, ou de la même bibliothèque “partagée” de votre équipe : la visibilité Maven et la visibilité OSGi sont deux mécanismes indépendants qui se ressemblent en surface, mais n’ont rien à voir entre eux.
Import-Package et Export-Package : le vrai classpath dans AEM
Chaque bundle OSGi porte des en-têtes de manifest qui décrivent son contrat de dépendances :
Export-Package: com.mysite.core.util;version="1.0.0"
Import-Package: org.apache.commons.lang3;version="[3.0,4)",*
Export-Package signifie “les autres bundles peuvent utiliser ces paquets
qui m’appartiennent.” Import-Package signifie “j’ai besoin de ces
paquets, fournis par n’importe quel bundle qui les exporte, dans cette
plage de versions.” Quand Felix démarre un bundle, il essaie de câbler
(wire) chaque entrée Import-Package avec un Export-Package compatible
provenant d’un autre bundle actif. S’il n’en trouve aucun, le bundle reste
à l’état Installed au lieu de passer à Active — ou, pour les imports
optionnels ou dynamiques, il s’active sans problème et échoue seulement au
moment précis où le code touche le paquet manquant.
Les projets AEM actuels (l’aem-project-archetype a abandonné le
maven-bundle-plugin il y a un moment) génèrent ces en-têtes avec le
bnd-maven-plugin, construit sur le même outil bnd qu’utilise Apache
Sling lui-même. Contrairement à l’ancien Maven Bundle Plugin, le
bnd-maven-plugin n’exporte pas tous les paquets par défaut : il faut
le déclarer explicitement, généralement en annotant un paquet avec
@org.osgi.annotation.bundle.Export dans package-info.java :
// core/src/main/java/com/mysite/core/util/package-info.java
@org.osgi.annotation.bundle.Export
package com.mysite.core.util;
ou avec une instruction bnd explicite Export-Package dans le pom.xml
du module :
<plugin>
<groupId>biz.aQute.bnd</groupId>
<artifactId>bnd-maven-plugin</artifactId>
</plugin>
Import-Package est généralement laissé à l’analyse de bytecode de bnd
lui-même : il scanne les classes compilées pour repérer tous les paquets
que votre code référence réellement et génère la liste d’imports
automatiquement, en résolvant les plages de versions à partir des
dépendances déclarées dans le POM. C’est pratique, mais c’est exactement
pour cela qu’un export manquant ailleurs dans votre projet se manifeste
comme une surprise à l’exécution plutôt que comme une erreur de build :
bnd ne peut importer que ce qu’un bundle est prêt à exporter.
Pourquoi ça compile parfaitement et casse quand même à l’exécution
Maven se soucie uniquement de savoir si une classe est accessible sur le classpath de compilation. Il n’a aucune idée de si cette classe sera visible au-delà d’une frontière de bundle une fois déployée. D’où cette séquence très courante :
- Vous ajoutez une méthode utilitaire à un module
corepartagé. - Vous l’appelez depuis un servlet, un Sling Model, ou un scheduled job du même reactor Maven.
mvn installréussit. Les tests unitaires, qui tournent sur le classpath JVM classique sans OSGi, passent.- Vous déployez sur AEM. Le bundle s’installe et s’active même, parce que la référence au paquet peut être résolue paresseusement.
- Le chemin de code qui appelle la méthode utilitaire s’exécute pour la
première fois — dans une requête, une étape de workflow, ou un
scheduled job — et AEM lève une
NoClassDefFoundErrorou uneClassNotFoundExceptionpour une classe qui, selon tous les signaux Maven, “existe”.
L’écart se situe dans le câblage Export-Package/Import-Package, pas
dans la compilation. Les tests unitaires ne le détectent pas car ils ne
tournent jamais à l’intérieur de Felix ; ils tournent sur un classpath
plat où les règles de visibilité OSGi ne s’appliquent tout simplement pas.
Déboguer cela avec la console web Felix
La console web Felix est l’outil principal pour diagnostiquer ce problème
dans AEM, que ce soit en local sur author
(http://localhost:4502/system/console) ou sur un environnement de
développement Cloud Service.
/system/console/bundles liste tous les bundles installés avec leur
état. Un bundle bloqué à Installed (au lieu de Active) signifie
généralement que Felix n’a pas pu satisfaire l’une de ses entrées
Import-Package. En cliquant sur un bundle précis, vous voyez son
manifest complet, y compris les sections Imported Packages et
Exported Packages : les imports résolus indiquent quel bundle les
fournit ; ceux qui ne sont pas résolus apparaissent comme insatisfaits, ce
qui vous indique immédiatement qu’aucun bundle actif du framework
n’exporte actuellement ce paquet dans une version compatible.
/system/console/depfinder prend un nom de classe ou de paquet
pleinement qualifié et vous indique quel bundle (ou quelle dépendance
Maven, si elle n’est présente nulle part) le fournit. C’est le moyen le
plus rapide de répondre à “cette classe est-elle disponible quelque part
dans cette instance AEM, et sous quelle version de paquet ?” avant de
partir traquer une NoClassDefFoundError dans votre propre code.
Entre les deux : utilisez bundles pour confirmer le câblage
import/export de votre propre bundle, et depfinder pour trouver d’où une
classe manquante devrait réellement provenir.
Où cela se manifeste dans de vrais projets AEM
- Un module utilitaire “common” ou “core” partagé. Un développeur ajoute une classe à un module partagé, oublie d’exporter le paquet dans lequel elle vit (ou le comportement par défaut de bnd de ne rien exporter l’exclut silencieusement), et tout bundle consommateur qui n’est pas dans le même build de reactor échoue à l’exécution, même si le build du reactor lui-même compile sans problème.
- Une dépendance transitive qui n’est pas elle-même un bundle OSGi.
Beaucoup de bibliothèques Java ordinaires n’ont jamais été conçues avec
OSGi en tête et n’ont pas leur propre en-tête
Export-Package. Si votre bundle a besoin d’une de leurs classes, vous devez soit l’embarquer (instructions de type-includeresource/Embed-Dependencydebnd), soit la fournir comme un bundle installé séparément : l’avoir seulement comme<dependency>Maven ne suffit pas, et c’est exactement le cas d’usage pour lequel/system/console/depfindera été conçu. - Des tests unitaires qui donnent une fausse confiance. Comme les tests tournent en dehors de Felix, un service peut avoir 100% de tests au vert et échouer malgré tout dès qu’il est déployé, si la classe dont il dépend n’est pas réellement câblée au niveau OSGi. Considérez des tests unitaires au vert comme la preuve que la logique est correcte, pas comme la preuve que le bundle va se résoudre.