Experiencia transfiriendo un proyecto Maven a Jar de lanzamiento múltiple: ya es posible, pero aún difícil

Tengo una pequeña biblioteca StreamEx que extiende la API Java 8 Stream. Tradicionalmente colecciono la biblioteca a través de Maven, y en su mayor parte estoy contento con todo. Sin embargo, quería experimentar.


Algunas cosas en la biblioteca deberían funcionar de manera diferente en diferentes versiones de Java. El ejemplo más llamativo son los nuevos métodos de Stream API como takeWhile , que apareció solo en Java 9. Mi biblioteca también proporciona una implementación de estos métodos en Java 8, pero cuando expande Stream API usted mismo, se encuentra con algunas restricciones que no mencionaré aquí. Desearía que los usuarios de Java 9+ tuvieran acceso a una implementación estándar.


Para que el proyecto continúe compilando usando Java 8, esto generalmente se hace usando herramientas de reflexión: descubrimos si hay un método correspondiente en la biblioteca estándar y, si es así, lo llamamos, y si no, usamos nuestra implementación. Sin embargo, decidí usar la API MethodHandle , porque declara menos sobrecarga de llamadas. Puede obtener MethodHandle por adelantado y guardarlo en un campo estático:


 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 } 

Y luego úsalo:


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

Todo esto está bien, pero se ve feo. Y lo más importante, en cada punto donde es posible una variación de implementaciones, debe escribir tales condiciones. Un enfoque ligeramente alternativo es separar las estrategias de Java 8 y Java 9 como una implementación de la misma interfaz. O, para guardar el tamaño de la biblioteca, simplemente implemente todo para Java 8 en una clase no final separada y sustituya un heredero por Java 9. Se hizo algo como esto:


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

Luego, en los puntos de uso, simplemente puede escribir return Internals.VER_SPEC.takeWhile(stream, predicate) . Toda la magia con manejadores de métodos ahora solo está en la clase Java9Specific . Este enfoque, por cierto, salvó la biblioteca para usuarios de Android que se habían quejado previamente de que no funcionaba en principio. La máquina virtual de Android no es Java, ni siquiera implementa la especificación Java 7. En particular, no hay métodos con una firma polimórfica como invokeExact , y la sola presencia de esta llamada en el invokeExact lo rompe todo. Ahora estas llamadas se colocan en una clase que nunca se inicializa.


Sin embargo, todo esto sigue siendo feo. Una solución hermosa (al menos en teoría) es usar el Multi Release Jar, que viene con Java 9 ( JEP-238 ). Para hacer esto, parte de las clases deben compilarse en Java 9 y los archivos de clase compilados se colocan en META-INF/versions/9 dentro del archivo Jar. Además, debe agregar la línea Multi-Release: true al manifiesto. Entonces Java 8 ignorará con éxito todo esto, y Java 9 y versiones posteriores cargarán nuevas clases en lugar de clases con los mismos nombres que se encuentran en el lugar habitual.


La primera vez que intenté hacer esto hace más de dos años, poco antes del lanzamiento de Java 9. Fue muy difícil y lo dejé. Incluso fue difícil hacer que el compilador del proyecto compilara desde Java 9: ​​muchos complementos de Maven simplemente se rompieron debido a las API internas cambiadas java.version cadena java.version cambiado u otra cosa.


Un nuevo intento este año fue más exitoso. En su mayor parte, los complementos se han actualizado y funcionan en el nuevo Java de manera bastante adecuada. El primer paso traduje todo el ensamblado a Java 11. Para esto, además de actualizar las versiones del complemento, tuve que hacer lo siguiente:


  • Cambie los enlaces de <a name="..."> de la forma <a name="..."> a <a id="..."> . De lo contrario, JavaDoc se queja.
  • Especifique additionalOptions = --no-module-directories en maven-javadoc-plugin. Sin esto, hubo errores extraños con las funciones de búsqueda de JavaDoc: los directorios con módulos todavía no se crearon, pero al cambiar al resultado de búsqueda, /undefined/ agregó a la ruta (hola, JavaScript). Esta característica en Java 8 no era para nada, por lo que mi actividad ya ha tenido un buen resultado: JavaDoc se ha convertido en una búsqueda.
  • Arregle el complemento para publicar los resultados de la cobertura de prueba en Overol ( coveralls-maven-plugin ). Por alguna razón, se abandona, lo cual es extraño, dado que Overol vive solo y ofrece servicios comerciales. El jaxb-api que usa el complemento ha desaparecido de Java 11. Afortunadamente, no es difícil solucionar el problema usando las herramientas de Maven: es suficiente registrar explícitamente la dependencia del complemento:
     <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> 

