JavaRush /Курсы /Spring Boot /Безопасное управление starter’ами

Безопасное управление starter’ами

Spring Boot
4 уровень , 3 лекция
Открыта

1. Цена одной зависимости

Когда ты только начинаешь, строка в build.gradle.kts выглядит невинно: ну что такого, добавил одну библиотеку — и всё. Но в Boot-проекте зависимость почти никогда не приходит одна. Она тащит транзитивные зависимости, может сдвинуть версии общих библиотек, а иногда меняет поведение приложения просто потому, что на classpath появился новый набор классов. И это не «плохой Spring», а обычная механика Java-мира: classpath — общий котёл, и если ты добавил туда новый ингредиент, вкус меняется у всего супа, а не только у одной ложки.

Здесь важно разделить два типа изменений. Первое — ты просто расширил возможности проекта: добавил что-то сверху, не ломая базовый набор версий, потому что Spring Boot управляет ими через свой BOM. Второе — ты нечаянно начал перетягивать версии вручную или добавил библиотеку, которая конфликтует с тем, что Boot уже привёл. Во втором случае проект начинает вести себя как машина после «тюнинга в гараже по ролику из интернета»: вроде едет, но звук странный, лампочки горят, и на поворотах хочется молиться.

И ещё одна важная мысль: starter’ы — это удобная абстракция уровня «сценарий», но в итоге они всё равно раскрываются в конкретный набор модулей. Поэтому «безопасно добавить starter» — значит не просто написать строку, а сделать это так, чтобы не сломать managed-модель, не продублировать уже существующие части графа и не притащить в проект случайные ручные версии. Сейчас мы как раз соберём понятный алгоритм, который позволяет добавлять зависимости спокойно — без магии, без паники и без ритуала «ну попробуем...».

2. Безопасное добавление зависимостей

Если ты унесёшь из этой лекции только одну вещь, пусть это будет порядок действий, а не «самая красивая команда Gradle». Ошибка новичка обычно не в том, что он не знает exclude. Ошибка в том, что он начинает «лечить симптомы» раньше, чем посмотрел на причины: добавляет версии, исключает модули, меняет starter’ы — и всё это до того, как увидел реальную картину графа зависимостей. Поэтому правильный алгоритм начинается не с кода и даже не с Gradle, а с формулировки того, что именно нужно проекту.

Представь, что тебе нужно «добавить возможность X». Не важно, что это за X: диагностика, валидация, быстрый цикл разработки или что-то ещё. Сначала формулируешь сценарий, потом ищешь правильную зависимость уровня Boot, обычно starter, добавляешь её без версии, кладёшь в правильную Gradle-конфигурацию (про них — в следующем разделе) и сразу проверяешь две вещи: как изменилось дерево зависимостей и собирается ли проект. Если что-то пошло не так, ты не «подсыпаешь ещё одну версию», а возвращаешься и выясняешь, кто с кем конфликтует.

Удобно держать в голове простой «маршрут» в виде схемы:

flowchart TD
    A[Нужно новое поведение] --> B["Есть ли starter под сценарий"]
    B -->|Да| C[Добавляем starter без версии]
    B -->|Нет| D[Добавляем библиотеку и проверяем: managed ли она]
    C --> E["Смотрим dependency tree / dependencyInsight"]
    D --> E
    E --> F["Проверяем сборку: test/bootRun"]
    F --> G{"Всё ок"}
    G -->|Да| H[Фиксируем изменение]
    G -->|Нет| I[Откатываем и разбираем конфликт по фактам]

Чтобы эта схема не оставалась ритуалом, держи простое правило junior-уровня для Boot-проектов этого типа:

Официальные Boot starter’ы и Boot-артефакты вроде spring-boot-devtools или spring-boot-configuration-processor в рамках курса по умолчанию подключаем без версии.

Если библиотека внешняя и не выглядит частью набора, которым управляет Boot, начинаем с явной версии.

