JavaRush /Курси /Spring Boot /Транзитивні залежності і classpath

Транзитивні залежності і classpath

Spring Boot
Рівень 4, Лекція 2
Відкрита

1. Прямі та транзитивні залежності

Коли ви тільки починаєте, дуже хочеться вірити в просту картину світу: «я додав одну залежність — у проєкт приїхав один jar». Красива казка — приблизно як «я зʼїм один шматочок піци й зупинюся». У реальності залежності поводяться інакше: ви додаєте одну бібліотеку, а вона тягне за собою ще кількох друзів, друзів друзів і навіть пару «корисних дрібниць про запас».

Почнімо з термінів, але без занудства. Direct dependency — пряма залежність — це те, що ви самі вказали в dependencies{...}. Наприклад, ви явно підключили spring-boot-starter-webmvc. Transitive dependency — транзитивна залежність — це те, що приїхало «всередині» прямої залежності, тому що для роботи їй самій потрібні інші бібліотеки.

Щоб не плутатися, зручно тримати в голові коротку таблицю:

Термін Що це означає простими словами Де це видно
Пряма залежність «Я це підключив» build.gradle.kts
Транзитивна залежність «Вона підтягнулася сама» у дереві залежностей
Граф / дерево залежностей «Повна картина того, що реально підтягнулося» ./gradlew dependencies

Важливо не демонізувати транзитивні залежності. Вони існують не тому, що «Gradle хоче ускладнити вам життя», а тому, що бібліотеки теж пишуть люди, і вони теж використовують чужий код. Якщо бібліотека вміє парсити JSON, їй потрібно щось для JSON. Якщо бібліотека піднімає HTTP-сервер, їй потрібні контейнер, API й чимала інфраструктура. А якби все це підключали вручну, наш курс перетворився б на «археологію jar-файлів» приблизно на 28 днів поспіль.

Невелика схема, щоб закріпити ідею «пряме → транзитивне»:

