CodeGym /Kurse /Java Server /Direkte und transitive Abhängigkeiten

Direkte und transitive Abhängigkeiten

Java Server
Level 5, Lektion 0
Verfügbar

1. Abhängigkeiten: keine Liste, sondern ein Baum

Wenn man dachte, eine Zeile mit einer Bibliothek bedeutet genau eine Bibliothek im Projekt, kommt jetzt eine kleine, aber nützliche Enttäuschung. Im Java-Ökosystem leben Bibliotheken fast nie allein — wie eine gute Serie, wo jede Figur einen Spin-off und Freunde aus dem Nebenuniversum hat. Deshalb ist alles so aufgebaut: Man fügt eine Zeile in dependencies hinzu, und Gradle zieht noch einige JARs nach. Das ist normales Dependency Management, kein „unnötiger Ballast".

Das wichtigste Wort der heutigen Lektion ist Transitivität. Ganz einfach ausgedrückt: Eine transitive Abhängigkeit ist die Abhängigkeit einer Abhängigkeit. Das heißt, die von dir gewählte Bibliothek sagt selbst: „Damit ich funktioniere, brauche ich noch diese hier." Gradle liest das, baut einen Graphen (Baum) und stellt am Ende den Classpath zusammen.

Das Projekt kann bereits gebaut und gestartet werden, aber das reicht noch nicht. Solange man nicht sieht, welche Bibliotheken wirklich im Classpath landen und nach welchen Regeln, bleibt die Basis fragil: Irgendetwas funktioniert, aber warum — schwer zu erklären. Deshalb beschäftigen wir uns jetzt nicht nur mit dem Start, sondern auch mit der Herkunft der Abhängigkeiten.

Beginnen wir mit dem verständlichsten Beispiel. In build.gradle.kts kann man so schreiben:

dependencies {
    // Direkte Abhängigkeit: du bittest Gradle explizit, Logback zum Projekt hinzuzufügen.
    // Gradle zieht dann selbst das, was Logback als seine Abhängigkeiten deklariert hat.
    implementation("ch.qos.logback:logback-classic:1.5.32")
}

Auf den ersten Blick ist das eine Abhängigkeit. Aber logback-classic lebt kein Einzelleben: Es braucht logback-core, und für die Arbeit über die Standard-Logging-API noch slf4j-api. Deshalb ist der tatsächliche Satz an Bibliotheken „im Projekt" breiter als eine Zeile. Genau deshalb lernen wir heute, nicht in Listen, sondern in Bäumen zu denken.

2. Direkte Abhängigkeit: deine Anfrage an Gradle

Wenn du eine Abhängigkeit in build.gradle.kts schreibst, stellst du eine ganz formelle Anfrage: „Mein Projekt braucht dieses Modul in dieser Version." Das ist die direkte Abhängigkeit — die, die du manuell hinzugefügt hast. Sie ist im Repository und in der Git-Historie sichtbar, und die Verantwortung dafür trägst du, nicht Gradle und nicht die IDE. Deshalb sind direkte Abhängigkeiten die Eingabedaten für das Dependency Management.

In unserem ReadLater Starter können direkte Abhängigkeiten ungefähr so aussehen (vereinfacht, damit wir jetzt nicht über Schönheit diskutieren, sondern über den Kern reden):

dependencies {
    // Wird direkt von deinem Code benötigt: du importierst org.slf4j.* und kompilierst damit
    implementation("org.slf4j:slf4j-api:2.0.17")

    // Nur zur Laufzeit benötigt: das ist die Logging-Implementierung (SLF4J-Binding)
    runtimeOnly("ch.qos.logback:logback-classic:1.5.32")

    // Beispiel einer weiteren direkten Abhängigkeit: JSON-Serialisierung/Deserialisierung
    implementation("tools.jackson.core:jackson-databind:3.0.4")
}

Hier ist ein praktischer Gedanke festzuhalten. Eine direkte Abhängigkeit ist nicht unbedingt „alles, was im Projekt landet". Es ist eher die Liste dessen, was du angefragt hast und in welcher Konfiguration. Danach beginnt Gradle, diese Anfrage zu „entfalten": Es liest die Metadaten jeder Bibliothek und findet heraus, welche zusätzlichen Artefakte die Bibliothek selbst benötigt.

Und noch ein Punkt, den Einsteiger erstaunlich häufig verwechseln. Direkte Abhängigkeiten sind keine Plugins. Ein Plugin wird im Block plugins hinzugefügt, eine Abhängigkeit im Block dependencies. Wenn du je versucht hast, „Logback" über plugins { ... } einzubinden, herzlichen Glückwunsch: Du bist nicht allein. Das ist eine sehr beliebte Quest für Anfänger. Aber die Bedeutung ist eine andere: Ein Plugin ändert den Build, eine Abhängigkeit landet im Classpath der Anwendung.

