Создание и использование BOM в Gradle
Привет Хабр!
В каждой компании (а если она крупная, то, скорее всего, в каждом подразделении) должна быть выстроена культура использования BOM (bill of materials) для управления версиями зависимостей. В этой статье я хочу поделиться своим видением того, как это может быть организовано, а также рассмотреть более сложные случаи создания и использования BOM в Gradle-проектах.
Зачем вообще нужен BOM?
При разработке корпоративных приложений/микросервисов, как правило, используются современные фреймворки и сторонние библиотеки. Количество зависимостей даже в простом web-приложении очень велико. Это и прямые зависимости, которые подключаются в проект явно, и транзитивные — те, которые требуются прямым зависимостям и попадают на classpath
неявно.
Бывает так, что две библиотеки, например X и Y, требуют разные версии одной и той же транзитивной зависимости Z (snakeyaml, Google Guava, Apache Commons и т.п.). Такая ситуация называется конфликтом (или jar hell, когда это происходит в большом количестве). Подробнее об этом и стратегиях разрешения конфликтов можно почитать в отличной статье Баруха Садогурского. Поскольку ниже примеры будут для Gradle, важно отметить, что Gradle в общем случае использует стратегию latest, то есть выбирается зависимость с большей версией. Мы можем вмешиваться в процесс разрешения конфликтов зависимостей, но об этом позднее.
Итак, допустим мы столкнулись с конфликтом зависимостей: X приносит версию Z 1.3, а Y — версию Z 2.4. Gradle автоматически разрешит его в пользу более новой версии — Z 2.4. Если разные версии зависимости Z бинарно совместимы друг с другом, то проблемы не будет; в противном случае мы получим ошибку в runtime
.
По сути, есть всего один хороший способ исправить проблему — подобрать такие версии X и Y, которые не будут конфликтовать друг с другом. Ключевое слово здесь »подобрать». Это нетривиальная операция, требующая, как правило, много сил и времени. И здесь на помощь приходят BOM-файлы.
Как выглядит BOM?
BOM — это специально созданный POM-файл, содержащий внутри список зависимостей с их версиями, которые гарантированно не конфликтуют друг с другом. Самая важная часть BOM-файла — это секция dependencyManagement
(полный пример тут):
...
io.github.mfvanek
pg-index-health-model
0.10.2
io.github.mfvanek
pg-index-health
0.10.2
io.github.mfvanek
pg-index-health-jdbc-connection
0.10.2
io.github.mfvanek
pg-index-health-generator
0.10.2
io.github.mfvanek
pg-index-health-testing
0.10.2
io.github.mfvanek
pg-index-health-test-starter
0.10.2
Подключить BOM-файл в проект можно следующим образом:
dependencies {
// Подключаем BOM
implementation(platform("io.github.mfvanek:pg-index-health-bom:0.10.2"))
// Используем зависимости, контролируемые BOM
implementation("io.github.mfvanek:pg-index-health")
implementation("io.github.mfvanek:pg-index-health-generator")
implementation("io.github.mfvanek:pg-index-health-testing")
}
После чего вам больше не нужно указывать версии для всех зависимостей, контролируемых BOM.
Все крупные фреймворки и многомодульные проекты публикуют свои собственные BOM.
Как создать свой BOM с помощью Gradle?
Создать BOM в Gradle достаточно просто: для этого нужен java-platform
плагин. В секции dependencies
в блоке constraints
перечисляем нужные зависимости с конкретными версиями и готово (полный пример тут):
plugins {
id("java-platform")
...
}
description = "pg-index-health library BOM"
dependencies {
constraints {
api(libs.pg.index.health.model)
api(libs.pg.index.health.core)
api(libs.pg.index.health.jdbcConnection)
api(libs.pg.index.health.generator)
api(libs.pg.index.health.testing)
api(project(":pg-index-health-test-starter"))
}
}
Мы можем включить один BOM в другой, собрав таким образом более универсальное и удобное решение. Ярким примером такого агрегата является spring-boot-dependencies.
В Gradle для этого нужно указать опцию allowDependencies
(полный пример):
plugins {
id("java-platform")
…
}
description = "Example of BOM for internal usage"
javaPlatform {
allowDependencies()
}
dependencies {
api(platform("org.junit:junit-bom:5.10.1"))
api(platform("org.testcontainers:testcontainers-bom:1.19.3"))
api(platform("io.github.mfvanek:pg-index-health-bom:0.10.2"))
api(platform("org.mockito:mockito-bom:5.8.0"))
constraints {
api("com.google.code.findbugs:jsr305:3.0.2")
api("org.postgresql:postgresql:42.7.1")
api("com.zaxxer:HikariCP:5.1.0")
api("ch.qos.logback:logback-classic:1.4.14")
api("org.slf4j:slf4j-api:2.0.10")
api("org.assertj:assertj-core:3.25.0")
api("com.h2database:h2:2.2.224")
api("javax.annotation:javax.annotation-api:1.3.2")
api("org.threeten:threeten-extra:1.7.2")
api("io.netty:netty-all:4.1.104.Final")
}
}
Когда создавать свой BOM?
Если ваша java/kotlin-библиотека/Spring Boot-стартер имеет два и более артефактов, то создавайте для них BOM. Всегда. Расценивайте это как best practice.
BOM станет своего рода фасадом: позволит скрыть от внешних потребителей общее количество ваших артефактов (только с точки зрения управления версиями зависимостей) и облегчит создание композитных BOM.
Если вы работаете в среде с большим количеством разработчиков/команд, разделяйте внешние и внутренние зависимости. Внешние зависимости — это то, что мы тянем из Maven Central. Внутренние — это те артефакты, которые мы разрабатываем и потребляем самостоятельно в пределах своей компании.
Все внешние зависимости, по возможности, должны потребляться в виде BOM. BOM — это минимальная единица распространения. Если у какой-то библиотеки нет BOM, то её подключаете как есть. Важно максимально переиспользовать уже готовые BOM, минимизируя собственную работу.
В итоге BOM с внешними зависимостями будет выглядеть примерно так:
dependencies {
api(platform("org.springframework.cloud:spring-cloud-dependencies:2021.0.9"))
api(platform("org.springframework.cloud:spring-cloud-sleuth-otel-dependencies:1.1.4"))
api(platform("org.springframework.boot:spring-boot-dependencies:2.7.18"))
api(platform("org.springframework.security:spring-security-bom:5.8.9"))
api(platform("org.testcontainers:testcontainers-bom:1.19.3"))
api(platform("net.javacrumbs.shedlock:shedlock-bom:4.46.0"))
api(platform("org.springdoc:springdoc-openapi:1.7.0"))
api(platform("io.sentry:sentry-bom:5.7.4"))
constraints {
api("commons-io:commons-io:2.11.0")
api("org.apache.commons:commons-text:1.10.0")
api("com.zaxxer:HikariCP:5.1.0")
api("com.vladmihalcea:hibernate-types-52:2.21.1")
api("org.postgresql:postgresql:42.7.1")
api("net.ttddyy:datasource-proxy:1.9")
}
}
Не стремитесь собрать в BOM с внешними зависимостями абсолютно все используемые внутри компании библиотеки. Во-первых, вряд ли вам это удастся, а во-вторых, в этом нет практического смысла. BOM должен содержать 90%-95% процентов широко используемых артефактов. Всё остальное конкретные команды подключают в свои проекты самостоятельно (и сами следят за совместимостью).
BOM с внутренними зависимостями может (и должен) включать BOM с внешними зависимостями. Это удобно для конечных потребителей.
Что такое Rich Model и Gradle Module Metadata?
Если собрать и опубликовать BOM с помощью Gradle, то, заглянув в бинарный репозиторий, можно обнаружить дополнительный файл в JSON-формате с расширением .module
(пример такого файла). Это так называемые метаданные модуля — расширенная информация, которую Gradle публикует для более глубокого и гибкого управления зависимостями.
Давайте взглянем на BOM spring-boot-dependencies
версии 2.7.18. Он содержит библиотеку snakeyaml
версии 1.30, а в ней слишком много уязвимостей. Мы хотим поднять версию snakeyaml
до 1.33 для всех проектов и создаем для этого internal-spring-boot-2-bom
:
dependencies {
api(platform("org.springframework.boot:spring-boot-dependencies:2.7.18"))
constraints {
api("org.yaml:snakeyaml:1.33")
}
}
Для такого BOM Gradle сгенерирует метаданные:
"dependencyConstraints": [
{
"group": "org.yaml",
"module": "snakeyaml",
"version": {
"requires": "1.33"
}
}
]
Подключим этот BOM в приложение:
dependencies {
implementation(platform(project(":internal-spring-boot-2-bom")))
implementation("org.springframework.boot:spring-boot-starter-web")
}
И выполним следующую команду, чтобы понять, какую версию snakeyaml
выберет Gradle:
./gradlew dependencyInsight --dependency org.yaml:snakeyaml
На выходе получим примерно такой результат:
org.yaml:snakeyaml:1.33
Selection reasons:
- By constraint
- By conflict resolution: between versions 1.33 and 1.30
Более высокая версия 1.33 легко перекрыла версию 1.30 из spring-boot-dependencies
.
А теперь давайте добавим в проект зависимость для swagger-core
:
implementation("io.swagger.core.v3:swagger-core:2.2.20")
swagger-core
приносит версию snakeyaml
2.2, которая несовместима с 1.30 и 1.33, что с высокой долей вероятности сломает ваше приложение. swagger-core
может попасть на ваш classpath
неявно со springdoc
или другой зависимостью.
./gradlew dependencyInsight --dependency org.yaml:snakeyaml
org.yaml:snakeyaml:2.2
Selection reasons:
- By constraint
- By conflict resolution: between versions 2.2, 1.33 and 1.30
Есть, как минимум, три способа решить эту проблему. Самый простой — исключить неподходящую зависимость:
implementation("io.swagger.core.v3:swagger-core:2.2.20") {
exclude(group = "org.yaml", module = "snakeyaml")
}
Этот вариант не всегда применим и совсем плохо масштабируется на большое количество приложений/микросервисов.
Другой способ — использовать enforcedPlatform
при импорте BOM:
implementation(enforcedPlatform(project(":internal-spring-boot-2-bom")))
./gradlew dependencyInsight --dependency org.yaml:snakeyaml
org.yaml:snakeyaml:1.33
Selection reasons:
- By constraint
- Forced
Это работающий вариант, но он сравним с забиванием мебельных гвоздей кувалдой. И он, опять же, плохо масштабируется.
Третий (наиболее предпочтительный вариант, на мой взгляд) заключается в использовании расширенных версий (Rich Versions) в BOM-файле:
dependencies {
api(platform("org.springframework.boot:spring-boot-dependencies:2.7.18"))
constraints {
api("org.yaml:snakeyaml") {
version {
strictly("1.33")
}
}
}
}
Итоговый POM-файл в обоих случаях будет выглядеть одинаково, но на этот раз Gradle сгенерирует другие метаданные:
"dependencyConstraints": [
{
"group": "org.yaml",
"module": "snakeyaml",
"version": {
"strictly": "1.33",
"requires": "1.33"
}
}
]
Использование strictly
позволяет жестко зафиксировать версию и не требует правок в прикладных проектах/сервисах:
./gradlew dependencyInsight --dependency org.yaml:snakeyaml
org.yaml:snakeyaml:1.33
Selection reasons:
- By constraint
- By ancestor
Как несколько BOM сочетаются друг с другом?
Как мы увидели выше, перекрыть версию зависимости, управляемую BOM, достаточно просто. Но что будет, если в ваш проект подключено несколько BOM, каждый из которых приносит разные версии одной и той же зависимости?
В Gradle в общем случае будет выбрана более высокая версия. В Maven поведение другое: будет выбрана версия из того BOM файла, который объявлен первым.
Иногда порядок включения BOM имеет значение даже в Gradle, например, если вы используете Spring Boot и плагин io.spring.dependency-management
. Рассмотрим такой пример:
plugins {
id("java")
id("org.springframework.boot") version "3.2.1"
id("io.spring.dependency-management") version "1.1.4"
}
dependencyManagement {
imports {
mavenBom("org.springdoc:springdoc-openapi:2.2.0")
// mavenBom("org.springframework.boot:spring-boot-dependencies:3.2.1") // because of springdoc-openapi
mavenBom("org.testcontainers:testcontainers-bom:1.19.3")
mavenBom("org.junit:junit-bom:5.10.1")
}
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Если раскомментировать строчку с импортом BOM spring-boot-dependencies
, то проект собирается; в противном случае будет ошибка поднятия контекста. Проблема в том, что BOM springdoc-openapi
приносит старую версию Spring Framework 6.0, которая несовместима со Spring Boot 3.2. Вариантов решения множество: обновить spingdoc
, изменить порядок импорта BOM, но самый лучший, на мой взгляд, не использовать плагин io.spring.dependency-management
.
Управление версиями Gradle-плагинов через BOM
Помимо обычных зависимостей в BOM можно добавить Gradle-плагины и контролировать их версии в том числе. Если вы используете Spring Boot, то, вероятно, знакомы с плагином org.springframework.boot. Как правило, его версия совпадает с версией spring-boot-dependencies
, и будет разумно поставлять их вместе:
plugins {
id("java-platform")
}
description = "Spring Boot 3 cumulative BOM"
javaPlatform {
allowDependencies()
}
dependencies {
val spring3Version = "3.2.1"
api(platform("org.springframework.boot:spring-boot-dependencies:$spring3Version"))
constraints {
api("org.springframework.boot:spring-boot-gradle-plugin:$spring3Version")
}
}
Чтобы использовать BOM для управления версией плагина, нужно создать каталог buildSrc
в целевом проекте/приложении с файлом build.gradle.kts
и подключить туда этот BOM:
// buildSrc/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
mavenLocal()
gradlePluginPortal()
}
dependencies {
implementation(platform("io.github.mfvanek:internal-spring-boot-3-bom:0.1.1")) // Подключаем BOM
implementation("org.springframework.boot:spring-boot-gradle-plugin") // Подключаем нужный плагин. Версия берётся из BOM
}
В самом приложении используем плагин как обычно, но версию указывать уже не нужно (пример приложения для экспериментов):
plugins {
id("java")
id("org.springframework.boot")
}
...