
Partager:
Sina est développeur Java chez Vonage. Il est issu d'une formation universitaire et est généralement curieux de tout ce qui touche aux voitures, aux ordinateurs, à la programmation, à la technologie et à la nature humaine. Pendant son temps libre, on peut le trouver en train de marcher ou de jouer à des jeux vidéo compétitifs.
Une astuce simple pour résoudre les problèmes de dépendance au niveau de l'exécution de Java
Introduction
Si vous travaillez avec un langage basé sur la langage basé sur la JVMcomme Java, Kotlin, Scala, Groovy, Clojure, etc., vous avez très probablement rencontré des outils de construction et de gestion des dépendances tels que Ant / Ivy, Maven, sbt, Leinengen ou Gradle.
Fondamentalement, l'objectif de ces outils est d'automatiser le processus de construction (et parfois même de publication) de votre application. Ils se chargent de compiler votre code, d'exécuter des tests, de générer des pages de documentation (Javadocs) et de produire des artefacts distribuables tels que des fichiers JAR.
L'une des principales responsabilités et l'un des principaux avantages de l'utilisation d'un outil d'automatisation de la compilation est sa capacité à gérer les dépendances de manière déclarative. Pour les petits projets, il est possible de ne pas utiliser de système de construction du tout, en optant plutôt pour un simple script shell qui utilise les commandes javac et jar pour compiler et empaqueter l'application.
Cela devient fastidieux et moins facile à maintenir lorsque vous commencez à ajouter des dépendances tierces à votre projet. Trouver la dernière version du fichier JAR pour la dépendance, l'utiliser dans le cadre de votre processus de construction et l'inclure dans le classpath pour la compilation et l'exécution, tout en veillant à ne pas conserver de binaires inutiles dans votre système de contrôle de version, c'est beaucoup à gérer manuellement.
Bien que les principaux outils de construction de l'écosystème Java fournissent un moyen déclaratif de spécifier les dépendances et gèrent le processus pour vous, ils ne sont pas infaillibles. Il peut arriver que vous rencontriez des problèmes dus à l'inclusion de dépendances qui, à première vue, n'ont pas de sens, sans cause ni solution évidente.
Cet article a pour but d'explorer un scénario problématique de gestion des dépendances, d'en expliquer les raisons et de voir comment le résoudre.
Exemple de projet
À des fins d'illustration, j'utiliserai un projet Java avec Maven comme exemple de travail minimal tout au long de cet article. Cependant, les principes sont également applicables à Gradle et à d'autres langages JVM. Sans plus attendre, plongeons dans le vif du sujet !
Voici le contenu initial de l'élément <project> dans 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>
Comme vous pouvez le voir, nous ne déclarons qu'une seule dépendance : le SDK Java de Vonage. Voici notre classe principale, qui utilise l'API Account pour obtenir notre solde, et l'API Messages pour nous envoyer ce solde par SMS.
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());
}
}Lorsque vous l'exécutez à partir de votre IDE, cela fonctionne correctement. Maintenant, créons un fichier JAR exécutable avec toutes les dépendances afin d'avoir un exécutable autonome. Nous pouvons le faire en utilisant le plugin plugin Maven Shade:
<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>
Lorsque nous lançons mvn clean installnous obtenons exactement cela : un JAR autonome exécutable (situé dans target/minimal-1.0-SNAPSHOT.jar). Et cela fonctionne ! Cependant, avez-vous remarqué les avertissements générés lors de l'exécution de la commande mvn ? Au final, vous verrez quelque chose comme ceci :
"[WARNING] maven-shade-plugin a détecté que certains fichiers sont présents dans deux JARs ou plus. Lorsque cela se produit, une seule version du fichier est copiée dans l'uber jar. En général, cela n'est pas dangereux et vous pouvez ignorer ces avertissements, sinon essayez d'exclure manuellement les artefacts en vous basant sur mvn dependency:tree -Ddetail=true et la sortie ci-dessus."
Qu'est-ce que cet avertissement essaie de nous dire ? Eh bien, le SDK Java lui-même a des dépendances - vous pouvez les parcourir dans la section "Runtime Dependencies" sur mvnrepository.com. Ces dépendances ont à leur tour leurs propres dépendances et ainsi de suite.
Comme nous créons un seul fichier JAR qui "aplatit" cette hiérarchie de dépendances, nous nous retrouvons avec un grand nombre de fichiers de classe. Cependant, certaines de ces dépendances ont des dépendances sur le même artefact. Ce processus de dépaquetage récursif de chaque JAR afin qu'il ne reste que des classes signifie que s'il existe plusieurs versions de la même dépendance, seule une des classes est conservée. En effet, il n'est pas possible d'avoir deux fichiers portant le même nom dans le même répertoire.
Par exemple, "jackson-datatype-jsr310" dépend de "jackson-core", dont nous dépendons également par l'intermédiaire de "jackson-databind". Quelque part dans la chaîne, il y a aussi des dépendances sur le cadre de journalisation SLF4J, qui est utilisé par de nombreuses bibliothèques populaires. Maven met en évidence toutes les classes dans lesquelles il y a des doublons lors de l'aplatissement de la hiérarchie des dépendances.
L'origine des problèmes
Comme l'indique l'avertissement, ce n'est généralement pas un problème. Si c'était le cas, il serait très difficile, voire impossible, d'automatiser la construction des projets les plus simples comportant des dépendances, comme celui présenté dans cet article.
La stratégie de sélection de la version de la classe à utiliser (c'est-à-dire de la dépendance à choisir) dépend de l'ordre de chargement de la classe et de détails qui dépassent le cadre de cet article, mais l'essentiel est qu'il ne peut jamais y avoir qu'une seule instance d'une classe pleinement qualifiée dans une instance d'exécution donnée d'une application Java. Lorsque plusieurs versions sont nécessaires, le plugin Shade dispose d'une stratégie configurable de stratégie de relocalisation afin qu'elles puissent coexister.
Dans ce contexte, quand cela pose-t-il un problème ? Si, par défaut, tout fonctionne, quand cela échoue-t-il ? Pour le démontrer, revenons à notre exemple.
Maintenant, brisons-le en ajoutant la dépendance suivante à la section <dependencies> de notre pom.xml:
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.0-alpha1</version>
</dependency>
Si vous reconstruisez avec mvn clean install et que vous essayez ensuite d'exécuter le programme à l'aide du fichier JAR, vous obtiendrez l'erreur suivante :
Exception dans le thread "main" java.lang.NoClassDefFoundError : org/apache/http/conn/HttpClientConnectionManager
En effet, vous remplacez l'une des principales dépendances du SDK par une version beaucoup plus ancienne qui ne contient pas de classe dont nous dépendons. Il en va de même si vous l'exécutez à nouveau à partir de l'IDE. Vous pouvez essayer une autre dépendance. Par exemple :
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.0.0</version>
</dependency>
Cela génère l'erreur suivante :
Exception dans le thread "main" java.lang.NoClassDefFoundError : com/fasterxml/jackson/core/util/JacksonFeature
Même si le SDK Java de Vonage ne dépend pas directement de "jackson-core", cela pose tout de même un problème car nous modifions la version de la bibliothèque (et donc les fichiers de classe) qui sont inclus dans notre application.
Exécution ou compilation
Vous vous demandez peut-être comment il se fait que notre programme ait réussi à se compiler correctement malgré cette dépendance erronée, alors que nous n'avons rencontré le problème qu'au moment de l'exécuter. Cela est dû à la manière dont les dépendances sont résolues à la compilation et non à l'exécution.
Au moment de la compilation, nous disposons déjà de toutes les dépendances dont nous avons besoin. Après tout, le SDK Java de Vonage a déjà été construit et son fonctionnement a été prouvé, nous n'avons donc pas besoin de recompiler les classes, nous les chargeons à partir d'un JAR existant. Cependant, au moment de l'exécution, nous remplaçons les classes utilisées pendant l'exécution.
Les fichiers de classe ont déjà été générés lors de la phase de compilation, mais différentes versions de ces classes peuvent être présentes au moment de l'exécution sur le chemin d'accès aux classes.
C'est la clé pour comprendre les problèmes de dépendances à l'exécution : ils sont presque toujours dus à une mauvaise configuration du chemin d'accès. Les systèmes de construction tels que Maven et Gradle disposent de valeurs par défaut et d'options de configuration judicieuses pour résoudre ce problème, mais une mauvaise utilisation de ces options, comme indiqué ci-dessus, peut entraîner des erreurs. Il est utile de comprendre les différentes portées des déclarations de dépendances.
Par défaut, dans Maven, une dépendance est incluse à la fois au moment de l'exécution et de la compilation. compile. Vous pouvez en savoir plus sur les scopes dans la la documentation officielle. De même, dans Gradle, la portée par défaut est implementation (par opposition à, par exemple, compileOnly).
Bien que la terminologie diffère, le cadrage et la propagation des dépendances sont des concepts importants à comprendre lorsque l'on s'appuie sur un système de construction pour gérer ses dépendances et, en fin de compte, le chemin de classe de son application.
La solution simple
Le titre de cet article promettait une solution rapide aux problèmes de dépendances. En réalité, bien que le problème soit lié à un classpath mal configuré, la solution dépendra largement de la complexité de votre projet et de ses dépendances.
La commande mvn dependency:tree (et ses équivalents dans d'autres outils de construction) peut vous aider à identifier les dépendances imbriquées et potentiellement conflictuelles, mais en fin de compte, c'est à vous de vous assurer que vous n'écrasez pas les dépendances utilisées par d'autres bibliothèques dont vous dépendez, car cela peut conduire à des conflits.
Parfois, les interactions entre certaines dépendances peuvent causer des problèmes. La première chose à faire est donc de déterminer quelles dépendances sont communes et s'il y a un écart de version. Existe-t-il une version que vous pouvez utiliser et qui est compatible avec toutes vos autres dépendances ? Si c'est le cas, déclarez-la explicitement.
Cependant, la meilleure façon de minimiser les conflits est de ne déclarer que les dépendances que vous utilisez directement. La meilleure pratique consiste généralement à minimiser le nombre de dépendances déclarées dans votre projet et à n'utiliser que ce dont vous avez besoin.
Plus vous ajoutez de dépendances à votre projet, plus votre artefact final devient volumineux et plus les conflits de dépendances sont susceptibles de poser des problèmes. Sans oublier que les dépendances peuvent réduire la sécurité de votre application, souvent par le biais de dépendances imbriquées.
Par exemple, supposons que vous utilisiez une bibliothèque (bibliothèque A) qui dépend indirectement d'une ancienne version d'une autre bibliothèque (bibliothèque B) présentant une faille de sécurité. Si les responsables de la bibliothèque A ne mettent pas à jour la dernière version de la bibliothèque B, cette faille peut également avoir un impact sur votre application. Vous pouvez tenter de résoudre ce problème en spécifiant directement la dernière version de la bibliothèque B dans votre fichier pom.xml ou build.gradle. Cependant, cette approche ne garantit pas la compatibilité et déplace la responsabilité des mainteneurs de la bibliothèque A vers VOUS.
La plupart du temps, en tant que développeur, vous ne connaissez pas toutes les classes incluses dans votre application par le biais de dépendances transitives et vous ne vous en souciez pas. Du moins, jusqu'à ce que quelque chose se brise.
Ensuite, vous devez examiner le chemin d'accès (classpath). Certains outils peuvent aider. Par exemple, Maven peut fournir un "pom efficace" en utilisant le plugin éponyme. Cela vous donnera une image plus claire des paramètres utilisés dans votre construction (utile pour les projets complexes).
Mais le plus utile est sans doute de comprendre votre graphe de dépendance. Ici, la commande mvn dependency:tree donne une image claire de toutes les bibliothèques et de leur portée. Recherchez celles qui ont la portée runtime et voyez s'il y a des conflits potentiels. D'autres outils de construction ont des mécanismes similaires. Par exemple, gradle dependencies dans Gradle et dependencytree dans Ivy. Vous pouvez alors utiliser ces informations pour voir où il peut y avoir des dépendances conflictuelles.
Signature
C'est tout pour l'instant ! J'espère que cet article a été instructif et utile pour vous. L'essentiel à retenir est que les problèmes de dépendance à l'exécution sont le résultat d'un classpath mal configurémal configuré, ce qui signifie qu'il manque quelque chose ou que la mauvaise version est utilisée. Comprendre la portée de vos dépendances et les conflits potentiels entre les dépendances transitives vous aidera à aller au fond des choses.
Si vous avez des commentaires ou des suggestions, n'hésitez pas à nous contacter sur X, anciennement connu sous le nom de Twitter ou à notre Slack communautaire. Si vous avez apprécié cet article, n'hésitez pas à consulter mes autres articles sur Java.
Partager:
Sina est développeur Java chez Vonage. Il est issu d'une formation universitaire et est généralement curieux de tout ce qui touche aux voitures, aux ordinateurs, à la programmation, à la technologie et à la nature humaine. Pendant son temps libre, on peut le trouver en train de marcher ou de jouer à des jeux vidéo compétitifs.