https://a.storyblok.com/f/270183/1368x665/2591bd895a/one-simple-trick_24.png

Ein einfacher Trick zur Lösung von Java-Laufzeitabhängigkeitsproblemen

Zuletzt aktualisiert am October 17, 2023

Lesedauer: 8 Minuten

Einführung

Wenn Sie mit einer beliebigen JVM-basierten Sprachewie Java, Kotlin, Scala, Groovy, Clojure usw. arbeiten, werden Sie höchstwahrscheinlich schon mit Build- und Abhängigkeitsmanagement-Tools wie Ant / Ivy, Maven, sbt, Leinengen oder Gradle.

Grundsätzlich besteht der Zweck dieser Tools darin, den Build-Prozess (und manchmal sogar den Release-Prozess) Ihrer Anwendung zu automatisieren. Sie kümmern sich um die Kompilierung Ihres Codes, die Durchführung von Tests, die Erstellung von Dokumentationsseiten (Javadocs) und die Produktion verteilbarer Artefakte wie JAR-Dateien.

Zu den wichtigsten Aufgaben und Vorteilen eines Build-Automatisierungstools gehört seine Fähigkeit, Abhängigkeiten für Sie deklarativ zu verwalten. Für kleine Projekte ist es möglich, überhaupt kein Build-System zu verwenden und stattdessen ein einfaches Shell-Skript zu verwenden, das javac und jar verwendet, um die Anwendung zu kompilieren und zu verpacken.

Dies wird mühsam und weniger wartungsfreundlich, wenn Sie anfangen, Abhängigkeiten von Drittanbietern zu Ihrem Projekt hinzuzufügen. Die neueste Version der JAR-Datei für die Abhängigkeit zu finden, sie als Teil des Build-Prozesses zu verwenden und sie sowohl bei der Kompilierung als auch zur Laufzeit in den Klassenpfad aufzunehmen und gleichzeitig darauf zu achten, dass keine unnötigen Binärdateien in Ihrem Versionskontrollsystem gespeichert werden, ist eine Menge Arbeit, die manuell erledigt werden muss.

Obwohl die wichtigsten Build-Tools innerhalb des Java-Ökosystems eine deklarative Möglichkeit zur Angabe von Abhängigkeiten bieten und den Prozess für Sie übernehmen, sind sie nicht unfehlbar. Gelegentlich kann es zu Problemen kommen, die durch die Einbeziehung von Abhängigkeiten verursacht werden, die oberflächlich betrachtet keinen Sinn ergeben und für die es keine offensichtliche Ursache oder Lösung gibt.

In diesem Artikel soll ein problematisches Szenario der Abhängigkeitsverwaltung untersucht werden, warum es auftritt und wie es gelöst werden kann.

Beispielprojekt

Zur Veranschaulichung werde ich in diesem Artikel ein Java-Projekt mit Maven als minimales Arbeitsbeispiel verwenden. Die Prinzipien sind jedoch auch auf Gradle und andere JVM-Sprachen anwendbar. Ohne weitere Umschweife, lassen Sie uns gleich loslegen!

Hier sind die anfänglichen Inhalte des <project> Elements in 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>

Wie Sie sehen können, deklarieren wir eine Abhängigkeit - das Vonage Java SDK. Hier ist unsere Hauptklasse, die die Account API verwendet, um unseren Kontostand zu erhalten, und die Messages API, um uns diesen Kontostand zu senden.

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());
    }
}

Wenn Sie es von Ihrer IDE aus ausführen, funktioniert dies problemlos. Lassen Sie uns nun eine lauffähige JAR-Datei mit allen Abhängigkeiten erstellen, damit wir eine eigenständige ausführbare Datei haben. Wir können dies mit dem 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>

Wenn wir mvn clean installausführen, erhalten wir genau das: ein ausführbares, eigenständiges JAR (dieses befindet sich in target/minimal-1.0-SNAPSHOT.jar). Und es funktioniert! Haben Sie jedoch die Warnungen bemerkt, die beim Ausführen des mvn Befehl ausführten? Am Ende werden Sie etwas wie dieses sehen:

