Привет Хабр!
В каждой компании (а если она крупная, то, скорее всего, в каждом подразделении) должна быть выстроена культура использования 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
(полный пример тут):
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
...
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.mfvanek</groupId>
<artifactId>pg-index-health-model</artifactId>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>io.github.mfvanek</groupId>
<artifactId>pg-index-health</artifactId>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>io.github.mfvanek</groupId>
<artifactId>pg-index-health-jdbc-connection</artifactId>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>io.github.mfvanek</groupId>
<artifactId>pg-index-health-generator</artifactId>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>io.github.mfvanek</groupId>
<artifactId>pg-index-health-testing</artifactId>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>io.github.mfvanek</groupId>
<artifactId>pg-index-health-test-starter</artifactId>
<version>0.10.2</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
Подключить 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")
}
...
Ссылки на репозитории
Пример BOM для моего open-source проекта
Большой многомодульный проект с несколькими BOM
Песочница для экспериментов со io.spring.dependency-management
Дополнительный материал
Комментарии (9)
navrotski
06.01.2024 09:01+2Я столкнулся с тем, что если у некоторых компонентов (отдельных java проектов) файл build.gradle (да и сам gradle) не обновлялся достаточно давно, то задействование bom с помощью platform (и тем более force platform) приносит кучу проблем, по сути ломая механизм разрешения зависимостей. Так что лучше перед использованием bom избавиться от зоопарка версий gradle (если он конечно есть), а также привести скрипты build.gradle к "общему знаменателю" - убрать, например, compile/runtime и другие устаревшие штуки.
Spyman
Меня удивляет, что такой продвинутый инструмент как java + gradle до сих пор не умеют в изоляцию графов зависимостей библиотек и проекта. Это кажется ужасной глупостью. Неужели нельзя было сделать какой нибудь дополнительный префикс к классу из пакета библиотеки, чтобы на уровне jvm не возникало конфликтов.
ultrinfaern
Иметь две разные библиотеки еще большая глупость. Представьте что это библиотеки логирования, тогда для каждой нужна своя конфигурация. И у каждой будет свой файл с логами. А если это ОРМ, то у нас будет несколько транзакций, несколько копий entity, это точно не взлетит.
Spyman
Ну очевидно что это должно быть опцией, а не принудительным вариантом. Но это вовсе не глупость - вот ты завязан на библиотеке от вендора/государства/легаси - а она осталась жить на старой версии зависимости - всё, ты блокирован в обновлении. В то время как иметь у себя две разных версии exoPlayer, rx. С orm тоже проблем не вижу - если библиотека пишет в ту-же таблицу что и ваш модуль - это что-то неадекватное, а если у неё своя таблица - какая вообще разница что там разные версии)
ultrinfaern
Если орм то это же не просто пишет в ту-же табличку это и определения классов, и транзакции (а управление ими это совсем другая бибилиотека у которой тоже свои требования к версии) и много еще чего.
Все-таки самый простой вариант - оставить одну бибилиотеку. А если апи не слишком изменилось, то оно может заработать и так. Или какую-то простенькую прокладку написать.
Spyman
Ключевое "может и заработать" - если повезёт, может быть. Это не бизнесовый подход - есть масса ситуаций когда например бизнесу нужно чтобы банковский модуль у вас был чужой - потому что там дешевле, а он тащит за собой конфликтные зависимости. И вы вынуждены с каждым обновлением подстраиваться и молиться.
Прь орм кстати всё таки не представляю кейс - был бы вам очень благодарен если вы прямо конкретный вариант конфликта опишите - я как будто что-то упускаю. Ну то есть орм ведь маппит таблицы и сущности на классы, но вы не лезете в базу либы (где другая версия орм), а общаетесь с ней классами которые она вам отдаёт, а даже если вы их в свою таблицу запишите и они смапятся там по другому, обратно то вы всё равно в либу отдадите класс на уровне jvm понимаемый либой (с префиксом).
Разве что realm какой нибудь, который не предоставляет реальные объекты, а лишь ленивые дата провайдеры, но даже с ним проблема решается вызовом copyFromRealm, да я и не знаю, что это за лютая либа, которая живые объекты рилма будет отдавать.
sshikov
Вы не представляете, насколько это реальный и типичный случай. Зачастую все даже еще хуже - потому что например, новая версия библиотеки, с одной стороны, не содержит уязвимостей, а с другой - требует более новой версии JVM. Ну или еще типовой неприятный случай - есть у вас скажем spring web, в которой в наличии кучка уязвимостей. Но - эти уязвимости - они в реализации серверной части, а вы используете например только RestTemplate, т.е. у вас клиент. Но при этом авторы оного spring web не подумали о таком сценарии, и разделили код так как разделили - то есть никак. И в одном компоненте лежит и серверная часть, и клиентская. И вот сидишь ты такой, и думаешь, как же мне доказать безопасникам (или сканеру типа SAST), что у тебя-то в приложении никаких уязвимостей нет, потому что кроме RestTemplate ничего не используется.
Ну и кстати, никакой BOM тут не помогает от слова совсем.
ChPr
Можно через Shadow плагин, но нужно руками все прописывать. Условно скинуть свой подграф в один Gradle модуль, применть Shadow с relocate и зависить в дальнейшем от него.
sshikov
Ну, сделать префикс к классу (или еще лучше - поменять пакет) может разработчик библиотеки. И это означает сломать API, вообще говоря. У такого подхода есть свои недостатки.
А пользователь библиотеки ограничен средствами системы сборки. Он может например использовать maven shade плагин, который сделает ровно то, что вы предлагаете (ну если я вас верно понял). А еще есть OSGI, где можно иметь несколько версий библиотек. достаточно несложно.