3. Transitive Abhängigkeit: Anfragen der Bibliotheken

Stellen wir uns jetzt vor, jede Bibliothek ist ein kleines Projekt mit eigenen Aufgaben. Die Autoren von Logback wollen nicht den gesamten SLF4J innerhalb von Logback kopieren — wozu auch. Die Autoren von Jackson wollen auch nicht alle möglichen Teile der JSON-Welt in eine einzige JAR packen. Deshalb wird eine Bibliothek zusammen mit Metadaten veröffentlicht, in denen steht: „Um mich zu verwenden, zieh bitte noch das hier." Gradle kann diese Metadaten lesen und den Abhängigkeitsgraphen aufbauen.

Das heißt, wir haben zwei Ebenen:

Direkte Abhängigkeit: das, was du explizit in dependencies angegeben hast.
Transitive Abhängigkeit: das, was „als Bonus" mitgekommen ist, weil deine direkten Abhängigkeiten es benötigen.

Kehren wir zu Logback zurück. Wenn man das „im Gradle-Stil" zeichnet, ergibt sich ungefähr folgendes Bild (in Abhängigkeitsberichten sieht es sehr ähnlich aus):

# Oberste Zeile — was du explizit deklariert hast (direkte Abhängigkeit).
# Alles darunter — was Gradle anhand von Metadaten mitgezogen hat (transitive Abhängigkeiten).
ch.qos.logback:logback-classic:1.5.32
+--- ch.qos.logback:logback-core:1.5.32
\--- org.slf4j:slf4j-api:2.0.17

Die oberste Zeile ist das, was du direkt hinzugefügt hast. Alles darunter, mit Einrückungen und Ästen, sind transitive Abhängigkeiten. Beachte ein psychologisch wichtiges Detail: Wenn du plötzlich slf4j-api im Projekt siehst, ist das meistens kein Fehler und kein „die IDE hat was Überflüssiges geladen", sondern ganz normale Transitivität.

Damit diese Idee noch greifbarer wird, zeigen wir sie auch als Diagramm. Die Logik ist einfach: Die Anwendung hängt direkt von logback-classic ab, und logback-classic hängt seinerseits transitiv von anderen Modulen ab.

flowchart LR
    %% Beispiel eines Baums: Pfeil von der Anwendung — direkte Abhängigkeit, darunter — transitive
    App["ReadLater Starter"] -->|"direkte Abhängigkeit"| Classic["logback-classic"]
    Classic -->|"transitiv"| Core["logback-core"]
    Classic -->|"transitiv"| Slf4j["slf4j-api"]

Dieses Diagramm ist der Hauptsinn der Lektion in einem Bild. Und ja, wenn du gerade denkst: „Klingt logisch, aber warum ist das wichtig?" — das ist eine ganz normale Frage. Die Antwort ist einfach: Zur Laufzeit hast du nicht „eine Bibliothek", sondern ein ganzes „Team", und manchmal ist jemand Unerwartetes in diesem Team. Daher der nächste praktische Schritt: Man muss den Abhängigkeitsbaum in der Gradle-Ausgabe lesen können, nicht aus dem Gedächtnis raten.

4. Den Abhängigkeitsbaum lesen

Wenn man die Ausgabe von dependencies zum ersten Mal sieht, entsteht leicht das Gefühl: „Ich habe die Waschmaschine geöffnet und 120 Socken rausgefallen, und keiner davon ist meiner." Das ist normal: Der Abhängigkeitsbaum sieht laut aus. Aber man kann ihn ganz ruhig lesen, wenn man ein paar Regeln kennt. Genau diese Gewohnheit legen wir jetzt an — ohne den Versuch, sofort in die „Dependency Hell" einzutauchen.

Die bequemste Lesemethode ist es, von oben nach unten zu gehen und sich ständig eine Frage zu stellen: „Wer hat wen mitgebracht?". Die obersten Knoten sind das, was du direkt deklariert hast (oder was Plugins und Konfigurationen deklariert haben, aber das lassen wir jetzt weg). Alles, was unter einem Knoten eingerückt ist, sind seine „Mitfahrer". Die Einrückungen zeigen die Verschachtelungstiefe, also die Tiefe der „Freunde der Freunde".

Noch eine wichtige Idee: Der Baum hängt von der Konfiguration ab. Wir kennen bereits die Wörter implementation und runtimeOnly. Im Kontext der Transitivität bedeutet das folgendes: Wenn eine Abhängigkeit als runtimeOnly deklariert ist, landen ihre transitiven Abhängigkeiten in erster Linie im Runtime-Classpath. Wenn eine Abhängigkeit als implementation deklariert ist, landen sie und ihre transitiven Abhängigkeiten im Compile-Classpath. Das ist nützlich zu wissen, um nicht überrascht zu sein, warum „zur Laufzeit ist eines vorhanden, aber die Kompilierung sieht etwas anderes".