Если зависимость без версии Gradle не может нормально разрешить, это практический сигнал: managed-модель эту версию тебе не поставляет.

После добавления всё равно проверяем фактами runtimeClasspath или dependencyInsight, а не верим на слово одной строке в build.gradle.kts.

Обрати внимание: здесь нет шага «в отчаянии ставим версии руками». Версия — это инструмент, но он должен быть последним, а не первым. И да: «проверяем сборку» — это не «когда-нибудь потом». Зависимости — фундамент. Если фундамент треснул, второй этаж не строят.

В контексте catalog-service этот алгоритм особенно важен, потому что мы строим шаблон Boot-сервиса. А шаблон ценен тем, что его можно расширять дальше. Если мы уже на раннем этапе научимся добавлять зависимости аккуратно, потом будет намного проще: новые возможности начнут появляться контролируемо, а не как случайный набор библиотек, которые однажды поссорились друг с другом.

3. Gradle-конфигурации зависимостей

Самая частая ошибка начинающего — считать, что все зависимости одинаковые. Мол, «раз библиотека нужна, значит пишем в implementation и поехали». Но Gradle, а вместе с ним и Boot, специально дают нам разные конфигурации: что нужно в рантайме приложения, что нужно только тестам, что нужно только во время разработки, а что вообще нужно лишь компилятору как «генератор» кода или метаданных. Это не бюрократия, а способ держать проект чистым и предсказуемым.

Ниже — небольшой ориентир. Это не «надо выучить наизусть», но полезно понимать, почему одна и та же библиотека в разных местах означает разные вещи:

Конфигурация Gradle Где используется Что означает простыми словами Пример из Boot-мира
implementation компиляция + запуск приложения «Это часть приложения» spring-boot-starter-actuator, spring-boot-starter-webmvc
testImplementation компиляция + запуск тестов «Это нужно только тестам» spring-boot-starter-test
runtimeOnly только запуск «Во время компиляции не нужно, но в рантайме нужно» встречается реже на старте курса
developmentOnly локальная разработка (IDE/bootRun) «Помощник для разработки, не тащим в прод» spring-boot-devtools
annotationProcessor компиляция (генерация кода/метаданных) «Это не библиотека приложения, а инструмент компилятора» spring-boot-configuration-processor

Если сказать очень грубо, то implementation — это «везём в рюкзаке в поход», testImplementation — «это лежит дома на тренировке», developmentOnly — «это велосипедные ролики: помогают учиться, но в гонку не берём», а annotationProcessor — «это не еда, а повар, который помогает её приготовить».

Для нашего проекта catalog-service важно, чтобы зависимости были «по делу» и не расползались. Например, spring-boot-devtools — штука полезная, но она не должна по умолчанию оказываться в итоговом артефакте. А spring-boot-configuration-processor вообще не должен восприниматься как runtime-зависимость — он нужен IDE и сборке для генерации metadata, а не для работы приложения.

Именно поэтому «безопасно добавить starter» — это ещё и «положить его в правильную конфигурацию». Иначе ты можешь получить проект, который вроде запускается, но его артефакт неожиданно раздулся или, наоборот, тесты падают из-за отсутствия зависимостей, которые ты по ошибке положил не туда.

4. Добавляем spring-boot-starter-actuator

Теперь максимально практично: как выглядит безопасное добавление starter’а, если мы хотим расширить проект ещё одной платформенной возможностью и при этом не лезть в ручное управление версиями. Здесь важен не набор endpoint’ов Actuator сам по себе, а навык: добавить зависимость, проверить дерево, убедиться, что версиями управляет Boot, и что проект после этого живёт.

Возьмём текущий baseline дня — spring-boot-starter-webmvc плюс spring-boot-starter-test — и отдельно посмотрим, что меняется, если поверх него добавить один официальный starter для диагностики. Это именно изолированное изменение модели зависимостей: мы не объявляем Actuator новым обязательным состоянием проекта, а смотрим, как нарастить build одной строкой.