flowchart TD
    A["build.gradle.kts
прямі залежності"] --> B["starter/webmvc"] A --> C["starter/actuator"] B --> D["spring-web"] B --> E["spring-webmvc"] B --> F["tomcat-embed-*"] B --> G["jackson-*"] C --> H["micrometer-*"] C --> I["spring-boot-actuator-*"]

Ця діаграма не зобов’язана збігатися з вашим деревом один до одного, але вона показує головне: starter — це вхід у цілу «гілку» залежностей.

2. Classpath і його роль

Classpath звучить як слово з давніх манускриптів, але насправді це річ цілком практична. Classpath — це список, точніше набір, усіх бібліотек і класів, доступних вашому застосунку в конкретний момент: під час компіляції, запуску та виконання тестів.

Уявіть, що ваш застосунок — це студент на іспиті, а classpath — книжки, які лежать у нього на столі. Якщо потрібна книжка є, її можна відкрити й списати, тобто використати клас. Якщо книжки немає, доведеться «писати самому» — тобто або додати залежність, або відмовитися від використання.

У Java це проявляється буквально. Якщо клас є на classpath, ви можете його імпортувати:

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

public class ClasspathProbe {
    // Логер існує лише тоді, коли slf4j-api справді є на classpath
    private static final Logger log = LoggerFactory.getLogger(ClasspathProbe.class);

    public static void main(String[] args) {
        // Якщо slf4j-api немає, цей клас навіть не скомпілюється (Logger/LoggerFactory не знайдуться)
        log.info("Я існую, бо slf4j є на classpath"); // приклад виводу залежить від конфігурації логування
    }
}

Якби бібліотека slf4j-api була недоступна, такий код навіть не скомпілювався б: IDE підкреслювала б Logger червоним, а компілятор сказав би: «Я не знаю такого класу».

Тонкість у тому, що classpath буває різним. У найгрубішому вигляді це виглядає так:

«Який це classpath» Коли використовується Типова конфігурація Gradle
Classpath для компіляції коли компілюємо src/main/java compileClasspath
Classpath для запуску коли запускаємо застосунок runtimeClasspath
Classpath для тестів коли запускаємо тести testRuntimeClasspath /
testCompileClasspath

Для нас зараз важлива одна думка: поведінка застосунку визначається не лише тим, що ми написали в dependencies, а тим, що реально опинилося на runtimeClasspath. А поведінка тестів — тим, що опинилося на testRuntimeClasspath. Це близькі, але не однакові світи.

3. Конфігурації залежностей у Gradle

Коли ви вперше запускаєте ./gradlew dependencies(), можна відчути легкий культурний шок: Gradle друкує тонну тексту, а зверху — якісь загадкові заголовки на кшталт runtimeClasspath, testRuntimeClasspath, compileClasspath. Це не знущання, а ключ до розуміння того, де саме живе бібліотека і коли вона буде доступна.

У Gradle залежності розкладаються по конфігураціях. Конфігурація — це просто «кошик залежностей для конкретного завдання». Один кошик — для компіляції, інший — для запуску, третій — для тестів, четвертий — для annotation processorʼів. Так Gradle може чесно зібрати те, що потрібно саме зараз, і не тягати всюди все підряд.

Для проєкту catalog-service на ранньому етапі ви найчастіше зустрінете ось такі типи підключень — сприймайте це як словничок, а не як іспит:

У build.gradle.kts Ідея Потрапляє в runtime?
implementation потрібно і для компіляції, і для запуску так
runtimeOnly потрібно лише для запуску так
testImplementation потрібно лише для тестів ні (лише тестовий runtime)
compileOnly потрібно для компіляції, але не має їхати в runtime ні
annotationProcessor потрібно компілятору для генерації коду/метаданих ні

Тут важливо не стільки запам’ятати назви, скільки зрозуміти сам принцип: якщо бібліотека лежить не в тому кошику, ви легко отримаєте класичну ситуацію «в IDE все зелене, а під час запуску падає» — або навпаки. Але лікувати такі речі ми будемо не ворожінням на зірках, а читанням дерева залежностей.

4. ./gradlew dependencies без паніки

Команда ./gradlew dependencies — це як рентген: вона показує, що всередині. І, як будь-який рентген, спочатку виглядає страшнувато, тому що там багато «кісток». Наше завдання — навчитися дивитися на вивід так, щоб мозок не перетворювався на хаотичний набір фрагментів.

Найпростіша версія команди виглядає так:

# Показує дерево залежностей для кількох конфігурацій (вивід буде великим)
./gradlew dependencies

Gradle виведе дерево для багатьох конфігурацій, і тексту там справді буде багато. Тому на практиці майже завжди корисніше одразу дивитися на конкретну конфігурацію, щоб прибрати шум. Для розуміння того, що реально буде доступно застосунку під час запуску, зазвичай достатньо:

# Головне дерево для запуску застосунку
./gradlew dependencies --configuration runtimeClasspath

А для тестів:

# Головне дерево для запуску тестів (там інший classpath)
./gradlew dependencies --configuration testRuntimeClasspath

Тепер — найважливіше: як читати це дерево.

Gradle друкує залежності приблизно так (я сильно скорочу вивід і залишу лише ідею):

runtimeClasspath - Runtime classpath вихідного набору 'main'.
\--- org.springframework.boot:spring-boot-starter-webmvc
     +--- org.springframework.boot:spring-boot-starter
     |    \--- org.springframework.boot:spring-boot
     +--- org.springframework:spring-webmvc
     |    \--- org.springframework:spring-web
     +--- org.apache.tomcat.embed:tomcat-embed-core
     \--- ... (ще багато чого)

Ось що тут важливо. Зверху — конфігурація, тобто «для якого набору завдань ми дивимося залежності». Нижче йдуть гілки. Галуження означає: «ця залежність сама залежить ось від цих». Відступи та символи \--- і +--- — це просто візуальні маркери вкладеності. Чим глибший відступ, тим далі залежність від вашого build.gradle.kts.

І так, дерево буде довгим. Це не помилка. Це нормальна ціна за те, що ви написали один рядок spring-boot-starter-webmvc, а отримали робочий web-стек, а не порожній клас із public static void main().

5. Дерево залежностей catalog-service

Коли ми говоримо «у проєкті підключений starter», це звучить занадто абстрактно. Давайте максимально приземлимося й подивимося на наш catalog-service. На цьому етапі він ще маленький, але залежностям уже є що показати.

Наш робочий базовий набір простіший — web starter і test starter. Але щоб у дереві одразу побачити кілька різних ролей залежностей, візьмемо розширений варіант поверх нього. Це не новий обов’язковий знімок проєкту, а зручний зріз для читання графа:

dependencies {
    // Веб-стек: Spring MVC + вбудований сервер + JSON і все, що потрібно «по дорозі»
    implementation("org.springframework.boot:spring-boot-starter-webmvc")

    // Спостережуваність: метрики, кінцеві точки actuator тощо
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    // Тестовий стек Spring Boot (потрапляє лише в тестові конфігурації)
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // Генерація метаданих для конфігурації (використовується компілятором, не потрібна в runtime)
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}

Саме тут він корисний, бо в моделі залежностей одразу видно звичайні runtime-залежності, test-only залежність і annotation processor як окремого учасника графа.

Тепер запускаємо:

# Дивимося, що реально опиниться на runtime classpath застосунку
./gradlew dependencies --configuration runtimeClasspath

І у виводі шукаємо знайомі якорі. У поточному базовому наборі ви майже відразу побачите spring-boot-starter-webmvc, tomcat, jackson, logback; у розширеному варіанті додасться ще й micrometer. Корисна стратегія тут проста: не намагайтеся прочитати все підряд. Знайдіть якір і читайте лише потрібну гілку.

Наприклад, ви можете побачити, що spring-boot-starter-webmvc розкривається в кілька великих смислових частин: базовий шар Spring Boot, шар Spring MVC, JSON-шар і вбудований сервер. Навіть якщо ви поки не знаєте, що робить кожна бібліотека, ви вже можете відповісти на важливе питання: «Звідки це взялося?» — «Ось із цієї гілки дерева».

Щоб краще відчути структуру, ось спрощена «карта» залежності web starterʼа — це не точний вивід Gradle, а навчальна вичавка смислових блоків:

spring-boot-starter-webmvc
├─ spring-boot-starter
│  ├─ spring-boot
│  └─ spring-boot-autoconfigure
├─ spring-webmvc
│  └─ spring-web
├─ JSON-стек
│  ├─ jackson-databind
│  ├─ jackson-core
│  └─ jackson-annotations
└─ вбудований сервер
   ├─ tomcat-embed-core
   └─ tomcat-embed-websocket (може бути)

Якщо ви бачите в дереві, що всередині runtimeClasspath присутній tomcat-embed-core, це не означає, що ви «випадково підключили Tomcat». Це означає, що ви обрали starter, який виражає web-сценарій, а вбудований сервер — частина цього сценарію.

У цьому розширеному варіанті spring-boot-starter-actuator тягне спостережуваність — і навіть якщо ви ще нічого не налаштовували, на рівні залежностей уже видно, що в проєкт приїхали частини Micrometer і actuator-модулі Spring Boot.

Тепер важлива інженерна думка: Classpath — це контракт. Якщо бібліотека є на classpath, вона потенційно може впливати на поведінку застосунку — навіть якщо ви самі її напряму не використовуєте. Саме тому dependency tree потрібне не лише збиральнику, а й розробнику.

Маркер -> і (*)

Коли ви почнете уважно дивитися на вивід dependencies, ви майже напевно побачите два типові маркери, які спочатку виглядають як шифр.

Перший маркер — стрілка ->. Вона означає, що десь у графі запросили одну версію, а Gradle зрештою вибрав іншу. Приклад — умовний, але дуже схожий на реальність:

+--- some.group:some-lib:1.2
|    \--- common.group:common-lib:1.0 -> 1.1

Читається це так: some-lib хотів common-lib:1.0, але в проєкті за підсумком розв’язання залежностей буде common-lib:1.1. Причини бувають різні: інша гілка графа вимагала 1.1, Gradle вибрав більш відповідну версію, версією керує платформа або спрацювало правило.

Друга річ — (*). Вона зазвичай означає: «цю залежність уже показали вище, і Gradle не друкує її гілку повторно, щоб не роздувати вивід ще сильніше». Це буквально позначка «див. вище». Приклад:

+--- common.group:common-lib:1.1
\--- another.group:another-lib:2.0
     \--- common.group:common-lib:1.1 (*)

Тобто another-lib теж залежить від common-lib, але Gradle не буде заново показувати все піддерево, тому що воно вже траплялося.

Ці два маркери — мінімальний алфавіт, після якого дерево перестає бути шумом. Стрілка говорить про підсумкову версію, а (*) — «одне й те саме вдруге не друкую».

dependencyInsight як лупа

Іноді дерева вистачає, щоб відповісти на питання «що приїхало», але не вистачає, щоб відповісти на питання «чому саме це». Наприклад, ви бачите в runtimeClasspath jackson-databind, але хочете зрозуміти, яка гілка його привезла й чому вибралася саме така версія.

Для таких випадків у Gradle є команда-лупа dependencyInsight. Її зручно використовувати, коли ви вже знаєте ім’я залежності й хочете знайти шлях у графі.

Виглядає це так:

# Показує, хто саме притягнув залежність і як вибралася підсумкова версія
./gradlew dependencyInsight --dependency jackson-databind --configuration runtimeClasspath

Вивід буде більш «розмовним»: Gradle покаже, хто запросив залежність і як вибралася підсумкова версія. Це особливо корисно, коли в проєкті багато starterʼів і складно очима знайти, звідки підтягнулася конкретна бібліотека.

Ту саму логіку можна застосувати й до іншої залежності. Наприклад:

# Та сама «лупа», але для вбудованого сервера
./gradlew dependencyInsight --dependency tomcat-embed-core --configuration runtimeClasspath

Навіть якщо ви поки не знаєте, що таке Tomcat, ви вже можете швидко побачити: він сидить усередині гілки web starterʼа, і це очікувано.

Сенс dependencyInsight не в тому, щоб вивчити всі команди Gradle. Сенс у тому, щоб перестати ухвалювати рішення щодо залежностей «на відчуттях». Якщо у вас є питання про залежність, ви берете факти з дерева та з insight, а не з інтуїції й гугла «ніби має бути так».

6. Типові помилки під час читання dependency tree і роботи з classpath

Помилка №1: дивитися лише на build.gradle.kts і ігнорувати транзитивні залежності.
Новачки часто щиро вважають, що в проєкті є рівно те, що записано в dependencies{...}. Це призводить до здивування в стилі «звідки взявся Tomcat?» або «чому у мене є Jackson, я ж його не додавав». Правильна звичка тут дуже спокійна: build.gradle.kts показує намір, а ./gradlew dependencies показує реальність. Якщо хочете зрозуміти, що реально є в проєкті, дивіться дерево.

Помилка №2: намагатися «прочитати дерево цілком» і вигоріти за 30 секунд.
dependencies виводить багато тексту, і мозок намагається читати все підряд, як книгу. Але це не книга, а карта. Її читають за орієнтирами: спочатку обирають конфігурацію — runtimeClasspath або testRuntimeClasspath, потім знаходять якір — starter або конкретну бібліотеку, а потім читають лише потрібну гілку. Коли ви починаєте читати дерево як карту, воно перестає лякати.

Помилка №3: не розуміти маркери -> і (*) та через це робити хибні висновки.
Якщо ви бачите 1.0 -> 1.1 і думаєте: «Ой, Gradle зламався й сам усе змінює», ви починаєте лагодити те, що не зламано. Стрілка просто говорить: підсумкова версія відрізняється від запитаної, і це часто нормально, особливо в платформному світі з managed versions. А (*) взагалі не про версію, а про те, що Gradle не дублює одну й ту саму гілку.

Помилка №4: плутати «компіляцію» і «запуск», ніби це одне й те саме.
Наївна модель звучить так: «якщо код компілюється, значить у рантаймі все є». Але в Gradle є різні конфігурації, і бібліотека може бути доступна компіляції, але відсутня в runtime — або навпаки. Навіть якщо ви поки не змінюєте конфігурації вручну, корисно розуміти, чому ми іноді дивимося на runtimeClasspath, а іноді — на testRuntimeClasspath: це різні світи з різними правилами.

Помилка №5: боятися довгого dependency tree як ознаки «поганого проєкту».
Довге дерево — це не діагноз. Це просто реальність сучасного стека: Spring Boot, JSON, вбудований сервер, логування, тестовий стек — усе це набори компонентів. Проблемою дерево стає не через довжину, а через симптоми: конфлікти версій, дублювальні реалізації одного й того ж шару, неочікувані «другі» бібліотеки з тим самим призначенням. Але сам факт «багато залежностей» у Boot-проєкті — цілком нормальний, особливо якщо ви використовуєте starterʼи.

1
Задача
Spring Boot, 4 рівень, 2 лекція
Недоступна
Скрипт звітів для runtime і test classpath
Скрипт звітів для runtime і test classpath
1
Задача
Spring Boot, 4 рівень, 2 лекція
Недоступна
dependencyInsight для транзитивної залежності
dependencyInsight для транзитивної залежності
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