1. Zależności: nie lista, ale drzewo
Jeśli wydawało ci się, że jedna linia z biblioteką oznacza dokładnie jedną bibliotekę w projekcie, zaraz cię czeka małe, ale pożyteczne rozczarowanie. W ekosystemie Javy biblioteki prawie nigdy nie żyją w samotności – jak dobry serial, gdzie każdy bohater ma spin-off i znajomych z sąsiedniego uniwersum. Dlatego wszystko działa tak: dodajesz jedną linię do dependencies, a Gradle dociąga kolejne JAR-y. To normalna praca dependency management, a nie „dodatkowe śmieci”.
Najważniejsze słowo dzisiejszego wykładu to przechodniość. Mówiąc zupełnie prosto, zależność przechodnia – to zależność zależności. Czyli biblioteka, którą wybrałeś, sama mówi: „Żebym mogła działać, potrzebuję jeszcze tych kolesi”. Gradle to czyta, buduje graf (drzewo) i w efekcie składa classpath.
Projekt już potrafi się budować i uruchamiać, ale to za mało. Dopóki nie widzisz, jakie biblioteki naprawdę trafiły do classpath i według jakich zasad, baseline pozostaje kruchy: coś działa, ale wytłumaczyć dlaczego – trudno. Dlatego teraz zajmujemy się nie tylko uruchamianiem, lecz także pochodzeniem zależności.
Na początek weźmy maksymalnie zrozumiały przykład. W build.gradle.kts możesz napisać tak:
dependencies {
// Zależność bezpośrednia: jawnie prosisz Gradle o dodanie Logback do projektu.
// Dalej Gradle sam dociągnie to, co Logback zadeklarował jako swoje zależności.
implementation("ch.qos.logback:logback-classic:1.5.32")
}
Z wyglądu to jedna zależność. Ale w rzeczywistości logback-classic nie żyje samotnie: potrzebuje logback-core, a do pracy przez standardowe logging API – slf4j-api. Dlatego końcowy zestaw bibliotek „wewnątrz projektu” będzie szerszy niż jedna linia. Właśnie dlatego dzisiaj uczymy się myśleć nie listą, ale drzewem.
2. Zależność bezpośrednia: twoje zgłoszenie do Gradle
Kiedy piszesz zależność w build.gradle.kts, składasz całkiem formalne zamówienie: „Mój projekt potrzebuje tego konkretnego modułu w tej konkretnej wersji”. To właśnie jest zależność bezpośrednia – ta, którą dodałeś ręcznie. Widać ją w repozytorium i w historii Git, a odpowiedzialność za nią ponosisz ty, a nie Gradle ani IDE. Dlatego zależności bezpośrednie to dane wejściowe dla dependency management.
W naszym ReadLater Starter zależności bezpośrednie mogą wyglądać mniej więcej tak (w uproszczeniu, żeby teraz nie kłócić się o estetykę, a mówić o istocie):
dependencies {
// Potrzebny wprost twojemu kodowi: importujesz org.slf4j.* i kompilujesz się z nim
implementation("org.slf4j:slf4j-api:2.0.17")
// Potrzebny tylko przy uruchomieniu: to implementacja logowania (SLF4J binding)
runtimeOnly("ch.qos.logback:logback-classic:1.5.32")
// Przykład kolejnej zależności bezpośredniej: serializacja/deserializacja JSON
implementation("tools.jackson.core:jackson-databind:3.0.4")
}
Tu ważne jest, żeby uchwycić jedną praktyczną myśl. Zależność bezpośrednia – to niekoniecznie „wszystko, co znajdzie się w projekcie”. Raczej jest to lista tego, o co poprosiłeś i w jakiej konfiguracji. Dalej Gradle zaczyna „rozpakowywać” tę prośbę: patrzy w metadane każdej biblioteki i ustala, jakie dodatkowe artefakty są jej potrzebne.
I jeszcze jeden moment, który początkujący zaskakująco często mylą. Zależności bezpośrednie – to nie pluginy. Plugin dodaje się w bloku plugins, a zależność – w bloku dependencies. Jeśli kiedykolwiek próbowałeś „podłączyć Logback” przez plugins { ... }, gratulacje: nie jesteś w tym sam. To bardzo popularny quest dla początkujących. Ale sens jest inny: plugin zmienia budowanie, a zależność trafia do classpath aplikacji.
3. Zależność przechodnia: żądania bibliotek
Wyobraźmy sobie teraz, że każda biblioteka to mały projekt ze swoimi zadaniami. Autorzy Logback nie chcą kopiować do wnętrza Logback całego SLF4J – a po co. Autorzy Jackson też nie chcą ciągnąć do jednego JAR-a wszystkich możliwych części świata JSON. Dlatego biblioteka publikowana jest razem z metadanymi, gdzie napisane jest: „Żeby mnie używać, dociągnij jeszcze to”. Gradle potrafi czytać te metadane i budować graf zależności.
Czyli mamy dwa poziomy:
Zależność bezpośrednia: to, co jawnie wskazałeś w dependencies.
Zależność przechodnia: to, co przyjechało „w pakiecie”, bo jest potrzebne twoim zależnościom bezpośrednim.
Wróćmy do Logback. Jeśli narysować to „w stylu Gradle”, wyjdzie mniej więcej taki obrazek (w raportach zależności wygląda bardzo podobnie):
# Górna linia – to, co zadeklarowałeś jawnie (zależność bezpośrednia).
# Wszystko poniżej – to, co Gradle dociągnął na podstawie metadanych (zależności przechodnie).
ch.qos.logback:logback-classic:1.5.32
+--- ch.qos.logback:logback-core:1.5.32
\--- org.slf4j:slf4j-api:2.0.17
Górna linia – to, co dodałeś bezpośrednio. Wszystko, co poniżej, z wcięciami i gałęziami, to zależności przechodnie. I zwróć uwagę na psychologicznie ważny szczegół: jeśli nagle widzisz w projekcie slf4j-api, to najczęściej nie jest błąd ani „IDE dociągnęło coś niepotrzebnego”, tylko normalna przechodniość.
Żeby ta idea stała się zupełnie namacalna, pokażmy ją jeszcze w formie schematu. Logika jest prosta: aplikacja zależy od logback-classic bezpośrednio, a logback-classic już zależy od innych modułów przechodnio.
flowchart LR
%% Przykład drzewa: strzałka od aplikacji – zależność bezpośrednia, poniżej – przechodnie
App["ReadLater Starter"] -->|"zależność bezpośrednia"| Classic["logback-classic"]
Classic -->|"przechodnio"| Core["logback-core"]
Classic -->|"przechodnio"| Slf4j["slf4j-api"]
Ten schemat – to główny sens wykładu na jednym obrazku. I tak, jeśli teraz myślisz: „Brzmi logicznie, ale dlaczego to dla mnie ważne?” – to normalne pytanie. Odpowiedź jest prosta: w runtime będziesz miał nie „jedną bibliotekę”, ale całą „drużynę”, i czasami w tej drużynie okazuje się ktoś niespodziewany. Stąd kolejny praktyczny krok: trzeba umieć patrzeć na drzewo zależności w wyjściu Gradle, a nie zgadywać z pamięci.
4. Czytanie drzewa zależności
Kiedy po raz pierwszy widzisz wyjście dependencies, łatwo pojawia się uczucie: „Otworzyłem drzwiczki pralki, a stamtąd wypadło 120 skarpetek i żadna nie jest moja”. To normalne: drzewo zależności wygląda głośno. Ale czytać je można całkiem spokojnie, jeśli pamiętasz kilka zasad. Dzisiaj właśnie kładziemy ten nawyk – bez próby nurkowania od razu w „dependency hell”.
Najwygodniejszy sposób czytania – iść z góry na dół i cały czas zadawać sobie jedno pytanie: „kto kogo przyprowadził?”. Górne węzły to to, co zadeklarowałeś bezpośrednio (lub to, co zadeklarowały pluginy i konfiguracje, ale tego na razie nie ruszamy). Wszystko, co zagnieżdżone pod węzłem – to jego „pasażerowie”. Wcięcia pokazują poziom zagnieżdżenia, czyli głębokość „znajomych znajomych”.
Jeszcze jedna ważna idea: drzewo zależy od konfiguracji. Znamy już słowa implementation i runtimeOnly. W kontekście przechodniości oznacza to: jeśli zależność jest zadeklarowana jako runtimeOnly, jej zależności przechodnie w pierwszej kolejności trafiają do runtime classpath. A jeśli zależność jest zadeklarowana jako implementation, to ona i jej zależności przechodnie trafiają do compile classpath. Warto o tym pamiętać, żeby się nie dziwić, dlaczego „w runtime jest jedno, a kompilacja widzi co innego”.
Wygodnie porównać zależności bezpośrednie i przechodnie w małej tabelce – nie dla formalności, ale żeby mózg przestał się plątać:
| Co porównujemy | Zależność bezpośrednia | Zależność przechodnia |
|---|---|---|
| Kto „zamówił” | Ty, w dependencies { ... } | Inna biblioteka |
| Gdzie zapisana | W twoim build.gradle.kts | W metadanych modułu (POM / Gradle Module Metadata itp.) |
| Po co potrzebna | Twojemu kodowi lub twojemu runtime | Żeby działała biblioteka, którą podłączyłeś |
| Dlaczego „nagle się pojawiła” | Bo ją dodałeś | Bo bez niej twoja biblioteka się nie uruchamia / nie kompiluje |
I jeszcze jedno spokojne ostrzeżenie: czasem ta sama biblioteka pojawia się w drzewie wielokrotnie przez różne ścieżki. To znaczy, że różne zależności „chcą” tego samego. Dobra wiadomość – można oszczędzić miejsce!
Zła wiadomość – wersje mogą się różnić. Którą wersję wybrać i jak rozwiązywać konflikty, to osobny temat i dzisiaj świadomie tam nie wchodzimy. Nasz cel jest skromniejszy: nauczyć się widzieć pochodzenie bibliotek, a nie od razu dyrygować całą tą orkiestrą.
5. Mini-eksperyment: przechodniość i konfiguracje
Teraz przeprowadzimy mały eksperyment na zrozumienie. Dobrze wiąże dzisiejszy temat z tym, co już wiemy o konfiguracjach zależności. Sens nie polega na tym, żeby zepsuć projekt (chociaż to też tradycja), ale na tym, żeby zobaczyć: zależności przechodnie nie przychodzą do próżni, ale do konkretnych classpath-ów – compile i runtime.
Załóżmy, że mamy taki kawałek build.gradle.kts (idea jest ważniejsza niż dokładna zawartość):
dependencies {
// Bezpośrednia zależność compile-time: bez niej nie skompiluje się kod, który importuje org.slf4j.*
implementation("org.slf4j:slf4j-api:2.0.17")
// Zależność tylko do uruchomienia: implementacja logowania potrzebna w runtime, ale nieobowiązkowa do kompilacji
runtimeOnly("ch.qos.logback:logback-classic:1.5.32")
}
I taki minimalny kod, który korzysta z SLF4J API. Żyje w naszej aplikacji (np. w ReadLaterApplication) i kompiluje się jak zwykła klasa Javy:
package com.example.readlater;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ReadLaterApplication {
// Logger bierzemy przez fasadę SLF4J: na etapie kompilacji potrzebny jest slf4j-api
private static final Logger log = LoggerFactory.getLogger(ReadLaterApplication.class);
public static void main(String[] args) {
// W runtime konkretną implementację/binding (np. Logback) podchwyci classpath
log.info("ReadLater Starter is starting..."); // (przykładowe wyjście: INFO ... ReadLater Starter is starting...)
}
}
Teraz ważny moment. Jeśli usuniesz bezpośrednią zależność slf4j-api (czyli wykasujesz linię implementation("org.slf4j:slf4j-api:...")), wielu spodziewa się: „Nic strasznego, przecież Logback i tak dociągnie go przechodnio”. I właśnie tu objawia się związek dzisiejszego tematu z runtimeOnly.
logback-classic rzeczywiście jest powiązany ze SLF4J, a slf4j-api często przyjeżdża jako zależność przechodnia. Ale ponieważ logback-classic masz zadeklarowany jako runtimeOnly, wszystko, co „przychodzi razem z nim”, trafia przede wszystkim do runtime classpath, a nie do compile classpath. Rezultat prosty i trochę irytujący: kod przestaje się kompilować, ponieważ kompilator nie widzi org.slf4j.LoggerFactory.
Czyli w praktyce dostajesz błąd typu „package org.slf4j does not exist” albo „cannot find symbol LoggerFactory”. I to nie magia, ale uczciwa mechanika: zależność bezpośrednia kontroluje to, gdzie i na jakim etapie biblioteka jest dostępna, a przechodniość „podporządkowuje się” tej konfiguracji.
Morał tego eksperymentu jest całkiem praktyczny. Nawet jeśli biblioteka może przyjechać przechodnio, czasem i tak deklaruje się ją jawnie: na przykład dlatego, że używasz jej w kodzie na etapie kompilacji albo chcesz jawnie zafiksować wersję. Ale temat „jak fiksować wersje zależności przechodnich i co robić przy konfliktach” – to już następny poziom trudności, i tam jeszcze nie skaczemy.
Na tym etapie wystarczy pewnie wypowiadać frazę: „Mam zależności bezpośrednie i ciągną one zależności przechodnie; końcowy zestaw bibliotek to drzewo”. Dalej potrzebna jest już czysto praktyczna umiejętność: oglądać to drzewo poleceniem Gradle, a nie zgadywać.
6. Typowe błędy przy pracy z zależnościami przechodnimi
Teraz, kiedy idea „drzewa” już jest, warto spokojnie przegadać typowe grabie. Zwykle nie wiążą się one ze skomplikowanymi przypadkami, ale z błędnym modelem myślowym: człowiek szczerze sądzi, że rozumie zależności, a potem przez dwie godziny kłóci się z rzeczywistością, Gradle i kosmosem. Te błędy łatwo wyłapać z wyprzedzeniem, jeśli wiesz, gdzie patrzeć i co uznać za normę.
Błąd nr 1: sądzić, że w projekcie jest tylko to, co napisane w dependencies.
Takie myślenie sprawia, że każdy raport zależności wygląda jak „Gradle sam coś sobie pobrał”. W rzeczywistości prawie zawsze widzisz tylko wierzchołek góry lodowej: zależność bezpośrednia – to twoja prośba, a zależności przechodnie – komponenty, bez których biblioteka nie działa.
Błąd nr 2: bać się „nadmiarowych” bibliotek i od razu szukać winnego IDE.
IDE rzeczywiście może wiele podpowiadać, ale zależności zwykle nie wymyśla. Jeśli do projektu przyjechał jakiś JAR, to najczęściej dlatego, że inny JAR tak zadeklarował w metadanych. Gradle czyta te metadane (POM, opisy modułów itd.) i właśnie dlatego drzewo rośnie.
Błąd nr 3: nie wiązać przechodniości z konfiguracjami (implementation vs runtimeOnly).
Czasem wydaje się, że „jeśli biblioteka jest w projekcie, to znaczy, że jest dostępna wszędzie”. Ale Gradle nie ma takiego rozmytego stanu: jest compile classpath i runtime classpath, a to, gdzie trafią zależności przechodnie, określa to, jak zadeklarowana jest zależność bezpośrednia. Właśnie dlatego można przypadkowo usunąć „jawny” slf4j-api i dostać błąd kompilacji, choć „przecież gdzieś tam powinien być”.
Błąd nr 4: próbować leczyć niezrozumiałe drzewo zależności dodawaniem jeszcze większej liczby zależności „na chybił trafił”.
Bardzo chce się działać jak w memach: „nie działa – dodaj jeszcze jedną linię”. Problem w tym, że tak drzewo staje się jeszcze grubsze, a przyczyna – jeszcze mniej widoczna. Znacznie pożyteczniej najpierw zrozumieć, kto kogo przyprowadził, a dopiero potem podejmować decyzję. Dzisiaj uczymy się właśnie tego: czytać pochodzenie bibliotek, a nie grać w loterię z zależnościami.
Błąd nr 5: sądzić, że zależności przechodnie to „niepotrzebne śmieci”, które można po prostu ignorować.
Czasami zależności przechodnie rzeczywiście nie są używane bezpośrednio przez twój kod, ale są używane przez biblioteki, które podłączyłeś. Jeśli bezmyślnie uznać je za „nadmiarowe”, łatwo dojść do stanu, kiedy projekt się kompiluje, ale w czasie wykonania pada z dziwnym błędem ClassNotFoundException. Na poziomie naszego aktualnego tematu wystarczy pamiętać: przechodniość – to nie przypadek, ale sposób, w jaki biblioteki uczciwie zgłaszają swoje wymagania.
GO TO FULL VERSION