dependencies {
    // Версию не указываем: Spring Boot управляет версиями starter’ов через свой BOM
    implementation("org.springframework.boot:spring-boot-starter-actuator")
}

Обрати внимание: мы всё ещё не пишем версию. Это не «лень», а осознанный Boot-стиль. Версия starter’а и всех его внутренних библиотек выровнена через Spring Boot dependency management.

Теперь два быстрых способа проверить, что всё действительно подключилось так, как мы ожидаем. Первый — посмотреть дерево зависимостей именно для runtime classpath: так проще, чем читать весь отчёт подряд.

# Смотрим дерево зависимостей именно runtimeClasspath (то, что реально попадёт в runtime)
./gradlew dependencies --configuration runtimeClasspath

# ... в выводе ищем spring-boot-starter-actuator и его ветку

Второй — точечно проверить конкретную библиотеку, которая пришла транзитивно, и увидеть, почему выбрана именно эта версия. Например, если из планов курса мы знаем, что Boot приводит Micrometer, можно сделать так:

# Точечно выясняем: откуда пришёл micrometer-core и почему выбрана именно эта версия
./gradlew dependencyInsight --dependency micrometer-core --configuration runtimeClasspath

Тут важный психологический момент. Новичок часто ждёт, что после добавления starter’а он увидит «одну библиотеку». А увидит целый кусок графа. Это нормально. Starter — это curated dependency set: он приносит набор компонентов, которые обычно нужны вместе. Дерево может вырасти заметно — и это не признак того, что ты сделал что-то плохо. Плохой признак — когда дерево выросло, а ты не можешь объяснить почему: «ну, само как-то».

После проверки дерева делаем последнюю вещь, которая превращает нас из «редактора build.gradle.kts» в инженера: убеждаемся, что проект собирается. На раннем этапе не нужно усложнять — достаточно обычного:

./gradlew test
# BUILD SUCCESSFUL

Если сборка прошла, значит модель зависимостей согласована. И самое главное: ты добавил новую возможность, не ломая baseline и не превращая build в таблицу ручных номеров версий.

5. Ручные версии как крайняя мера

Теперь давай о самом популярном сценарии поломки: «я добавил версию, потому что видел в интернете». Интернет, конечно, прекрасен, но у него есть особенность: он не знает, что у тебя Spring Boot 4, а не Boot 2. Он не знает, что у тебя Jackson 3, а не Jackson 2. И ему вообще всё равно, что у тебя под капотом managed versions. Поэтому рецепт «вставьте вот эту зависимость с вот этой версией» в Boot-проекте часто работает как медвежья услуга: вроде помог, но дверной проём теперь шириной в медведя.

Классический плохой пример выглядит примерно так: разработчик хочет «настроить JSON» и решает добавить jackson-databind руками, причём ещё и с версией из старой статьи:

dependencies {
    // Starter подключаем без версии (это правильно)
    implementation("org.springframework.boot:spring-boot-starter-webmvc")

    // А вот тут начинается «вторая правда»: ручная версия, да ещё и потенциально не из Boot-managed линии
    implementation("com.fasterxml.jackson.core:jackson-databind:2.17.1")
}

Проблема здесь даже не в самой записи версии. Проблема в том, что это другая линия Jackson. В Boot 4 baseline — Jackson 3, и он может быть в другом groupId и с другими ожиданиями по совместимости модулей. Итог бывает разный: от «собралось, но упало на старте с NoSuchMethodError» до «всё работает, пока не тронешь определённую функциональность, а потом падает». И это самый неприятный тип ошибки — «плавающий».

Как здесь правильно мыслить. Если тебе нужен JSON в Boot, то почти всегда правильный ответ — не добавлять jackson-databind отдельно, а добавить или оставить starter, который приносит JSON-поддержку согласованно. В нашем курсе это делается через web starter, но даже без него принцип тот же: не подключай низкоуровневую библиотеку, если у тебя есть starter, который уже решает сценарий целиком.

