De un traductor: estamos trabajando activamente para traducir la plataforma en rieles de Java 11 y estamos pensando en cómo desarrollar de manera eficiente las bibliotecas de Java (como YARG ), teniendo en cuenta las características de Java 8/11 para que no tenga que crear ramas y lanzamientos por separado. Una posible solución es un JAR de lanzamiento múltiple, pero no todo es sencillo.
Java 9 incluye una nueva opción de tiempo de ejecución de Java llamada JAR de lanzamiento múltiple. Esta es quizás una de las innovaciones más controvertidas en la plataforma. TL; DR: consideramos que esta es una solución torcida para un problema grave . En esta publicación explicaremos por qué creemos que sí, y también le diremos cómo construir tal JAR si realmente lo desea.
Los JAR de lanzamiento múltiple , o JAR MR, son una nueva característica de la plataforma Java, introducida en JDK 9. Aquí describiremos en detalle los riesgos significativos asociados con el uso de esta tecnología y cómo crear JAR de lanzamiento múltiple usando Gradle, si aún quieres
De hecho, un JAR de lanzamiento múltiple es un archivo Java que incluye varias variantes de la misma clase para trabajar con diferentes versiones del tiempo de ejecución. Por ejemplo, si está trabajando en JDK 8, el entorno Java usará la versión de clase para JDK 8 y si en Java 9 se usa la versión para Java 9. De manera similar, si la versión se creó para una versión futura de Java 10, el tiempo de ejecución usa esta versión en lugar de la versión para Java 9 o la versión predeterminada (Java 8).
Debajo del gato, entendemos el dispositivo del nuevo formato JAR y descubrimos si esto es todo.
Cuándo usar JAR de lanzamiento múltiple
- Tiempo de ejecución optimizado. Esta es una solución al problema que enfrentan muchos desarrolladores: al desarrollar una aplicación, no se sabe en qué entorno se ejecutará. Sin embargo, para algunas versiones del tiempo de ejecución, puede incrustar versiones genéricas de la misma clase. Suponga que desea mostrar el número de versión de Java en el que se ejecuta la aplicación. Para Java 9, puede usar el método Runtime.getVersion. Sin embargo, este es un nuevo método disponible solo en Java 9+. Si necesita otros tiempos de ejecución, digamos Java 8, debe analizar la propiedad java.version. Como resultado, tendrá 2 implementaciones diferentes de una función.
API en conflicto: la resolución de conflictos entre API también es un problema común. Por ejemplo, debe admitir dos tiempos de ejecución, pero en uno de ellos la API está en desuso. Hay 2 soluciones comunes a este problema:
- El primero es la reflexión. Por ejemplo, puede especificar la interfaz VersionProvider, luego 2 clases específicas Java8VersionProvider y Java9VersionProvider, y cargar la clase correspondiente en tiempo de ejecución (¡es curioso que para elegir entre dos clases tenga que analizar el número de versión!). Una de las opciones para esta solución es crear una sola clase con varios métodos que se llaman usar la reflexión.
- Una solución más avanzada es utilizar métodos de control cuando sea posible. Lo más probable es que la reflexión te parezca inhibitoria e incómoda y, en general, tal como es.
Alternativas conocidas a los JAR de lanzamiento múltiple
La segunda forma, más simple y más comprensible, es crear 2 archivos diferentes para diferentes tiempos de ejecución. En teoría, crea dos implementaciones de la misma clase en el IDE, y la tarea del sistema de compilación es compilarlas, probarlas y empacarlas correctamente en 2 artefactos diferentes. Este es un enfoque que se ha utilizado en Guava o Spock durante muchos años. Pero también es necesario para idiomas como Scala. Y todo porque hay tantas opciones para el compilador y el tiempo de ejecución que la compatibilidad binaria se vuelve casi inalcanzable.
Pero hay muchas otras razones para usar archivos JAR separados:
- JAR es solo una forma de empacar.
es un artefacto de ensamblaje que incluye clases, pero eso no es todo: los recursos, como regla, también se incluyen en el archivo. El empaque, como el manejo de recursos, tiene un precio. El equipo de Gradle tiene como objetivo mejorar la calidad de construcción y reducir el tiempo de espera para que el desarrollador compile resultados, pruebas y el proceso de construcción en general. Si el archivo aparece en el proceso demasiado pronto, se crea un punto de sincronización innecesario. Por ejemplo, para compilar clases dependientes de API, lo único que se necesita son archivos .class. No se necesitan archivos jar ni recursos en jar. Del mismo modo, solo se necesitan archivos y recursos de calificaciones para ejecutar las pruebas de Gradle. Para las pruebas, no es necesario crear un jar. Solo será necesario para un usuario externo (es decir, al publicar). Pero si la creación de un artefacto se vuelve obligatoria, algunas tareas no se pueden ejecutar en paralelo y se inhibe todo el proceso de ensamblaje. Si para proyectos pequeños esto no es crítico, para proyectos corporativos a gran escala este es el principal factor de desaceleración.
- Es mucho más importante que, al ser un artefacto, un archivo jar no pueda transportar información sobre dependencias.
Es poco probable que las dependencias de cada clase en el tiempo de ejecución de Java 9 y Java 8 sean las mismas. Sí, en nuestro ejemplo simple será, pero para proyectos más grandes esto no es cierto: generalmente el usuario importa el backport de la biblioteca para la funcionalidad Java 9 y lo usa para implementar la versión de la clase Java 8. Sin embargo, si empaqueta ambas versiones en un archivo, en un artefacto habrá elementos con diferentes árboles de dependencia. Esto significa que si está trabajando con Java 9, tiene dependencias que nunca serán necesarias. Además, contamina el classpath, creando conflictos probables para los usuarios de la biblioteca.
Y finalmente, en un proyecto puede crear JAR para diferentes propósitos:
- para API
- para java 8
- para java 9
- con enlace nativo
- etc.
El uso incorrecto del clasificador de dependencias genera conflictos relacionados con el uso compartido del mismo mecanismo. Por lo general, las fuentes o javadocs se instalan como clasificadores, pero en realidad no tienen dependencias.
- No queremos generar inconsistencias; el proceso de construcción no debe depender de cómo se obtienen las clases. En otras palabras, el uso de jarras de lanzamiento múltiple tiene un efecto secundario: llamar desde el archivo JAR y llamar desde el directorio de la clase ahora son cosas completamente diferentes. ¡Tienen una gran diferencia en semántica!
- Dependiendo de la herramienta que use para crear el JAR, ¡puede terminar con archivos JAR incompatibles! La única herramienta que garantiza que cuando empaquete dos opciones de clase en un archivo, tendrán una única API abierta: la propia utilidad jar . Lo cual, no sin razón, no necesariamente involucra herramientas de ensamblaje o incluso usuarios. JAR es esencialmente un "sobre" que se asemeja a un archivo ZIP. Entonces, dependiendo de cómo lo recolecte, obtendrá diferentes comportamientos, o tal vez recolectará un artefacto incorrecto (y no lo notará).
Formas más eficientes de administrar archivos JAR individuales
La razón principal por la que los desarrolladores no usan archivos separados es que son inconvenientes para recopilar y usar. Las herramientas de compilación tienen la culpa, que, antes de Gradle, no se las arregló para nada. En particular, aquellos que usaron este método en Maven solo podían confiar en la función de clasificador débil para publicar artefactos adicionales. Sin embargo, el clasificador no ayuda en esta difícil situación. Se utilizan para diversos fines, desde la publicación de fuentes, documentación, javadocs, hasta la implementación de opciones de biblioteca (guava-jdk5, guava-jdk7, ...) o varios casos de uso (api, fat jar, ...). En la práctica, no hay forma de mostrar que el árbol de dependencia del clasificador es diferente del árbol de dependencia del proyecto principal. En otras palabras, el formato POM está fundamentalmente roto porque Representa cómo se ensambla el componente y los artefactos que suministra. Supongamos que necesita implementar 2 archivos jar diferentes: jar clásico y gordo, que incluye todas las dependencias. ¡Maven decide que 2 artefactos tienen árboles de dependencia idénticos, incluso si esto obviamente es incorrecto! En este caso, esto es más que obvio, ¡pero la situación es la misma que con los JAR de lanzamiento múltiple!
La solución es manejar las opciones correctamente. Gradle puede hacer esto administrando dependencias basadas en opciones. Esta característica está actualmente disponible para desarrollo en Android, ¡pero también estamos trabajando en su versión para Java y aplicaciones nativas!
La gestión de dependencias basada en variantes se basa en el hecho de que los módulos y los artefactos son cosas completamente diferentes. El mismo código puede funcionar perfectamente en diferentes tiempos de ejecución, teniendo en cuenta diferentes requisitos. Para aquellos que trabajan con compilación nativa, esto ha sido obvio durante mucho tiempo: compilamos para i386 y amd64 y de ninguna manera podemos interferir con las dependencias de la biblioteca i386 con arm64 . En el contexto de Java, esto significa que para Java 8 necesita crear una versión del archivo JAR "java 8", donde el formato de clase corresponderá a Java 8. Este artefacto contendrá metadatos con información sobre qué dependencias usar. Para Java 8 o 9, se seleccionarán las dependencias correspondientes a la versión. Es así de simple (de hecho, la razón no es que el tiempo de ejecución sea solo un campo de opciones, puede combinar varias).
Por supuesto, nadie había hecho esto antes debido a una complejidad excesiva: Maven, aparentemente, nunca permitiría que se realizara una operación tan complicada. Pero con Gradle es posible. El equipo de Gradle está trabajando actualmente en un nuevo formato de metadatos que les dice a los usuarios qué opción usar. En pocas palabras, una herramienta de compilación debe manejar la compilación, prueba, empaque y procesamiento de dichos módulos. Por ejemplo, el proyecto debería funcionar en los tiempos de ejecución de Java 8 y Java 9. Idealmente, debe implementar 2 versiones de la biblioteca. Esto significa que hay 2 compiladores diferentes (para evitar el uso de la API de Java 9 cuando se trabaja en Java 8), 2 directorios de clase y, en última instancia, 2 archivos JAR diferentes. Y también, muy probablemente, será necesario probar 2 tiempos de ejecución. O implementa 2 archivos, pero decide probar el comportamiento de la versión de Java 8 en el tiempo de ejecución de Java 9 (¡porque esto puede suceder al inicio!).
Este esquema aún no se ha implementado, pero el equipo de Gradle ha hecho un progreso significativo en esta dirección.
Cómo crear JAR de lanzamiento múltiple usando Gradle
Pero si esta función aún no está lista, ¿qué debo hacer? Relájese, los artefactos correctos se crean de la misma manera. Antes de que aparezca la función anterior en el ecosistema de Java, hay dos opciones:
- buena manera antigua usando la reflexión o diferentes archivos JAR;
- use JAR de lanzamiento múltiple (tenga en cuenta que esta puede ser una mala solución, incluso si hay buenos casos de uso).
Lo que elija, diferentes archivos o archivos JAR de lanzamiento múltiple, el esquema será el mismo. Los JAR de lanzamiento múltiple son esencialmente un paquete incorrecto: deberían ser una opción, pero no el objetivo. Técnicamente, el diseño de origen es el mismo para JAR individuales y externos. Este repositorio describe cómo crear un JAR de lanzamiento múltiple usando Gradle. La esencia del proceso se describe brevemente a continuación.
En primer lugar, siempre debe recordar un mal hábito de los desarrolladores: ejecutan Gradle (o Maven) utilizando la misma versión de Java en la que se planea lanzar los artefactos. Además, a veces se usa una versión posterior para iniciar Gradle, y la compilación se produce con un nivel de API anterior. Pero no hay una razón particular para hacerlo. En Gradle, la compilación de Ross es posible. Le permite describir la posición del JDK, así como ejecutar la compilación como un proceso separado, para compilar el componente utilizando este JDK. La mejor manera de configurar varios JDK es configurar la ruta al JDK a través de variables de entorno, como se hace en este archivo . Entonces solo necesita configurar Gradle para usar el JDK correcto, basado en la compatibilidad con la plataforma fuente / destino . Vale la pena señalar que, comenzando con JDK 9, las versiones anteriores de JDK no son necesarias para la compilación cruzada. Esto hace una nueva característica, -release. Gradle usa esta función y configurará el compilador según sea necesario.
Otro punto clave es el conjunto de fuentes de designación. El conjunto de origen es un conjunto de archivos de origen que deben compilarse juntos. Un JAR se obtiene compilando uno o más conjuntos de origen. Para cada conjunto, Gradle crea automáticamente una tarea de compilación personalizada adecuada. Esto significa que si hay fuentes para Java 8 y Java 9, estas fuentes estarán en conjuntos diferentes. Así es exactamente como funciona en el conjunto de fuentes para Java 9 , en el que habrá una versión de nuestra clase. Esto realmente funciona y no necesita crear un proyecto separado, como en Maven. Pero lo más importante, este método le permite ajustar la compilación del conjunto.
Una de las dificultades de tener diferentes versiones de una clase es que el código de clase rara vez es independiente del resto del código (tiene dependencias con clases que no están en el conjunto principal). Por ejemplo, su API puede usar clases que no necesitan fuentes especiales para admitir Java 9. Al mismo tiempo, no me gustaría volver a compilar todas estas clases comunes y empacar sus versiones para Java 9. Son clases comunes, por lo tanto, deben existir por separado de clases para un JDK específico. Lo configuramos aquí : agregue una dependencia entre el conjunto de origen para Java 9 y el conjunto principal, de modo que al compilar la versión para Java 9, todas las clases comunes permanezcan en el classpath de compilación.
El siguiente paso es simple : debe explicarle a Gradle que el conjunto principal de fuentes se compilará con el nivel API de Java 8, y el conjunto para Java 9 con el nivel Java 9.
Todo lo anterior lo ayudará a usar los dos enfoques mencionados anteriormente: implementar archivos JAR separados o JAR de lanzamiento múltiple. Como la publicación trata sobre este tema, veamos un ejemplo de cómo hacer que Gradle construya un JAR de lanzamiento múltiple:
jar { into('META-INF/versions/9') { from sourceSets.java9.output } manifest.attributes( 'Multi-Release': 'true' ) }
Este bloque describe: empacar clases para Java 9 en el directorio META-INF / versiones / 9 , que se usa para MR JAR, y establecer la etiqueta de liberación múltiple en el manifiesto.
Y eso es todo, ¡tu primer MR JAR está listo!
Pero, desafortunadamente, el trabajo en esto no ha terminado. Si trabajó con Gradle, sabe que cuando usa el complemento de la aplicación, puede ejecutar la aplicación directamente a través de la tarea de ejecución . Sin embargo, debido al hecho de que Gradle generalmente intenta minimizar la cantidad de trabajo, la tarea de ejecución debe usar tanto los directorios de clase como los directorios de los recursos procesados. Para los JAR de versiones múltiples, esto es un problema porque los JAR son necesarios de inmediato. Por lo tanto, en lugar de usar el complemento, tendrá que crear su propia tarea , y este es un argumento en contra del uso de JAR de lanzamiento múltiple.
Y por último, pero no menos importante, mencionamos que necesitaríamos probar 2 versiones de la clase. Para esto, puede usar solo VM en un proceso separado, porque no hay un equivalente del marcador de liberación para el tiempo de ejecución de Java. La idea es que solo se necesita escribir una prueba, pero se ejecutará dos veces: en Java 8 y Java 9. Esta es la única forma de asegurarse de que las clases específicas del tiempo de ejecución funcionen correctamente. De forma predeterminada, Gradle crea una tarea de prueba y utiliza los directorios de clase de la misma manera en lugar del JAR. Por lo tanto, haremos dos cosas: crear una tarea de prueba para Java 9 y configurar ambas tareas para que utilicen el JAR y los tiempos de ejecución de Java especificados. La implementación se verá así:
test { dependsOn jar def jdkHome = System.getenv("JAVA_8") classpath = files(jar.archivePath, classpath) - sourceSets.main.output executable = file("$jdkHome/bin/java") doFirst { println "$name runs test using JDK 8" } } task testJava9(type: Test) { dependsOn jar def jdkHome = System.getenv("JAVA_9") classpath = files(jar.archivePath, classpath) - sourceSets.main.output executable = file("$jdkHome/bin/java") doFirst { println classpath.asPath println "$name runs test using JDK 9" } } check.dependsOn(testJava9)
Ahora, cuando comience la tarea, verifique que Gradle compilará cada conjunto de fuentes usando el JDK deseado, creará un JAR de lanzamiento múltiple y luego ejecutará las pruebas usando este JAR en ambos JDK. Las versiones futuras de Gradle lo ayudarán a hacer esto de manera más declarativa.
Conclusión
Para resumir. Aprendiste que los JAR de lanzamiento múltiple son un intento de resolver el problema real que enfrentan muchos desarrolladores de bibliotecas. Sin embargo, esta solución al problema parece ser incorrecta. Gestión correcta de la dependencia, vinculación de artefactos y opciones, cuidado del rendimiento (la capacidad de ejecutar tantas tareas como sea posible en paralelo): todo esto hace de MR JAR una solución para los pobres. Este problema se puede resolver correctamente utilizando las opciones. Y, sin embargo, si bien la gestión de dependencias que utiliza las opciones de Gradle está en desarrollo, los JAR de lanzamiento múltiple son bastante convenientes en casos simples. En este caso, esta publicación lo ayudará a comprender cómo hacer esto y cómo la filosofía de Gradle difiere de Maven (conjunto de fuentes vs proyecto).
Finalmente, no negamos que haya casos en los que los JAR de lanzamiento múltiple tengan sentido: por ejemplo, cuando no se sabe en qué entorno se ejecutará la aplicación (no una biblioteca), pero esto es más bien una excepción. En esta publicación, describimos los principales problemas que enfrentan los desarrolladores de bibliotecas y cómo los JAR de versiones múltiples intentan resolverlos. El modelado correcto de las dependencias como opciones mejora el rendimiento (a través del paralelismo preciso) y reduce los costos de mantenimiento (evitando la complejidad imprevista) en comparación con los JAR de lanzamiento múltiple. En su situación, los MR JAR también pueden ser necesarios, por lo que Gradle ya se ha encargado de esto. Eche un vistazo a este proyecto de muestra y pruébelo usted mismo.