"[WARNUNG] maven-shade-plugin hat festgestellt, dass einige Dateien in zwei oder mehr JARs vorhanden sind. Wenn dies der Fall ist, wird nur eine einzige Version der Datei in das uber jar kopiert. Normalerweise ist dies nicht schädlich und Sie können diese Warnungen überspringen, andernfalls versuchen Sie, Artefakte manuell auszuschließen, basierend auf mvn dependency:tree -Ddetail=true und der obigen Ausgabe."

Was will uns diese Warnung sagen? Nun, das Java SDK selbst hat Abhängigkeiten - Sie können sie im Abschnitt "Runtime Dependencies" auf mvnrepository.com. Diese Abhängigkeiten haben wiederum ihre eigenen Abhängigkeiten und so weiter.

Da wir eine einzige JAR-Datei erstellen, die diese Abhängigkeitshierarchie "abflacht", haben wir am Ende eine Menge Klassendateien. Einige dieser Abhängigkeiten haben jedoch Abhängigkeiten zu demselben Artefakt. Dieser Prozess des rekursiven Entpackens jeder JAR-Datei, so dass nur noch Klassen übrig bleiben, bedeutet, dass bei mehreren Versionen der gleichen Abhängigkeit nur eine der Klassen erhalten bleibt. Schließlich ist es nicht möglich, zwei Dateien mit demselben Namen im selben Verzeichnis zu haben.

Zum Beispiel hat "jackson-datatype-jsr310" eine Abhängigkeit von "jackson-core", von dem wir auch durch "jackson-databind" abhängig sind. Irgendwo in der Kette gibt es auch Abhängigkeiten vom SLF4J Logging Framework, welches von vielen populären Bibliotheken verwendet wird. Maven hebt alle Klassen hervor, in denen es bei der Verflachung der Abhängigkeitshierarchie Duplikate gibt.

Wo Probleme auftauchen

Wie die Warnung besagt, stellen diese normalerweise kein Problem dar. Wäre dies der Fall, wäre es sehr schwierig, wenn nicht gar unmöglich, die Erstellung selbst einfachster Projekte mit Abhängigkeiten, wie dem in diesem Artikel, zu automatisieren.

Die Strategie für die Auswahl der zu verwendenden Version der Klasse (d.h. die Auswahl der Abhängigkeit) hängt von der Reihenfolge des Ladens der Klasse ab und geht über den Rahmen dieses Artikels hinaus, aber der Punkt ist, dass es letztendlich immer nur eine Instanz einer voll qualifizierten Klasse in einer bestimmten Laufzeitinstanz einer Java-Anwendung geben kann. Wenn mehrere Versionen benötigt werden, hat das Shade-Plugin eine konfigurierbare Verschiebungsstrategie damit sie nebeneinander existieren können.

In Anbetracht dessen: Wann ist das problematisch? Wenn standardmäßig alles funktioniert, wann ist es dann problematisch? Um das zu verdeutlichen, kehren wir zu unserem Beispiel zurück.

Nun fügen wir die folgende Abhängigkeit in den <dependencies> Abschnitt unserer pom.xml:

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.0-alpha1</version>
</dependency>

Wenn Sie das Programm mit mvn clean install neu erstellen und dann versuchen, das Programm mit der JAR-Datei auszuführen, erhalten Sie die folgende Fehlermeldung:

Ausnahme im Thread "main" java.lang.NoClassDefFoundError: org/apache/http/conn/HttpClientConnectionManager

Das liegt daran, dass Sie jetzt eine der Kernabhängigkeiten des SDK mit einer viel älteren Version überschreiben, die eine Klasse, von der wir abhängig sind, nicht enthält. Das Gleiche gilt, wenn Sie es von der IDE aus erneut ausführen. Sie könnten stattdessen eine andere Abhängigkeit ausprobieren. Zum Beispiel:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.0.0</version>
</dependency>

Dies führt zu folgendem Fehler:

Ausnahme im Thread "main" java.lang.NoClassDefFoundError: com/fasterxml/jackson/core/util/JacksonFeature

Obwohl das Vonage Java SDK keine direkte Abhängigkeit von "jackson-core" hat, gibt es dennoch ein Problem, weil wir die Version der Bibliothek (und damit die Klassendateien) überschreiben, die in unserer Anwendung enthalten sind.

Laufzeit vs. Kompilieren

Sie fragen sich vielleicht, wie es sein kann, dass unser Programm trotz dieser fehlerhaften Abhängigkeit problemlos kompiliert werden konnte, wir aber erst bei der Ausführung auf das Problem gestoßen sind. Das liegt an der Art und Weise, wie Abhängigkeiten zur Kompilierzeit im Vergleich zur Laufzeit aufgelöst werden.

Zur Kompilierzeit haben wir bereits alle Abhängigkeiten, die wir benötigen. Schließlich wurde das Java-SDK von Vonage bereits erstellt und hat sich bewährt. Wir müssen die Klassen also nicht neu kompilieren, sondern laden sie aus einem vorhandenen JAR. Zur Laufzeit ersetzen wir jedoch die bei der Ausführung verwendeten Klassen.

Die Klassendateien wurden bereits während der Kompilierungsphase erzeugt, aber zur Laufzeit können unterschiedliche Versionen dieser Klassen im Klassenpfad vorhanden sein.

Dies ist der Schlüssel zum Verständnis von Problemen mit Abhängigkeiten zur Laufzeit: Sie sind fast immer darauf zurückzuführen, dass der Klassenpfad falsch konfiguriert ist. Build-Systeme wie Maven und Gradle verfügen über sinnvolle Standardeinstellungen und Konfigurationsoptionen, die dabei helfen, aber ein falscher Gebrauch, wie oben gezeigt, kann zu Fehlern führen. Es lohnt sich, die verschiedenen Gültigkeitsbereiche für Abhängigkeitsdeklarationen zu verstehen.

Standardmäßig ist eine Abhängigkeit in Maven so skaliert, dass sie sowohl zur Laufzeit als auch zur Kompilierzeit enthalten ist - genannt compile. Sie können mehr über Scopes in der der offiziellen Dokumentation. In ähnlicher Weise ist in Gradle der Standardbereich implementation (im Gegensatz zu, z.B.. compileOnly).

Obwohl die Terminologie unterschiedlich ist, sind Abhängigkeitsscoping und -propagation wichtige Konzepte, die man verstehen muss, wenn man sich auf ein Build-System verlässt, um seine Abhängigkeiten und letztendlich den Klassenpfad seiner Anwendung zu verwalten.

Die einfache Lösung

Der Titel dieses Artikels versprach eine schnelle Lösung für Probleme mit Abhängigkeiten. In Wirklichkeit hat das Problem zwar letztlich mit einem falsch konfigurierten Klassenpfad zu tun, aber die Lösung hängt weitgehend von der Komplexität Ihres Projekts und seinen Abhängigkeiten ab.

Der mvn dependency:tree Befehl (und ähnliche in anderen Build-Tools) kann Ihnen dabei helfen, verschachtelte und potenziell konfliktträchtige Abhängigkeiten zu identifizieren, aber letztendlich liegt es an Ihnen, sicherzustellen, dass Sie die von anderen Bibliotheken, von denen Sie abhängig sind, verwendeten Abhängigkeiten nicht überschreiben, da dies zu Konflikten führen kann.

Manchmal können die Wechselwirkungen zwischen bestimmten Abhängigkeiten zu Problemen führen. Daher sollten Sie zunächst herausfinden, welche Abhängigkeiten sie gemeinsam haben und ob es eine Versionsdiskrepanz gibt. Gibt es eine Version, die Sie verwenden können und die mit all Ihren anderen Abhängigkeiten kompatibel ist? Wenn ja, deklarieren Sie sie explizit.