Если ты уже добавил руками «не ту» библиотеку, исправление обычно скучное: удалить ручную зависимость, вернуться к managed-модели и снова посмотреть дерево:

dependencies {
    // Оставляем starter: он притянет согласованный набор зависимостей, включая JSON
    implementation("org.springframework.boot:spring-boot-starter-webmvc")

    // jackson-databind руками не добавляем
}

А дальше снова ./gradlew dependencies --configuration runtimeClasspath и проверка, какая версия Jackson реально пришла из Boot-managed набора.

И вот здесь появляется важное правило для junior-уровня, которое реально спасает проекты: если библиотека уже приходит из starter’а, добавлять её отдельно — почти всегда ошибка. Иногда бывают исключения, например тонкая настройка или замена реализации, но эти исключения должны быть объяснимыми и проверяемыми, а не «на всякий случай».

6. Optional-зависимости и прод

С понятием «optional dependency» у новичка обычно происходит маленькая путаница. Кажется, будто optional — это что-то вроде «можно поставить, можно не ставить, мир не рухнет». И это правда, но важнее другое: optional-зависимость обычно нужна не всем слоям и не во всех режимах жизни приложения. В Boot-проекте особенно важно отличать зависимости, которые должны попасть в итоговый артефакт, от зависимостей, которые нужны только для того, чтобы тебе было удобнее жить в IDE.

Возьмём два типичных примера из Boot-мира: DevTools и configuration processor. Первый помогает в разработке, второй помогает IDE и сборке генерировать metadata для конфигурации. Оба полезны, но оба не должны восприниматься как «часть приложения в рантайме».

В Gradle это выражается не словами «optional» (как в Maven), а тем, куда именно мы их кладём.

DevTools — хороший пример зависимости, которая нужна только в разработке, а в «боевой» сборке должна отсутствовать:

dependencies {
    // Нужен только для локальной разработки (bootRun/IDE), в прод-артефакт попадать не должен
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}

Configuration processor — пример зависимости, которая нужна компилятору как инструмент:

dependencies {
    // Нужен компилятору/IDE для генерации metadata, не является runtime-зависимостью приложения
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}

Тут можно заметить интересную вещь: обе строки не используют версию. И это снова нормальный Boot-стиль: этими зависимостями тоже управляет Boot dependency management.

Почему это важно для catalog-service прямо сейчас, даже если мы ещё не чувствуем пользу от этих инструментов? Потому что мы собираем не просто проект, а шаблон с правильными привычками: зависимости должны подключаться по смыслу. Когда ты потом будешь собирать bootJar, запускать java -jar и переносить шаблон в следующий проект, тебе не придётся отрезать от него «лишние хвосты». Они просто не появятся там изначально.

Ещё одна практическая деталь, которая спасает нервы: если зависимость должна быть «не частью продакшена», не пытайся решать это дисциплиной вида «ну мы просто не будем запускать так». Лучше решать это структурой Gradle-конфигураций. Это как ремень безопасности: он нужен не потому, что ты планируешь аварии, а потому что ты человек.

7. exclude и замена starter’ов

Когда студент впервые узнаёт про exclude, очень хочется пойти и «почистить дерево зависимостей». Оно же длинное! Наверняка там много лишнего! И вот тут главное вовремя остановиться. Длинное дерево зависимостей — не болезнь. Болезнь — это конфликт версий, дублирование по смыслу и неуправляемые ручные overrides. exclude — это инструмент для точечного вмешательства, когда ты точно знаешь, что именно заменяешь и зачем. Если ты не можешь объяснить исключение одним предложением, лучше его не делать.