El siguiente paso fue la adaptación de las pruebas. Dado que el comportamiento de la biblioteca es obviamente diferente en Java 8 y Java 9, sería lógico ejecutar pruebas para ambas versiones. Ahora estamos ejecutando todo en Java 11, por lo que no se prueba el código específico de Java 8. Este es un código bastante grande y no trivial. Para solucionar esto, hice una pluma artificial:


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

Ahora solo pase -Done.util.streamex.emulateJava8=true cuando ejecute las pruebas,
para probar lo que generalmente funciona en Java 8. Ahora agregue un nuevo bloque <execution> a la configuración de maven-surefire-plugin con argLine = -Done.util.streamex.emulateJava8=true , y las pruebas pasan dos veces.


Sin embargo, me gustaría considerar las pruebas de cobertura total. Uso JaCoCo, y si no le dices nada, la segunda ejecución simplemente sobrescribirá los resultados de la primera. ¿Cómo funciona JaCoCo? Primero ejecuta el destino del prepare-agent , que establece la propiedad argLine Maven, firmando algo como -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 . Quiero que se formen dos archivos exec diferentes. Puedes hackearlo de esta manera. Agregue destFile=${project.build.directory} configuración del prepare-agent . Áspero pero efectivo. Ahora argLine terminará en blah-blah/myproject/target . Sí, este no es un archivo, sino un directorio. Pero ya podemos sustituir el nombre del archivo al comienzo de las pruebas. Regresamos al maven-surefire-plugin y establecemos argLine = @{argLine}/jacoco_java8.exec -Done.util.streamex.emulateJava8=true para la ejecución de Java 8 y argLine = @{argLine}/jacoco_java11.exec para la ejecución de Java 11. Entonces es fácil combinar estos dos archivos usando el objetivo de merge , que también proporciona el complemento JaCoCo, y obtenemos la cobertura general.


Actualización: Godin en los comentarios dijo que era innecesario, puede escribir en un archivo y pegará automáticamente el resultado con el anterior. Ahora no estoy seguro de por qué este escenario no funcionó para mí inicialmente.


Bueno, estamos bien preparados para cambiar al Jar de lanzamiento múltiple. Encontré una serie de recomendaciones sobre cómo hacer esto. El primero sugirió el uso de un proyecto Maven multimodular. No quiero: esta es una gran complicación de la estructura del proyecto: hay cinco pom.xml, por ejemplo. Perder el tiempo por un par de archivos que deben compilarse en Java 9 parece ser demasiado. Otro sugirió comenzar la compilación a través de maven-antrun-plugin . Aquí decidí mirar solo como último recurso. Está claro que cualquier problema en Maven se puede resolver usando Ant, pero esto es bastante torpe. Finalmente, vi una recomendación para usar un complemento de terceros de lanzamiento múltiple, jar-maven-plugin . Ya sonaba delicioso y correcto.


El complemento recomienda colocar códigos fuente específicos para nuevas versiones de Java en directorios como src/main/java-mr/9 , lo cual hice. Todavía decidí evitar al máximo las colisiones en los nombres de clase, por lo que la única clase (incluso la interfaz) que está presente tanto en Java 8 como en Java 9, tengo esto:


 // 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(); } 

La vieja constante se mudó a un nuevo lugar, pero nada más cambió realmente. Solo que ahora la clase Java9Specific vuelto mucho más simple: todas las sentadillas con MethodHandle se han reemplazado con éxito por llamadas a métodos directos.


El complemento promete hacer lo siguiente:


  • Reemplace el maven-compiler-plugin estándar maven-compiler-plugin y compile en dos pasos con una versión de destino diferente.
  • Reemplace el maven-jar-plugin estándar maven-jar-plugin y maven-jar-plugin resultado de maven-jar-plugin compilación con las rutas correctas.
  • Agregue la línea Multi-Release: true to MANIFEST.MF .