Direkte und transitive Abhängigkeiten vergleicht man bequem in einer kleinen Tabelle — nicht der Form halber, sondern damit das Gehirn aufhört, sich zu verwirren:

Was wir vergleichen Direkte Abhängigkeit Transitive Abhängigkeit
Wer sie „bestellt" hat Du, in dependencies { ... } Eine andere Bibliothek
Wo sie steht In deiner build.gradle.kts In den Modul-Metadaten (POM / Gradle Module Metadata usw.)
Wozu sie nötig ist Für deinen Code oder deine Runtime Damit die Bibliothek funktioniert, die du eingebunden hast
Warum sie „plötzlich aufgetaucht ist" Weil du sie hinzugefügt hast Weil deine Bibliothek ohne sie nicht startet/kompiliert

Und noch eine ruhige Warnung: Manchmal taucht dieselbe Bibliothek im Baum mehrfach auf verschiedenen Wegen auf. Das bedeutet, dass verschiedene Abhängigkeiten dasselbe „wollen". Gute Nachricht — man kann Platz sparen!

Schlechte Nachricht — die Versionen können sich unterscheiden. Welche Version zu wählen ist und wie man Konflikte löst, ist ein separates Thema, und dorthin gehen wir heute bewusst nicht. Unser Ziel ist bescheidener: lernen, die Herkunft der Bibliotheken zu sehen, und nicht sofort dieses gesamte Orchester zu dirigieren.

5. Mini-Experiment: Transitivität und Konfigurationen

Jetzt führen wir ein kleines Verständnisexperiment durch. Es verbindet gut das heutige Thema mit dem, was wir bereits über Abhängigkeitskonfigurationen wissen. Der Sinn ist nicht, das Projekt zu zerstören (obwohl das auch Tradition ist), sondern zu sehen: Transitive Abhängigkeiten kommen nicht ins Vakuum, sondern in konkrete Classpaths — compile und runtime.

Angenommen, wir haben diesen Ausschnitt aus build.gradle.kts (die Idee ist wichtiger als der genaue Inhalt):

dependencies {
    // Direkte Compile-Time-Abhängigkeit: ohne sie lässt sich Code nicht kompilieren, der org.slf4j.* importiert
    implementation("org.slf4j:slf4j-api:2.0.17")

    // Nur für den Start benötigt: die Logging-Implementierung ist zur Laufzeit nötig, aber nicht zwingend für die Kompilierung
    runtimeOnly("ch.qos.logback:logback-classic:1.5.32")
}

Und folgender minimaler Code, der die SLF4J-API nutzt. Er lebt in unserer Anwendung (zum Beispiel in ReadLaterApplication) und wird wie eine normale Java-Klasse kompiliert:

package com.example.readlater;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ReadLaterApplication {
    // Logger über das SLF4J-Facade: zur Kompilierzeit wird slf4j-api benötigt
    private static final Logger log = LoggerFactory.getLogger(ReadLaterApplication.class);

    public static void main(String[] args) {
        // Zur Laufzeit greift der Classpath die konkrete Implementierung/Binding (z. B. Logback)
        log.info("ReadLater Starter is starting..."); // (ungefähre Ausgabe: INFO ... ReadLater Starter is starting...)
    }
}

Jetzt der wichtige Moment. Wenn du die direkte Abhängigkeit slf4j-api entfernst (also die Zeile implementation("org.slf4j:slf4j-api:...") löschst), erwarten viele: „Kein Problem, Logback zieht es ohnehin transitiv mit." Und genau hier zeigt sich die Verbindung des heutigen Themas mit runtimeOnly.

logback-classic ist tatsächlich mit SLF4J verbunden, und slf4j-api kommt häufig als transitive Abhängigkeit mit. Aber da logback-classic bei dir als runtimeOnly deklariert ist, landet alles, was „mit ihm kommt", in erster Linie im Runtime-Classpath, nicht im Compile-Classpath. Das Ergebnis ist einfach und ein bisschen ärgerlich: Der Code lässt sich nicht mehr kompilieren, weil der Compiler org.slf4j.LoggerFactory nicht sieht.

In der Praxis bekommst du einen Fehler wie „package org.slf4j does not exist" oder „cannot find symbol LoggerFactory". Das ist keine Magie, sondern ehrliche Mechanik: Die direkte Abhängigkeit steuert, wo und in welcher Phase die Bibliothek verfügbar ist, und die Transitivität „gehorcht" dieser Konfiguration.

