Compilando una biblioteca con dependencias
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
En la página anterior dejamos un cliffhanger, anticipando que la ejecución de la aplicación no funcionaría como esperábamos y te invitamos a ejecutar el código para descubrir qué sucedía. En este capítulo, desvelaremos por qué ocurrió ese error y te guiaremos paso a paso para solucionarlo correctamente.
Ejecutando la aplicación
Para ejecutar la aplicación, utiliza el comando ./gradlew :app:run
. Sin embargo, al ejecutar este comando, es probable que te encuentres con un error similar al siguiente:
> Task :app:run FAILED
Exception in thread "main" java.lang.NoClassDefFoundError: kotlinx/datetime/Clock$System
at cl.ravenhill.EchoKt.echo(Echo.kt:5)
at cl.ravenhill.EchoAppKt.main(EchoApp.kt:5)
Caused by: java.lang.ClassNotFoundException: kotlinx.datetime.Clock$System
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
... 2 more
Este error ocurre porque la biblioteca kotlinx-datetime
no se ha añadido al classpath de la aplicación. Aunque la biblioteca se encuentra empaquetada en el archivo JAR, no está disponible en el classpath durante la ejecución, lo que impide que la aplicación encuentre las clases necesarias para funcionar.
Classpath
El classpath es un parámetro de la JVM que define las rutas donde se encuentran las clases y recursos necesarios para la ejecución de una aplicación. Incluye tanto las clases propias del proyecto como las de bibliotecas externas que no forman parte de la librería estándar de Java.
Transitividad de requerimientos
Utilizaremos el término transitividad de requerimientos para referirnos a cómo la dependencia de una biblioteca puede requerir otras bibliotecas. Diremos que una biblioteca tiene una dependencia transitiva si requiere otra biblioteca para funcionar correctamente. La transitividad se da en que una biblioteca puede requerir otra biblioteca, que a su vez puede requerir otra biblioteca, y así sucesivamente. Si alguna de esas bibliotecas no se incluye en el classpath, la aplicación no funcionará correctamente.
En nuestro caso, lo que sucede es que la biblioteca lib
requiere la biblioteca kotlinx-datetime
, pero esta última no se incluye en el classpath de la aplicación. Por lo tanto, debemos asegurarnos de que todas las dependencias transitivas de lib
estén disponibles en el classpath.
La manera más sencilla de abordar este problema es simplemente dejar que la transitividad de requerimientos llegue a la aplicación final y que la aplicación tenga como dependencia directa todas las bibliotecas necesarias. Sin embargo, esto resultará en que el "cliente" de la biblioteca lib
también deberá incluir todas las dependencias transitivas de lib
, lo que puede ser inconveniente, además de agregar pasos adicionales para poder utilizar nuestra biblioteca.
Dicho esto, la solución en este caso sería simplemente repetir la dependencia de kotlinx-datetime
en el archivo build.gradle.kts
de la aplicación. De esta manera, la biblioteca kotlinx-datetime
se incluirá en el classpath de la aplicación y se resolverá el error de NoClassDefFoundError
.
JAR en detalle
Para comprender mejor por qué la transitividad de dependencias no se resuelve automáticamente, es importante profundizar en cómo se empaquetan las bibliotecas en un archivo JAR. Un archivo JAR es esencialmente un archivo ZIP que contiene los archivos de clase y recursos de una biblioteca, junto con un archivo META-INF/MANIFEST.MF
que describe la biblioteca y sus metadatos, como dependencias.
Podemos descomprimir el archivo JAR de la biblioteca lib
utilizando herramientas como unzip
en Unix o 7-Zip
en Windows. Si no tienes instalada una herramienta, puedes consultar la guía de instalación, aunque este paso es opcional:
unzip -l lib/build/libs/lib-1.0.0.jar -d decompiled-jar
7z x lib/build/libs/lib-1.0.0.jar -odecompiled-jar
Esto generará una estructura de directorios como la siguiente:
./echo/decompiled-jar
├───cl
│ └───ravenhill
│ EchoKt.class
│
└───META-INF
lib.kotlin_module
MANIFEST.MF
Revisemos lo que se generó
cl.ravenhill.EchoKt.class
Este archivo contiene la clase compilada en bytecode de Java, por lo que no podemos leerlo directamente. Sin embargo, podemos usar un decompilador como Fernflower para obtener una representación en texto del código. Aquí tienes un ejemplo del código decompilado del archivo EchoKt.class
:
// Source code is decompiled from a .class file using FernFlower decompiler.
package cl.ravenhill;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import kotlinx.datetime.Instant;
import kotlinx.datetime.Clock.System;
import org.jetbrains.annotations.NotNull;
@Metadata(
mv = {2, 0, 0},
k = 2,
xi = 48,
d1 = {"\u0000\n\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0002\u001a\u000e\u0010\u0000\u001a\u00020\u00012\u0006\u0010\u0002\u001a\u00020\u0001\u00a8\u0006\u0003"},
d2 = {"echo", "", "message", "lib"}
)
public final class EchoKt {
@NotNull
public static final String echo(@NotNull String message) {
Intrinsics.checkNotNullParameter(message, "message");
Instant var10000 = System.INSTANCE.now();
return "" + var10000 + " - " + message;
}
}
Este es el resultado de convertir un archivo Kotlin a Java. A continuación, explicamos algunos puntos clave:
-
Paquete y Clases Importadas:
- El archivo pertenece al paquete
cl.ravenhill
. - Utiliza clases de Kotlin como
Instant
yClock
para gestionar fechas y tiempos, además de anotaciones como@NotNull
para garantizar la seguridad frente a valores nulos.
- El archivo pertenece al paquete
-
Anotación
@Metadata
:- Generada por el compilador de Kotlin, esta anotación almacena información del archivo original, como la versión de Kotlin y la estructura de la clase, facilitando la interoperabilidad entre Kotlin y Java.
-
Clase
EchoKt
:- En Kotlin, las funciones fuera de una clase se agrupan en una clase generada automáticamente, llamada
EchoKt
en este caso. - La función
echo
es estática, recibe un parámetromessage
, y devuelve una cadena con la fecha y el mensaje concatenados.
- En Kotlin, las funciones fuera de una clase se agrupan en una clase generada automáticamente, llamada
META-INF
Dentro de esta carpeta, encontramos dos archivos:
lib.kotlin_module
Este archivo contiene información sobre el módulo de Kotlin al que pertenece la biblioteca. No es relevante para nuestro análisis actual.
MANIFEST.MF
El archivo de manifiesto contiene metadatos sobre el archivo JAR, como la versión y otra información importante. En JARs ejecutables, el archivo de manifiesto define el punto de entrada de la aplicación con el atributo Main-Class
.
En nuestro caso, el archivo de manifiesto de lib
es simple:
Manifest-Version: 1.0
Aunque este archivo es básico, en bibliotecas más avanzadas puede incluir información importante como la versión de la biblioteca, el autor y otras propiedades. Para el final de esta lección tendremos un archivo más completo.
Resolviendo dependencias transitivas
Para manejar las dependencias transitivas de nuestra biblioteca lib
, debemos asegurarnos de que todas las dependencias requeridas (como kotlinx-datetime
) estén incluidas en el classpath de la aplicación. La solución común es crear un fat JAR.
Fat JAR
Un fat JAR (también conocido como uber JAR) es un archivo JAR que incluye no solo las clases y recursos de la aplicación principal, sino también todas las dependencias necesarias para su ejecución.
Primero, crearemos una extensión que permita configurar nuestro plugin de construcción. Para ello, añadimos la clase FatJarExtension
en el paquete extensions
del módulo convention-plugins
:
package extensions
abstract class FatJarExtension {
abstract var implementationTitle: String
abstract var implementationVersion: String
}
Luego, actualizamos el archivo compile.conventions.gradle.kts
para registrar la nueva extensión:
- Kotlin DSL
- Groovy DSL
import extensions.FatJarExtension
// ...
extensions.create<FatJarExtension>("fatJar")
import extensions.FatJarExtension
// ...
extensions.create(FatJarExtension, "fatJar")
Con esto configurado, podemos utilizar esta extensión en el archivo build.gradle.kts
de la biblioteca lib
:
- Kotlin DSL
- Groovy DSL
fatJar {
implementationTitle = project.name
implementationVersion = project.version.toString()
}
fatJar {
implementationTitle = project.name
implementationVersion = project.version.toString()
}
Finalmente, añadimos una tarea en el archivo compile.conventions.gradle.kts
para construir el fat JAR:
- Kotlin DSL
- Groovy DSL
import java.time.LocalDateTime // (1)
plugins {
id("jvm.conventions") // (2)
}
// ...
tasks.register<Jar>("fatJar") {
group = "build"
description = "Creates a fat JAR with all dependencies"
val fatJarConfig = project.extensions.getByType<FatJarExtension>()
archiveClassifier = "all" // (3)
duplicatesStrategy = DuplicatesStrategy.EXCLUDE // (4)
manifest { // (5)
attributes["Implementation-Title"] = fatJarConfig.implementationTitle
attributes["Implementation-Version"] = fatJarConfig.implementationVersion
attributes["Build-Date"] = LocalDateTime.now().toString()
}
from(sourceSets.main.get().output) // (6)
dependsOn(configurations.runtimeClasspath) // (7)
from({ // (8)
configurations.runtimeClasspath.get()
.filter { file -> file.name.endsWith("jar") } // (9)
.map { file -> zipTree(file) } // (10)
})
}
Explicación de la tarea fatJar
import java.time.LocalDateTime
: Importa la claseLocalDateTime
para obtener la fecha y hora actuales. No tenemos acceso akotlinx-datetime
en este punto, por lo que usamos la clase estándar de Java.plugins { id("jvm.conventions") }
: Aplica el pluginjvm.conventions
para acceder a las convenciones de construcción de JVM. Esto es necesario para acceder a las rutas de salida y las dependencias del proyecto.archiveClassifier
: Define el sufijo del archivo JAR. En este caso,all
indica que es un fat JAR, resultando en un archivo comolib-1.0.0-all.jar
.duplicatesStrategy
: Establece cómo manejar archivos duplicados. UsamosEXCLUDE
para evitar conflictos y reducir el tamaño del JAR.manifest
: Establece los atributos del manifiesto JAR, como el título, la versión de la implementación y la fecha de compilación.Implementation-Title
yImplementation-Version
son atributos comunes y necesarios para publicar la biblioteca, mientras queBuild-Date
es personalizado.from(sourceSets.main.get().output)
: Incluye los archivos compilados del proyecto en el JAR.dependsOn(configurations.runtimeClasspath)
: Asegura que todas las dependencias de tiempo de ejecución se incluyan antes de crear el fat JAR.from { ... }
: Añade las dependencias externas al JAR..filter { file -> file.name.endsWith("jar") }
: Filtra para incluir solo archivos JAR..map { file -> zipTree(file) }
: Descomprime los JARs para incluir su contenido en el fat JAR.
Probando la tarea
Ejecuta la tarea fatJar
para generar el fat JAR:
./gradlew :lib:fatJar
Esto creará el archivo JAR en lib/build/libs/lib-1.0.0-all.jar
, que incluirá todas las dependencias transitivas como kotlinx-datetime
.
Podemos verificar el contenido del JAR descomprimiéndolo:
unzip -l lib/build/libs/lib-1.0.0-all.jar -d decompiled-fat-jar
7z x lib/build/libs/lib-1.0.0-all.jar -odecompiled-fat-jar
En el archivo META-INF/MANIFEST.MF
, podrás ver los atributos del manifiesto que definimos en la tarea:
Manifest-Version: 1.0
Implementation-Title: lib
Implementation-Version: 1.0.0
Build-Date: 2024-09-12T14:37:34.279851100
Automatizando la creación del fat JAR
Para automatizar el proceso de creación del fat JAR (JAR con todas las dependencias), podemos modificar la tarea copyLib
en el archivo compile.conventions.gradle.kts
para que dependa de la tarea fatJar
en lugar de la tarea estándar jar
.
Esto nos permitirá que, al ejecutar la tarea copyLib
, se genere y copie el fat JAR, asegurando que todas las dependencias transitivas estén incluidas.
- Kotlin DSL
- Groovy DSL
// Implementación anterior
tasks.named("copyLib") {
dependsOn("jar") // Dependía de la tarea jar estándar
}
// Nueva implementación
tasks.named("copyLib") {
dependsOn("fatJar") // Ahora depende del fat JAR
}
// Implementación anterior
tasks.named('copyLib') {
dependsOn 'jar' // Dependía de la tarea jar estándar
}
// Nueva implementación
tasks.named('copyLib') {
dependsOn 'fatJar' // Ahora depende del fat JAR
}
Ahora, al ejecutar la tarea copyLib
, se generará el fat JAR automáticamente y este será copiado al directorio de la aplicación. Este proceso garantiza que el JAR contenga todas las dependencias transitivas, lo que facilita la distribución de la biblioteca o la ejecución de la aplicación sin tener que gestionar manualmente esas dependencias.
Ejecutando la aplicación con el fat JAR
Una vez que hemos generado el fat JAR, el siguiente paso es agregar esta nueva dependencia en el archivo build.gradle.kts
del módulo app
. Esto permitirá que la aplicación utilice el JAR con todas las dependencias incluidas.
- Kotlin DSL
- Groovy DSL
dependencies {
implementation(
fileTree("libs") {
include("lib-1.0.0-all.jar") // Agregamos el fat JAR
// Puedes agregar más JARs si es necesario
}
)
}
dependencies {
implementation fileTree("libs") {
include "lib-1.0.0-all.jar" // Agregamos el fat JAR
// Puedes agregar más JARs si es necesario
}
}
Ejecutando la aplicación
Con la dependencia configurada, ahora podemos ejecutar la aplicación utilizando el fat JAR:
./gradlew :app:run --args="Hello, world!"
Si todo ha sido configurado correctamente, deberías ver una salida similar a la siguiente:
> Task :app:run
2024-09-12T17:50:25.821885Z - Hello,
2024-09-12T17:50:25.830884600Z - world!
¡Y eso es todo! La aplicación ahora utiliza el fat JAR que incluye todas las dependencias necesarias.
¿Qué aprendimos?
En esta sección, hemos aprendido a gestionar las dependencias transitivas de una biblioteca mediante la creación de un fat JAR. Un fat JAR incluye todas las dependencias necesarias para ejecutar una aplicación o biblioteca, eliminando la necesidad de gestionar manualmente el classpath y asegurando que todas las dependencias estén disponibles en tiempo de ejecución.
Puntos clave:
- Error
NoClassDefFoundError
: Este error ocurre cuando una clase requerida por la aplicación no está en el classpath. Lo resolvimos empaquetando todas las dependencias necesarias en un fat JAR. - Classpath y transitividad de requerimientos: Comprendimos cómo las dependencias de una biblioteca pueden tener sus propias dependencias transitivas, que también deben estar disponibles en el classpath para evitar errores.
- JARs y meta información: Exploramos la estructura de un JAR, desde los archivos compilados hasta el archivo
MANIFEST.MF
, que contiene metadatos importantes sobre la biblioteca o aplicación. - Automatización con fat JARs: Configuramos un proceso automatizado para construir un fat JAR que incluye todas las dependencias, lo que simplifica la distribución de la biblioteca y facilita la ejecución de la aplicación sin preocuparse por las dependencias externas.
Este enfoque nos permitió distribuir una biblioteca como un único archivo JAR que contiene todo lo necesario para funcionar correctamente. Esto es particularmente útil en proyectos grandes o cuando queremos compartir la biblioteca con otras aplicaciones o equipos sin tener que preocuparnos por dependencias adicionales.
Finalmente, aprendimos a generar un fat JAR con Gradle y a utilizarlo dentro de nuestra aplicación de forma sencilla, permitiendo que todas las dependencias estén incluidas y asegurando que la aplicación funcione de manera robusta en cualquier entorno.