Para que funcione, tomó bastantes pasos.


  1. Cambie el embalaje de jar a jar multi-release-jar .


  2. Añadir build-extension:


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

  3. Copie la configuración de maven-compiler-plugin . Solo tenía la versión predeterminada en el espíritu de <source>1.8</source> y <arg>-Xlint:all</arg>


  4. Pensé que el maven-compiler-plugin ahora se puede eliminar, pero resultó que el nuevo plugin no reemplaza la compilación de pruebas, por lo que la versión de Java se restableció a la predeterminada (1.5!) Y el -Xlint:all argumentos desaparecieron. Entonces tuve que irme.


  5. Para no duplicar el origen y el destino de los dos complementos, descubrí que ambos respetan las propiedades de maven.compiler.source y maven.compiler.target . Los instalé y eliminé las versiones de la configuración del complemento. Sin embargo, de repente resultó que maven-javadoc-plugin usa la source de la configuración de maven-compiler-plugin para encontrar la URL del JavaDoc estándar, que debe vincularse cuando se hace referencia a métodos estándar. Y ahora no respeta maven.compiler.source . Por lo tanto, tuve que devolver <source>${maven.compiler.source}</source> a la configuración de maven-compiler-plugin . Afortunadamente, no se necesitaron otros cambios para generar JavaDoc. Muy bien se puede generar a partir de fuentes Java 8, porque todo el carrusel con versiones no afecta a la API de la biblioteca.


  6. El maven-bundle-plugin , lo que convirtió mi biblioteca en un artefacto OSGi. Simplemente se negó a trabajar con packaging = multi-release-jar . En principio, nunca me gustó. Escribe un conjunto de líneas adicionales en el manifiesto, al mismo tiempo estropea el orden de clasificación y agrega más basura. Afortunadamente, resultó que no es difícil deshacerse de él escribiendo todo lo que necesita a mano. Solo, por supuesto, no en maven-jar-plugin , sino en el nuevo. La configuración completa del complemento multi-release-jar eventualmente se volvió así (definí algunas propiedades como 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. Pruebas Ya no tenemos one.util.streamex.emulateJava8 , pero puede lograr el mismo efecto modificando las pruebas de class-path. Ahora lo contrario es cierto: por defecto, la biblioteca funciona en modo Java 8, y para Java 9 necesita escribir:


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

    Un punto importante: las classes-9 deben adelantarse a los archivos de clase ordinarios, por lo que tuvimos que transferir los habituales a additionalClasspathElements , que se agregan después.


  8. Fuentes Voy a tener source-jar, y sería bueno incluir fuentes de Java 9 en él para que, por ejemplo, el depurador en el IDE pueda mostrarlas correctamente. No estoy muy preocupado por duplicar VerSpec , porque hay una línea, que se ejecuta solo durante la inicialización. Está bien que deje solo la versión de Java 8. Sin embargo, sería bueno que Java9Specific.java adjunte. Esto se puede hacer agregando manualmente un directorio fuente adicional:


     <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> 

    Después de recolectar el artefacto, lo conecté a un proyecto de prueba y lo verifiqué en el depurador IntelliJ IDEA. Todo funciona a la perfección: dependiendo de la versión de la máquina virtual utilizada para ejecutar el proyecto de prueba, terminamos en una fuente diferente al depurar.


    Sería genial que esto lo hiciera el complemento jar de liberación múltiple, así que hice una sugerencia.


  9. JaCoCo. Resultó ser lo más difícil con él, y no podría prescindir de la ayuda externa. El hecho es que el complemento exec-files perfectamente generado para Java-8 y Java-9, normalmente los pegó en un solo archivo, sin embargo, al generar informes en XML y HTML, ignoraron obstinadamente las fuentes de Java-9. Hurgando en la fuente , vi que solo genera un informe para los archivos de clase encontrados en project.getBuild().getOutputDirectory() . Este directorio, por supuesto, puede ser reemplazado, pero de hecho tengo dos de ellos: classes y classes-9 . Teóricamente, puede copiar todas las clases en un directorio, cambiar el outputDirectory salida e iniciar JaCoCo, y luego cambiar el outputDirectory nuevo para no romper el ensamblaje JAR. Pero eso suena completamente feo. En general, decidí posponer la solución a este problema en mi proyecto por ahora, pero les escribí a los chicos de JaCoCo que sería bueno poder especificar varios directorios con archivos de clase.


    Para mi sorpresa, solo unas horas después, uno de los desarrolladores de JaCoCo godin vino a mi proyecto y trajo una solicitud de extracción , que resuelve el problema. ¿Cómo se decide? Usando Ant, por supuesto! Resultó que el complemento Ant para JaCoCo es más avanzado y puede generar un informe resumido para varios directorios de origen y archivos de clase. Ni siquiera necesitaba un paso de merge separado, porque podía alimentar varios archivos ejecutivos a la vez. En general, Ant no se puede evitar, que así sea. Lo principal que funcionó, y pom.xml creció solo seis líneas.



    Incluso tuiteé en corazones:




Así que obtuve un proyecto muy funcional que construye un hermoso Jar Multi-Release. Al mismo tiempo, el porcentaje de cobertura incluso aumentó, porque catch (NoSuchMethodException | IllegalAccessException e) todo tipo de catch (NoSuchMethodException | IllegalAccessException e) que eran inalcanzables en Java 9. Desafortunadamente, esta estructura del proyecto no es compatible con IntelliJ IDEA, por lo que tuve que abandonar la importación de POM y configurar el proyecto en el IDE manualmente . Espero que en el futuro todavía haya una solución estándar que sea compatible automáticamente con todos los complementos y herramientas.

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


All Articles