Die Moral dieses Experiments ist ganz praxisnah. Auch wenn eine Bibliothek transitiv mitgeliefert werden kann, deklariert man sie manchmal trotzdem explizit: zum Beispiel weil man sie im Code zur Kompilierzeit verwendet oder die Version explizit festlegen möchte. Aber das Thema „Wie fixiert man die Versionen transitiver Abhängigkeiten und was macht man bei Konflikten?" ist ein nächstes Schwierigkeitsniveau, und dorthin springen wir jetzt noch nicht.

Auf dieser Stufe reicht es, den Satz selbstsicher sagen zu können: „Ich habe direkte Abhängigkeiten, und diese führen transitive mit sich; der gesamte Satz an Bibliotheken ist ein Baum." Als nächstes braucht man eine rein praktische Fähigkeit: diesen Baum mit einem Gradle-Befehl zu sehen, statt zu raten.

6. Typische Fehler beim Umgang mit transitiven Abhängigkeiten

Jetzt, da die Idee des „Baums" vorhanden ist, ist es nützlich, ruhig die typischen Stolperfallen zu besprechen. Sie hängen meistens nicht mit komplexen Fällen zusammen, sondern mit einem falschen mentalen Modell: Man denkt wirklich zu verstehen, wie Abhängigkeiten funktionieren, und streitet dann ein paar Stunden lang mit der Realität, Gradle und dem Universum. Diese Fehler lassen sich leicht vermeiden, wenn man weiß, wo man hinschauen und was als normal betrachten soll.

Fehler Nr. 1: Denken, dass im Projekt nur das vorhanden ist, was in dependencies steht.
Diese Denkweise lässt jeden Abhängigkeitsbericht aussehen wie „Gradle hat selbst etwas heruntergeladen". In Wirklichkeit sieht man fast immer nur die Spitze des Eisbergs: Eine direkte Abhängigkeit ist deine Anfrage, und transitive Abhängigkeiten sind Komponenten, ohne die die Bibliothek nicht funktioniert.

Fehler Nr. 2: Sich vor „überflüssigen" Bibliotheken fürchten und sofort die IDE beschuldigen.
Die IDE kann zwar viel vorschlagen, aber Abhängigkeiten erfindet sie normalerweise nicht. Wenn ein JAR ins Projekt gekommen ist, ist es meistens deshalb gekommen, weil ein anderes JAR es in seinen Metadaten so deklariert hat. Gradle liest diese Metadaten (POM, Modulbeschreibungen usw.), und genau deshalb wächst der Baum.

Fehler Nr. 3: Transitivität nicht mit Konfigurationen verbinden (implementation vs. runtimeOnly).
Manchmal hat man das Gefühl: „Wenn eine Bibliothek im Projekt vorhanden ist, ist sie überall zugänglich." Aber Gradle hat keinen solchen unscharfen Zustand: Es gibt Compile-Classpath und Runtime-Classpath, und wohin transitive Abhängigkeiten landen, hängt davon ab, wie die direkte Abhängigkeit deklariert ist. Genau deshalb kann man versehentlich das „explizite" slf4j-api entfernen und einen Kompilierungsfehler bekommen, obwohl „es irgendwo da sein müsste".

Fehler Nr. 4: Einen unverständlichen Abhängigkeitsbaum dadurch „heilen" wollen, dass man noch mehr Abhängigkeiten „auf gut Glück" hinzufügt.
Es ist sehr verlockend, wie in Memes zu handeln: „funktioniert nicht — füg noch eine Zeile hinzu". Das Problem ist, dass der Baum so noch dicker wird und die Ursache noch weniger sichtbar ist. Viel nützlicher ist es, erst zu verstehen, wer wen mitgebracht hat, und dann erst eine Entscheidung zu treffen. Genau das lernen wir heute: die Herkunft der Bibliotheken zu lesen, statt Abhängigkeiten zu verlosen.

Fehler Nr. 5: Denken, dass transitive Abhängigkeiten „unnötiger Ballast" sind, den man einfach ignorieren kann.
Manchmal werden transitive Abhängigkeiten nicht direkt von deinem Code verwendet, aber die Bibliotheken, die du eingebunden hast, nutzen sie. Wenn man sie gedankenlos als „überflüssig" betrachtet, kann man leicht in einen Zustand geraten, wo das Projekt kompiliert, aber zur Laufzeit mit einem seltsamen ClassNotFoundException abstürzt. Auf dem Niveau unseres aktuellen Themas reicht es zu wissen: Transitivität ist kein Zufall, sondern eine Art, wie Bibliotheken ehrlich ihre Anforderungen kommunizieren.

1
Aufgabe
Java Server, Level 5, Lektion 0
Gesperrt
Direkte Abhaengigkeit fuer die Logging-API
Direkte Abhaengigkeit fuer die Logging-API
1
Aufgabe
Java Server, Level 5, Lektion 0
Gesperrt
Transitiver SLF4J über Logback
Transitiver SLF4J über Logback
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION