Expérience de transfert d'un projet Maven vers Multi-Release Jar: déjà possible, mais toujours difficile

J'ai une petite bibliothèque StreamEx qui étend l'API Java 8 Stream. Je collectionne traditionnellement la bibliothèque via Maven, et pour la plupart je suis satisfait de tout. Cependant, je voulais expérimenter.


Certaines choses dans la bibliothèque devraient fonctionner différemment dans différentes versions de Java. L'exemple le plus frappant est les nouvelles méthodes de l'API Stream comme takeWhile , qui n'apparaissaient qu'en Java 9. Ma bibliothèque fournit également une implémentation de ces méthodes dans Java 8, mais lorsque vous développez vous-même l'API Stream, vous êtes soumis à certaines restrictions que je ne mentionnerai pas ici. Je souhaite que les utilisateurs de Java 9+ aient accès à une implémentation standard.


Pour que le projet continue de compiler en utilisant Java 8, cela se fait généralement à l'aide d'outils de réflexion: nous découvrons s'il existe une méthode correspondante dans la bibliothèque standard et, si c'est le cas, nous l'appelons, et sinon, nous utilisons notre implémentation. Cependant, j'ai décidé d'utiliser l' API MethodHandle , car elle déclare moins de surcharge d'appel. Vous pouvez obtenir MethodHandle à l'avance et l'enregistrer dans un champ statique:


 MethodHandles.Lookup lookup = MethodHandles.publicLookup(); MethodType type = MethodType.methodType(Stream.class, Predicate.class); MethodHandle method = null; try { method = lookup.findVirtual(Stream.class, "takeWhile", type); } catch (NoSuchMethodException | IllegalAccessException e) { // ignore } 

Et puis utilisez-le:


 if (method != null) { return (Stream<T>)method.invokeExact(stream, predicate); } else { // Java 8 polyfill } 

C'est bien, mais ça a l'air moche. Et surtout, à chaque point où une variation des implémentations est possible, vous devez écrire de telles conditions. Une approche légèrement alternative consiste à séparer les stratégies de Java 8 et Java 9 en tant qu'implémentation de la même interface. Ou, pour économiser la taille de la bibliothèque, implémentez simplement tout pour Java 8 dans une classe non finale distincte et remplacez un héritier pour Java 9. Il a été fait quelque chose comme ceci:


 //    Internals static final VersionSpecific VER_SPEC = System.getProperty("java.version", "").compareTo("1.9") > 0 ? new Java9Specific() : new VersionSpecific(); 

Ensuite, aux points d'utilisation, vous pouvez simplement écrire return Internals.VER_SPEC.takeWhile(stream, predicate) . Toute la magie avec les Java9Specific méthode est désormais uniquement dans la classe Java9Specific . Cette approche, en passant, a sauvé la bibliothèque pour les utilisateurs d'Android qui s'étaient auparavant plaints qu'elle ne fonctionnait pas en principe. La machine virtuelle Android n'est pas Java, elle invokeExact même pas la spécification Java 7. En particulier, il n'y a pas de méthodes avec une signature polymorphe comme invokeExact , et la présence même de cet appel dans le bytecode casse tout. Maintenant, ces appels sont placés dans une classe qui n'est jamais initialisée.


Cependant, tout cela est toujours moche. Une belle solution (au moins en théorie) consiste à utiliser le Multi Release Jar, fourni avec Java 9 ( JEP-238 ). Pour ce faire, une partie des classes doit être compilée sous Java 9 et les fichiers de classe compilés placés dans META-INF/versions/9 à l'intérieur du fichier Jar. De plus, vous devez ajouter la ligne Multi-Release: true au manifeste. Ensuite, Java 8 ignorera tout cela avec succès, et Java 9 et les versions plus récentes chargeront de nouvelles classes au lieu de classes portant les mêmes noms qui se trouvent à l'emplacement habituel.


La première fois que j'ai essayé de le faire il y a plus de deux ans, peu de temps avant la sortie de Java 9. Ça a été très dur, et j'ai arrêté. Il était même difficile de faire compiler le projet par le compilateur à partir de Java 9: ​​de nombreux plugins Maven se sont simplement cassés à cause des API internes modifiées java.version chaîne java.version modifié java.version ou autre chose.


Une nouvelle tentative cette année a été plus réussie. Les plugins ont pour la plupart été mis à jour et fonctionnent assez bien dans le nouveau Java. La première étape, j'ai traduit l'assembly entier en Java 11. Pour cela, en plus de mettre à jour les versions du plugin, j'ai dû faire ce qui suit:


  • Changement dans les liens JavaDoc package-info.java du formulaire <a name="..."> en <a id="..."> . Sinon, JavaDoc se plaint.
  • Spécifiez additionalOptions = --no-module-directories dans maven-javadoc-plugin. Sans cela, il y avait des bugs étranges avec les fonctionnalités de recherche JavaDoc: les répertoires avec les modules n'étaient pas créés de toute façon, mais lors du passage au résultat de la recherche, /undefined/ ajouté au chemin (bonjour, JavaScript). Cette fonctionnalité dans Java 8 n'était pas du tout, donc mon activité a déjà apporté un joli résultat: JavaDoc est devenu une recherche.
  • Correction du plugin pour la publication des résultats de couverture de test dans Coveralls ( coveralls-maven-plugin ). Pour une raison quelconque, il est abandonné, ce qui est étrange, étant donné que Coveralls vit seul et offre des services commerciaux. Le jaxb-api utilisé par le plugin a disparu de Java 11. Heureusement, il n'est pas difficile de résoudre le problème à l'aide des outils Maven: il suffit d'enregistrer explicitement la dépendance au plugin:
     <plugin> <groupId>org.eluder.coveralls</groupId> <artifactId>coveralls-maven-plugin</artifactId> <version>4.3.0</version> <dependencies> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.2.3</version> </dependency> </dependencies> </plugin> 

L'étape suivante a été l'adaptation des tests. Étant donné que le comportement de la bibliothèque est évidemment différent dans Java 8 et Java 9, il serait logique d'exécuter des tests pour les deux versions. Maintenant, nous exécutons tout sous Java 11, donc le code spécifique à Java 8 n'est pas testé. Il s'agit d'un code assez volumineux et non trivial. Pour résoudre ce problème, j'ai créé un stylo artificiel:


 static final VersionSpecific VER_SPEC = System.getProperty("java.version", "").compareTo("1.9") > 0 && !Boolean.getBoolean("one.util.streamex.emulateJava8") ? new Java9Specific() : new VersionSpecific(); 

Maintenant, passez simplement -Done.util.streamex.emulateJava8=true lors de l'exécution des tests,
pour tester ce qui fonctionne généralement en Java 8. Ajoutez maintenant un nouveau bloc <execution> à la configuration de argLine = -Done.util.streamex.emulateJava8=true maven-surefire-plugin avec argLine = -Done.util.streamex.emulateJava8=true , et les tests passent deux fois.


Cependant, je voudrais considérer les tests de couverture totale. J'utilise JaCoCo, et si vous ne lui dites rien, alors la deuxième manche écrasera simplement les résultats de la première. Comment fonctionne JaCoCo? Il exécute d'abord la cible prepare-agent , qui définit la propriété argLine Maven, y signant quelque chose comme -javaagent:blah-blah/.m2/org/jacoco/org.jacoco.agent/0.8.4/org.jacoco.agent-0.8.4-runtime.jar=destfile=blah-blah/myproject/target/jacoco.exec . Je veux que deux fichiers exécutables différents soient formés. Vous pouvez le pirater de cette façon. Ajoutez destFile=${project.build.directory} configuration prepare-agent . Rude mais efficace. argLine se terminera maintenant par blah-blah/myproject/target . Oui, ce n'est pas du tout un fichier, mais un répertoire. Mais nous pouvons remplacer le nom du fichier déjà au début des tests. Nous revenons au maven-surefire-plugin et définissons argLine = @{argLine}/jacoco_java8.exec -Done.util.streamex.emulateJava8=true pour l'exécution Java 8 et argLine = @{argLine}/jacoco_java11.exec pour l'exécution Java 11. Ensuite, il est facile de combiner ces deux fichiers à l'aide de la cible de merge , que le plug-in JaCoCo fournit également, et nous obtenons la couverture globale.


Mise à jour: godin dans les commentaires a dit que c'était inutile, vous pouvez écrire dans un fichier, et il collera automatiquement le résultat avec le précédent. Maintenant, je ne sais pas pourquoi ce scénario n'a pas fonctionné pour moi au départ.


Eh bien, nous sommes bien préparés pour passer au pot multi-versions. J'ai trouvé un certain nombre de recommandations sur la façon de procéder. Le premier a suggéré l'utilisation d'un projet Maven multi-modulaire. Je n'en ai pas envie: c'est une grande complication de la structure du projet: il y a cinq pom.xml par exemple. S'amuser pour le plaisir de quelques fichiers qui doivent être compilés en Java 9 semble être trop. Un autre a suggéré de commencer la compilation via maven-antrun-plugin . Ici, j'ai décidé de ne regarder qu'en dernier recours. Il est clair que tout problème dans Maven peut être résolu en utilisant Ant, mais c'est en quelque sorte assez maladroit. Enfin, j'ai vu une recommandation d'utiliser un plugin tiers multi-release-jar-maven-plugin . Cela avait déjà l'air délicieux et juste.


Le plugin recommande de placer les codes source spécifiques aux nouvelles versions de Java dans des répertoires comme src/main/java-mr/9 , ce que j'ai fait. J'ai toujours décidé d'éviter les collisions dans les noms de classe au maximum, donc la seule classe (même l'interface) qui est présente à la fois dans Java 8 et Java 9, j'ai ceci:


 // Java 8 package one.util.streamex; /* package */ interface VerSpec { VersionSpecific VER_SPEC = new VersionSpecific(); } // Java 9 package one.util.streamex; /* package */ interface VerSpec { VersionSpecific VER_SPEC = new Java9Specific(); } 

L'ancienne constante a déménagé dans un nouvel endroit, mais rien d'autre n'a vraiment changé. Ce n'est que maintenant que la classe Java9Specific devenue beaucoup plus simple: tous les squats avec MethodHandle ont été remplacés avec succès par des appels de méthode directs.


Le plugin promet de faire les choses suivantes:


  • Remplacez le maven-compiler-plugin standard et compilez en deux étapes avec une version cible différente.
  • Remplacez le maven-jar-plugin standard et maven-jar-plugin résultat de maven-jar-plugin compilation avec les bons chemins.
  • Ajoutez la ligne Multi-Release: true à MANIFEST.MF .

Pour que cela fonctionne, il a fallu pas mal d'étapes.


  1. Changez l'emballage du jar en jar à multi-release-jar .


  2. Ajouter une extension de build:


     <build> <extensions> <extension> <groupId>pw.krejci</groupId> <artifactId>multi-release-jar-maven-plugin</artifactId> <version>0.1.5</version> </extension> </extensions> </build> 

  3. Copiez la configuration de maven-compiler-plugin . Je n'avais que la version par défaut dans l'esprit de <source>1.8</source> et <arg>-Xlint:all</arg>


  4. Je pensais que maven-compiler-plugin peut maintenant être supprimé, mais il s'est avéré que le nouveau plugin ne remplace pas la compilation de tests, donc pour cela la version Java a été réinitialisée à sa valeur par défaut (1.5!) Et l'argument -Xlint:all disparu. J'ai donc dû partir.


  5. Afin de ne pas dupliquer la source et la cible des deux plugins, j'ai découvert qu'ils respectent tous les deux les propriétés de maven.compiler.source et maven.compiler.target . Je les ai installés et supprimé les versions des paramètres du plugin. Cependant, il s'est avéré soudainement que maven-javadoc-plugin utilise la source des paramètres maven-compiler-plugin pour trouver l'URL du JavaDoc standard, qui doit être lié lors du référencement des méthodes standard. Et maintenant, il ne respecte pas maven.compiler.source . Par conséquent, j'ai dû retourner <source>${maven.compiler.source}</source> aux paramètres de maven-compiler-plugin . Heureusement, aucune autre modification n'a été nécessaire pour générer JavaDoc. Il peut très bien être généré à partir de sources Java 8, car l'intégralité du carrousel avec les versions n'affecte pas l'API de la bibliothèque.


  6. Le maven-bundle-plugin , ce qui a transformé ma bibliothèque en un artefact OSGi. Il a simplement refusé de travailler avec packaging = multi-release-jar . En principe, je ne l'ai jamais aimé. Il écrit un ensemble de lignes supplémentaires dans le manifeste, gâchant à la fois l'ordre de tri et ajoutant plus de déchets. Heureusement, il s'est avéré qu'il n'est pas difficile de s'en débarrasser en écrivant tout ce dont vous avez besoin à la main. Seulement, bien sûr, pas dans maven-jar-plugin , mais dans le nouveau. La configuration complète du plugin multi-release-jar est finalement devenue comme ça (j'ai défini moi-même certaines propriétés comme project.package ):


     <plugin> <groupId>pw.krejci</groupId> <artifactId>multi-release-jar-maven-plugin</artifactId> <version>0.1.5</version> <configuration> <compilerArgs><arg>-Xlint:all</arg></compilerArgs> <archive> <manifestEntries> <Automatic-Module-Name>${project.package}</Automatic-Module-Name> <Bundle-Name>${project.name}</Bundle-Name> <Bundle-Description>${project.description}</Bundle-Description> <Bundle-License>${license.url}</Bundle-License> <Bundle-ManifestVersion>2</Bundle-ManifestVersion> <Bundle-SymbolicName>${project.package}</Bundle-SymbolicName> <Bundle-Version>${project.version}</Bundle-Version> <Export-Package>${project.package};version="${project.version}"</Export-Package> </manifestEntries> </archive> </configuration> </plugin> 

  7. Tests. Nous n'avons plus one.util.streamex.emulateJava8 , mais vous pouvez obtenir le même effet en modifiant les tests de chemin de classe. Maintenant, le contraire est vrai: par défaut, la bibliothèque fonctionne en mode Java 8, et pour Java 9, vous devez écrire:


     <classesDirectory>${basedir}/target/classes-9</classesDirectory> <additionalClasspathElements>${project.build.outputDirectory}</additionalClasspathElements> <argLine>@{argLine}/jacoco_java9.exec</argLine> 

    Un point important: les classes-9 devraient précéder les fichiers de classe ordinaires, nous avons donc dû transférer les fichiers habituels vers additionalClasspathElements , qui sont ajoutés après.


  8. Sources. Je vais avoir un fichier source-jar, et ce serait bien de mettre des sources Java 9 dedans pour que, par exemple, le débogueur dans l'EDI puisse les afficher correctement. Je ne m'inquiète pas beaucoup de dupliquer VerSpec , car il n'y a qu'une seule ligne, qui n'est exécutée que lors de l'initialisation. Je Java9Specific.java laisser uniquement l'option de Java 8. Cependant, ce serait bien de Java9Specific.java . Cela peut être fait en ajoutant manuellement un répertoire source supplémentaire:


     <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> <version>3.0.0</version> <executions> <execution> <phase>test</phase> <goals><goal>add-source</goal></goals> <configuration> <sou​rces> <sou​rce>src/main/java-mr/9</sou​rce> </sou​rces> </configuration> </execution> </executions> </plugin> 

    Après avoir collecté l'artefact, je l'ai connecté à un projet de test et l'ai vérifié dans le débogueur IntelliJ IDEA. Tout fonctionne à merveille: selon la version de la machine virtuelle utilisée pour exécuter le projet de test, nous nous retrouvons dans une source différente lors du débogage.


    Ce serait cool de faire cela par le plugin multi-release-jar lui-même, alors j'ai fait une telle suggestion.


  9. JaCoCo. Cela s'est avéré être le plus difficile avec lui et je ne pouvais pas me passer d'aide extérieure. Le fait est que le plug-in a parfaitement généré des fichiers exécutables pour Java-8 et Java-9, les a généralement collés dans un seul fichier, cependant, lors de la génération de rapports en XML et HTML, ils ont obstinément ignoré les sources de Java-9. En fouillant dans la source , j'ai vu qu'il ne génère qu'un rapport pour les fichiers de classe trouvés dans project.getBuild().getOutputDirectory() . Ce répertoire, bien sûr, peut être remplacé, mais en fait j'en ai deux: classes et classes-9 . Théoriquement, vous pouvez copier toutes les classes dans un répertoire, modifier le répertoire de outputDirectory et démarrer JaCoCo, puis modifier le outputDirectory de outputDirectory afin de ne pas casser l'assembly JAR. Mais cela semble complètement moche. En général, j'ai décidé de reporter la solution à ce problème dans mon projet pour l'instant, mais j'ai écrit aux gars de JaCoCo que ce serait bien de pouvoir spécifier plusieurs répertoires avec des fichiers de classe.


    À ma grande surprise, quelques heures plus tard, l'un des développeurs de JaCoCo godin est venu à mon projet et a présenté une pull-request , ce qui résout le problème. Comment décide-t-il? Utiliser Ant, bien sûr! Il s'est avéré que le plugin Ant pour JaCoCo est plus avancé et peut générer un rapport de synthèse pour plusieurs répertoires source et fichiers de classe. Il n'avait même pas besoin d'une étape de merge distincte, car il pouvait alimenter plusieurs fichiers exécutifs à la fois. En général, Ant ne pouvait pas être évitée, qu'il en soit ainsi. La principale chose qui a fonctionné, et pom.xml n'a augmenté que de six lignes.



    J'ai même tweeté dans les cœurs:




J'ai donc eu un projet très fonctionnel qui construit un beau pot multi-versions. Dans le même temps, le pourcentage de couverture a même augmenté, car j'ai supprimé toutes sortes de catch (NoSuchMethodException | IllegalAccessException e) qui n'étaient pas accessibles en Java 9. Malheureusement, cette structure de projet n'est pas prise en charge par IntelliJ IDEA, j'ai donc dû abandonner l'importation POM et configurer manuellement le projet dans l'IDE. . J'espère qu'à l'avenir il y aura toujours une solution standard qui sera automatiquement prise en charge par tous les plugins et outils.

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


All Articles