Skip to main content

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:

Unix
unzip -l lib/build/libs/lib-1.0.0.jar -d decompiled-jar
Windows
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:

  1. Paquete y Clases Importadas:

    • El archivo pertenece al paquete cl.ravenhill.
    • Utiliza clases de Kotlin como Instant y Clock para gestionar fechas y tiempos, además de anotaciones como @NotNull para garantizar la seguridad frente a valores nulos.
  2. 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.
  3. 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ámetro message, y devuelve una cadena con la fecha y el mensaje concatenados.

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:

convention-plugins/src/main/kotlin/extensions/FatJarExtension.kt
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:

convention-plugins/src/main/kotlin/compile.conventions.gradle.kts
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:

lib/build.gradle.kts
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:

convention-plugins/src/main/kotlin/compile.conventions.gradle.kts
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

  1. import java.time.LocalDateTime: Importa la clase LocalDateTime para obtener la fecha y hora actuales. No tenemos acceso a kotlinx-datetime en este punto, por lo que usamos la clase estándar de Java.
  2. plugins { id("jvm.conventions") }: Aplica el plugin jvm.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.
  3. archiveClassifier: Define el sufijo del archivo JAR. En este caso, all indica que es un fat JAR, resultando en un archivo como lib-1.0.0-all.jar.
  4. duplicatesStrategy: Establece cómo manejar archivos duplicados. Usamos EXCLUDE para evitar conflictos y reducir el tamaño del JAR.
  5. 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 y Implementation-Version son atributos comunes y necesarios para publicar la biblioteca, mientras que Build-Date es personalizado.
  6. from(sourceSets.main.get().output): Incluye los archivos compilados del proyecto en el JAR.
  7. dependsOn(configurations.runtimeClasspath): Asegura que todas las dependencias de tiempo de ejecución se incluyan antes de crear el fat JAR.
  8. from { ... }: Añade las dependencias externas al JAR.
  9. .filter { file -> file.name.endsWith("jar") }: Filtra para incluir solo archivos JAR.
  10. .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:

Unix
unzip -l lib/build/libs/lib-1.0.0-all.jar -d decompiled-fat-jar
Windows
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.

convention-plugins/src/main/kotlin/compile.conventions.gradle.kts
// 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.

app/build.gradle.kts
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.