Der beste Weg, Konflikte von vornherein zu minimieren, ist jedoch, nur solche Abhängigkeiten zu deklarieren, die Sie direkt verwenden. Es ist in der Regel die beste Praxis, die Anzahl der deklarierten Abhängigkeiten in Ihrem Projekt zu minimieren und nur das zu verwenden, was Sie benötigen.

Je mehr Abhängigkeiten Sie zu Ihrem Projekt hinzufügen, desto umfangreicher wird Ihr endgültiges Artefakt und desto mehr potenzielle Probleme können durch Abhängigkeitskonflikte entstehen. Ganz zu schweigen davon, dass Abhängigkeiten die Sicherheit Ihrer Anwendung verringern können, häufig durch verschachtelte Abhängigkeiten.

Nehmen wir zum Beispiel an, Sie verlassen sich auf eine Bibliothek (Bibliothek A), die indirekt von einer älteren Version einer anderen Bibliothek (Bibliothek B) mit einer Sicherheitslücke abhängt. Wenn die Betreuer von Bibliothek A nicht auf die neueste Version von B aktualisieren, könnte sich diese Schwachstelle auch auf Ihre Anwendung auswirken. Sie können versuchen, dieses Problem zu lösen, indem Sie direkt die neueste Version von Bibliothek B in Ihrer pom.xml oder build.gradle. Dieser Ansatz garantiert jedoch keine Kompatibilität und verlagert die Verantwortung von den Betreuern der Bibliothek A auf Sie.

Die meiste Zeit über kennen Sie als Entwickler weder alle Klassen, die über transitive Abhängigkeiten in Ihre Anwendung eingebunden sind, noch kümmern Sie sich um diese. Das heißt, bis etwas kaputt geht.

Dann müssen Sie den Klassenpfad untersuchen. Einige Werkzeuge können dabei helfen. Zum Beispiel kann Maven ein "effektives pom" bereitstellen, indem es das gleichnamige Plugin. Dadurch erhalten Sie ein klareres Bild davon, welche Einstellungen in Ihrem Build verwendet werden (nützlich für komplexe Projekte).

Am nützlichsten ist es aber vielleicht, den Abhängigkeitsgraph zu verstehen. Hier gibt der mvn dependency:tree Befehl ein klares Bild aller Bibliotheken und ihres Umfangs. Suchen Sie nach den Bibliotheken mit dem runtime Geltungsbereich und sehen Sie, ob es irgendwelche potentiellen Konflikte gibt. Andere Build-Tools haben ähnliche Mechanismen. Zum Beispiel, gradle dependencies in Gradle und dependencytree in Ivy. Sie können diese Informationen verwenden, um zu sehen, wo es möglicherweise widersprüchliche Abhängigkeiten gibt.

Abmeldung

Das war's für heute! Ich hoffe, dieser Artikel war informativ und nützlich für Sie. Die wichtigste Erkenntnis ist, dass Abhängigkeitsprobleme zur Laufzeit sind das Ergebnis eines falsch konfigurierten Klassenpfadssind, was bedeutet, dass entweder etwas fehlt oder die falsche Version davon verwendet wird. Ein Verständnis des Umfangs Ihrer Abhängigkeiten und potenzieller Konflikte zwischen transitiven Abhängigkeiten wird Ihnen helfen, der Sache auf den Grund zu gehen.

Wenn Sie Kommentare oder Vorschläge haben, können Sie sich gerne an uns wenden auf X, früher bekannt als Twitter oder besuchen Sie unseren Gemeinschaft Slack. Wenn Ihnen dieser Artikel gefallen hat, lesen Sie bitte auch meine anderen Java-Artikel.

Teilen Sie:

https://a.storyblok.com/f/270183/400x400/46a3751f47/sina-madani.png
Sina MadaniVonage Ehemaliges Teammitglied

Sina ist Java Developer Advocate bei Vonage. Er hat einen akademischen Hintergrund und ist generell neugierig auf alles, was mit Autos, Computern, Programmierung, Technologie und der menschlichen Natur zu tun hat. In seiner Freizeit geht er gerne spazieren oder spielt Videospiele.