
Compartir:
Sina es promotora de desarrollo Java en Vonage. Procede del mundo académico y, en general, siente curiosidad por todo lo relacionado con los coches, los ordenadores, la programación, la tecnología y la naturaleza humana. En su tiempo libre, se le puede encontrar paseando o jugando a videojuegos de competición.
Un truco sencillo para resolver los problemas de dependencia del tiempo de ejecución de Java
Introducción
Si trabaja con cualquier lenguaje basado en JVMcomo Java, Kotlin, Scala, Groovy, Clojure, etc., lo más probable es que te hayas encontrado con herramientas de construcción y gestión de dependencias como Ant / Ivy, Maven, sbt, Leinengen o Gradle.
Fundamentalmente, el propósito de estas herramientas es automatizar el proceso de compilación (y a veces incluso de publicación) de su aplicación. Se encargan de compilar el código, ejecutar pruebas, generar páginas de documentación (Javadocs) y producir artefactos distribuibles como archivos JAR.
Entre las principales responsabilidades y ventajas de utilizar una herramienta de automatización de la compilación está su capacidad para gestionar las dependencias de forma declarativa. Para proyectos pequeños, es factible no utilizar un sistema de compilación en absoluto, optando en su lugar por un simple script de shell que utilice javac y jar para compilar y empaquetar la aplicación.
Esto se vuelve tedioso y menos fácil de mantener una vez que empiezas a añadir dependencias de terceros a tu proyecto. Encontrar la última versión del archivo JAR de la dependencia, utilizarlo como parte del proceso de compilación e incluirlo en el classpath tanto para la compilación como para la ejecución, sin olvidar no guardar binarios innecesarios en el sistema de control de versiones, es mucho trabajo manual.
Aunque las principales herramientas de compilación del ecosistema Java proporcionan una forma declarativa de especificar dependencias y gestionar el proceso por ti, no son infalibles. Ocasionalmente, puedes encontrarte con problemas causados por la inclusión de dependencias que, a primera vista, no tienen sentido, sin una causa o solución obvia.
Este artículo pretende explorar un escenario problemático de gestión de dependencias, por qué se produce y cómo resolverlo.
Ejemplo de proyecto
A efectos ilustrativos, utilizaré un proyecto Java con Maven como ejemplo mínimo de trabajo a lo largo de este artículo. Sin embargo, los principios también son aplicables a Gradle y otros lenguajes JVM. Sin más preámbulos, ¡entremos de lleno!
Este es el contenido inicial del elemento <project> en pom.xml:
<groupId>org.example</groupId>
<artifactId>minimal</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Dependency Example</name>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.vonage</groupId>
<artifactId>client</artifactId>
<version>7.10.0</version>
</dependency>
</dependencies>
Como puedes ver, estamos declarando una dependencia: el SDK Java de Vonage. Esta es nuestra clase principal, que utiliza Account API para obtener nuestro saldo y Messages API para enviarnos este saldo.
package com.example.minimal;
import com.vonage.client.VonageClient;
import com.vonage.client.messages.sms.SmsTextRequest;
public class BalancePrinter {
public static void main(String[] args) throws Throwable {
VonageClient client = VonageClient.builder()
.apiKey(System.getenv("VONAGE_API_KEY"))
.apiSecret(System.getenv("VONAGE_API_SECRET"))
.build();
var balance = client.getAccountClient().getBalance();
var balanceText = SmsTextRequest.builder()
.from("Vonage").to(System.getenv("TO_NUMBER"))
.text("Balance: €" + balance.getValue()).build();
var response = client.getMessagesClient().sendMessage(balanceText);
System.out.println(response.getMessageUuid());
}
}Cuando lo ejecutas desde tu IDE, esto funciona bien. Ahora vamos a crear un archivo JAR ejecutable con todas las dependencias para que tengamos un ejecutable independiente. Podemos hacer esto utilizando el Maven Shade plugin:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.minimal.BalancePrinter</mainClass>
</transformer>
</transformers>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Cuando ejecutamos mvn clean installobtenemos exactamente eso: un JAR ejecutable independiente (se encuentra en target/minimal-1.0-SNAPSHOT.jar). Y funciona. Sin embargo, ¿notaste las advertencias generadas al ejecutar el comando mvn comando? Al final, verás algo como esto:
"[ADVERTENCIA] maven-shade-plugin ha detectado que algunos archivos están presentes en dos o más JARs. Cuando esto sucede, sólo una única versión del archivo se copia en el uber jar. Normalmente esto no es perjudicial y puedes saltarte estas advertencias, de lo contrario intenta excluir manualmente los artefactos basándote en mvn dependency:tree -Ddetail=true y la salida anterior."
¿Qué intenta decirnos esta advertencia? Bueno, el propio SDK de Java tiene dependencias - puede examinarlas en la sección "Dependencias de tiempo de ejecución" en mvnrepository.com. Esas dependencias a su vez tienen sus propias dependencias y así sucesivamente.
Dado que estamos creando un único archivo JAR que "aplana" esta jerarquía de dependencias, terminamos con un montón de archivos de clase. Sin embargo, algunas de estas dependencias tienen dependencias en el mismo artefacto. Este proceso de desempaquetar recursivamente cada JAR para que sólo queden clases significa que si hay varias versiones de la misma dependencia, sólo se conserva una de las clases. Después de todo, no es posible tener dos archivos con el mismo nombre en el mismo directorio.
Por ejemplo, 'jackson-datatype-jsr310' depende de 'jackson-core', del que también dependemos a través de 'jackson-databind'. En algún lugar de la cadena también hay dependencias de SLF4J logging framework, que es utilizado por muchas bibliotecas populares. Maven destaca todas las clases en las que hay duplicados al aplanar la jerarquía de dependencias.
Dónde surgen los problemas
Como se indica en la advertencia, normalmente esto no es un problema. Si lo fueran, entonces sería muy difícil, si no imposible, automatizar la construcción incluso de los proyectos más simples con dependencias, como el de este artículo.
La estrategia para seleccionar qué versión de la clase utilizar (es decir, qué dependencia elegir) depende del orden de carga de la clase y de detalles que van más allá del alcance de este artículo, pero la cuestión es que, en última instancia, sólo puede haber una instancia de una clase totalmente cualificada en cualquier instancia de tiempo de ejecución de una aplicación Java. Cuando se necesitan múltiples versiones, el plugin Shade tiene una estrategia configurable de estrategia de reubicación para que puedan coexistir.
Teniendo esto en cuenta, ¿cuándo es problemático? Si por defecto todo funciona, ¿cuándo falla? Para demostrarlo, volvamos a nuestro ejemplo.
Ahora, vamos a romperlo añadiendo la siguiente dependencia a la sección <dependencies> de nuestro pom.xml:
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.0-alpha1</version>
</dependency>
Si reconstruye con mvn clean install y luego intenta ejecutar el programa utilizando el archivo JAR, obtendrá el siguiente error:
Excepción en el hilo "main" java.lang.NoClassDefFoundError: org/apache/http/conn/HttpClientConnectionManager
Esto se debe a que ahora está sobrescribiendo una de las dependencias principales del SDK con una versión mucho más antigua que no contiene una clase de la que dependemos. Lo mismo ocurre si se vuelve a ejecutar desde el IDE. En su lugar, puede probar con una dependencia diferente. Por ejemplo:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.0.0</version>
</dependency>
Esto generará el siguiente error:
Excepción en el hilo "main" java.lang.NoClassDefFoundError: com/fasterxml/jackson/core/util/JacksonFeature
Aunque el Vonage Java SDK no tiene dependencia directa de 'jackson-core' todavía causa un problema porque estamos sobreescribiendo la versión de la biblioteca (y por lo tanto, qué archivos de clase) se incluyen en nuestra aplicación.
Tiempo de ejecución vs Compilación
Te estarás preguntando, ¿cómo es posible que nuestro programa se haya podido compilar sin problemas a pesar de esta dependencia errónea, y sin embargo sólo nos hayamos encontrado con el problema al ejecutarlo? Esto se debe a la forma en que las dependencias se resuelven en tiempo de compilación en comparación con el tiempo de ejecución.
En tiempo de compilación, ya tenemos todas las dependencias que necesitamos. Después de todo, el SDK Java de Vonage ya ha sido construido y se ha comprobado que funciona, por lo que no necesitamos recompilar las clases, lo estamos cargando desde un JAR existente. Sin embargo, en tiempo de ejecución, sustituimos las clases utilizadas durante la ejecución.
Los archivos de clase ya se han generado durante la fase de compilación, pero en tiempo de ejecución pueden existir versiones diferentes de esas clases en el classpath.
Esta es la clave para entender los problemas con las dependencias en tiempo de ejecución: casi siempre se debe a que el classpath está mal configurado. Los sistemas de compilación como Maven y Gradle tienen opciones de configuración por defecto para ayudar con esto, pero el mal uso de ellos como se muestra arriba puede conducir a errores. Vale la pena entender los diferentes ámbitos de las declaraciones de dependencia.
Por defecto en Maven, una dependencia está definida para ser incluida tanto en tiempo de ejecución como en tiempo de compilación - llamada compile. Puedes leer más sobre scopes en la documentación oficial. De forma similar en Gradle, el ámbito por defecto es implementation (en lugar de, por ejemplo compileOnly).
Aunque la terminología difiere, el alcance y la propagación de las dependencias son conceptos importantes que hay que comprender cuando se confía en cualquier sistema de compilación para gestionar las dependencias y, en última instancia, el classpath de la aplicación.
La solución sencilla
El título de este artículo prometía una solución rápida para los problemas de dependencias. La realidad es que, aunque en última instancia el problema tiene que ver con un classpath mal configurado, la solución dependerá en gran medida de la complejidad de tu proyecto y sus dependencias.
El comando mvn dependency:tree (y similares en otras herramientas de compilación) puede ayudarte a identificar dependencias anidadas y potencialmente conflictivas, pero en última instancia depende de ti asegurarte de que no estás sobrescribiendo las dependencias utilizadas por otras bibliotecas de las que dependes, ya que esto puede dar lugar a conflictos.
A veces, las interacciones entre ciertas dependencias pueden causar problemas, por lo que el primer punto de llamada es averiguar qué dependencias tienen en común y si hay una discrepancia de versión. ¿Existe una versión que pueda utilizar y que sea compatible con todas las demás dependencias? Si es así, declárala explícitamente.
Sin embargo, la mejor forma de minimizar los conflictos es declarar únicamente las dependencias que se utilizan directamente. Por lo general, la mejor práctica consiste en minimizar el número de dependencias declaradas en el proyecto y utilizar únicamente las necesarias.
Cuantas más dependencias añadas a tu proyecto, más se hinchará tu artefacto final y más problemas potenciales pueden surgir de los conflictos de dependencias. Por no mencionar que las dependencias pueden reducir la seguridad de tu aplicación, a menudo a través de dependencias anidadas.
Por ejemplo, supongamos que dependes de una biblioteca (Biblioteca A) que indirectamente depende de una versión anterior de otra biblioteca (Biblioteca B) con un fallo de seguridad. Si los mantenedores de la biblioteca A no actualizan a la última versión de B, ese fallo también podría afectar a tu aplicación. Puede intentar solucionar este problema especificando directamente la última versión de la biblioteca B en su archivo pom.xml o en build.gradle. Sin embargo, este enfoque no garantiza la compatibilidad, y traslada la responsabilidad de los mantenedores de la librería A a TI.
La mayoría de las veces, como desarrollador, no sabes ni te preocupas por todas las clases incluidas en tu aplicación a través de dependencias transitivas. Es decir, hasta que algo se rompe.
Entonces, tienes que investigar el classpath. Algunas herramientas pueden ayudar. Por ejemplo, Maven puede proporcionar un "pom efectivo" utilizando el plugin del mismo nombre. Esto le dará una idea más clara de qué configuraciones se están utilizando en su construcción (útil para proyectos complejos).
Pero quizá lo más útil sea comprender su gráfico de dependencias. En este caso, el comando mvn dependency:tree ofrece una imagen clara de todas las bibliotecas y su alcance. Busque las que tienen runtime y comprueba si hay algún conflicto potencial. Otras herramientas de compilación tienen mecanismos similares. Por ejemplo gradle dependencies en Gradle y dependencytree en Ivy. A continuación, puede utilizar esta información para ver dónde puede haber dependencias en conflicto.
Despedida
Esto es todo por ahora. Espero que este artículo le haya resultado informativo y útil. La clave es que los problemas de dependencia en tiempo de ejecución son el resultado de un classpath mal configuradolo que significa que falta algo o que se está utilizando una versión incorrecta. Comprender el alcance de tus dependencias y los posibles conflictos entre dependencias transitivas te ayudará a llegar al fondo del asunto.
Si tiene algún comentario o sugerencia, no dude en ponerse en contacto con nosotros en X, antes conocido como Twitter o pásate por nuestro Slack de la comunidad. Si te ha gustado, echa un vistazo a mis otros artículos sobre Java.
Compartir:
Sina es promotora de desarrollo Java en Vonage. Procede del mundo académico y, en general, siente curiosidad por todo lo relacionado con los coches, los ordenadores, la programación, la tecnología y la naturaleza humana. En su tiempo libre, se le puede encontrar paseando o jugando a videojuegos de competición.