Самый понятный сценарий замены на уровне starter’ов — когда ты меняешь одну «реализацию сценария» на другую. Например, ты решил использовать альтернативный logging starter. В курсе мы не делаем это как обязательный шаг — мы держимся Boot defaults, — но как пример controlled replacement это идеально:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc") {
        // Убираем дефолтный logging starter, чтобы заменить его на альтернативный
        exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
    }

    // Подключаем альтернативный starter логирования (без ручных версий)
    implementation("org.springframework.boot:spring-boot-starter-log4j2")
}

Обрати внимание на «хорошие манеры» этого куска конфигурации. Здесь нет ручных версий. Здесь явно видно, что мы делаем: исключаем конкретный модуль и добавляем альтернативный starter. Это контролируемо, объяснимо и проверяемо через dependency tree. Плохой вариант выглядел бы иначе: «насыплю log4j2-артефактов руками, исключу что-нибудь, а если не заработает — добавлю ещё пару исключений». Вот так и появляются проекты, где build.gradle.kts напоминает заклинание из фэнтези: выглядит мощно, но никто не знает, что произойдёт при произнесении.

После любого exclude, особенно связанного со starter’ами, обязательно возвращаемся к фактам: снова смотрим ./gradlew dependencies --configuration runtimeClasspath и убеждаемся, что замена действительно произошла так, как мы ожидаем. И снова прогоняем сборку. exclude — это операция, а не косметика. После операции пациент должен дышать.

Если держать эту дисциплину, то даже сложные изменения модели зависимостей перестают быть страшными. Они становятся пошаговыми: одно изменение — один факт в дереве — один прогон сборки.

8. Типичные ошибки при работе со starter’ами

Ошибка №1: добавлять starter с ручной версией «для надёжности».
Это звучит логично только до первого конфликта. В Boot-проекте версия starter’а почти всегда должна приходить из managed-модели. Ручная версия в строке зависимости делает build менее предсказуемым, потому что ты создаёшь второй источник истины: Boot говорит одно, ты — другое. При следующем обновлении Boot это почти гарантированно начнёт расходиться.

Ошибка №2: подключать низкоуровневую библиотеку рядом со starter’ом, который уже приносит её транзитивно.
Так появляется «двойная правда». Сегодня Gradle выбрал одну версию, завтра — другую, и ты получаешь неожиданные runtime-ошибки. Если тебе нужен сценарий, ищи starter. Если starter уже есть, не дублируй его внутренности руками, пока не понимаешь, зачем ты это делаешь и как проверишь результат.

Ошибка №3: класть всё в implementation, потому что “так проще”.
Это быстро превращает проект в тяжёлый, шумный и плохо переносимый. DevTools в implementation — классический пример: локально удобно, но потом странно, что артефакт другой и ведёт себя иначе. Configuration processor в implementation тоже бессмысленен: он нужен компилятору, а не приложению. Правильная конфигурация зависимости — это часть архитектуры сборки.

Ошибка №4: использовать exclude как способ сделать дерево “красивым”.
Красивое дерево — не цель. Цель — совместимый и предсказуемый classpath. Если ты исключаешь зависимости только потому, что их «слишком много», ты, скорее всего, вырежешь что-то нужное, а потом начнёшь чинить симптомы ещё большим количеством костылей. exclude должен быть объясним как замена или устранение конкретного конфликта.

Ошибка №5: менять несколько вещей в модели зависимостей за один шаг и потом не понимать, что именно сломало сборку.
Добавил starter, одновременно сменил версию какой-то библиотеки, ещё и добавил пару exclude — и вот у тебя проект, который не собирается, а виноват «Spring». На деле виноват процесс изменений. Делай одно изменение — проверяй дерево — проверяй сборку. Это скучно, но скука здесь означает контроль.

1
Задача
Spring Boot, 4 уровень, 3 лекция
Недоступна
Безопасно добавить новый starter
Безопасно добавить новый starter
1
Задача
Spring Boot, 4 уровень, 3 лекция
Недоступна
Контролируемая замена logging starter
Контролируемая замена logging starter
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