Привет Хабр!

В каждой компании (а если она крупная, то, скорее всего, в каждом подразделении) должна быть выстроена культура использования 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")
}
...

Ссылки на репозитории

Дополнительный материал

Комментарии (9)


  1. Spyman
    06.01.2024 09:01

    Меня удивляет, что такой продвинутый инструмент как java + gradle до сих пор не умеют в изоляцию графов зависимостей библиотек и проекта. Это кажется ужасной глупостью. Неужели нельзя было сделать какой нибудь дополнительный префикс к классу из пакета библиотеки, чтобы на уровне jvm не возникало конфликтов.


    1. ultrinfaern
      06.01.2024 09:01
      +2

      Иметь две разные библиотеки еще большая глупость. Представьте что это библиотеки логирования, тогда для каждой нужна своя конфигурация. И у каждой будет свой файл с логами. А если это ОРМ, то у нас будет несколько транзакций, несколько копий entity, это точно не взлетит.


      1. Spyman
        06.01.2024 09:01

        Ну очевидно что это должно быть опцией, а не принудительным вариантом. Но это вовсе не глупость - вот ты завязан на библиотеке от вендора/государства/легаси - а она осталась жить на старой версии зависимости - всё, ты блокирован в обновлении. В то время как иметь у себя две разных версии exoPlayer, rx. С orm тоже проблем не вижу - если библиотека пишет в ту-же таблицу что и ваш модуль - это что-то неадекватное, а если у неё своя таблица - какая вообще разница что там разные версии)


        1. ultrinfaern
          06.01.2024 09:01

          Если орм то это же не просто пишет в ту-же табличку это и определения классов, и транзакции (а управление ими это совсем другая бибилиотека у которой тоже свои требования к версии) и много еще чего.

          Все-таки самый простой вариант - оставить одну бибилиотеку. А если апи не слишком изменилось, то оно может заработать и так. Или какую-то простенькую прокладку написать.


          1. Spyman
            06.01.2024 09:01

            Ключевое "может и заработать" - если повезёт, может быть. Это не бизнесовый подход - есть масса ситуаций когда например бизнесу нужно чтобы банковский модуль у вас был чужой - потому что там дешевле, а он тащит за собой конфликтные зависимости. И вы вынуждены с каждым обновлением подстраиваться и молиться.

            Прь орм кстати всё таки не представляю кейс - был бы вам очень благодарен если вы прямо конкретный вариант конфликта опишите - я как будто что-то упускаю. Ну то есть орм ведь маппит таблицы и сущности на классы, но вы не лезете в базу либы (где другая версия орм), а общаетесь с ней классами которые она вам отдаёт, а даже если вы их в свою таблицу запишите и они смапятся там по другому, обратно то вы всё равно в либу отдадите класс на уровне jvm понимаемый либой (с префиксом).

            Разве что realm какой нибудь, который не предоставляет реальные объекты, а лишь ленивые дата провайдеры, но даже с ним проблема решается вызовом copyFromRealm, да я и не знаю, что это за лютая либа, которая живые объекты рилма будет отдавать.


        1. sshikov
          06.01.2024 09:01

          вот ты завязан на библиотеке от вендора/государства/легаси - а она осталась жить на старой версии зависимости - всё, ты блокирован в обновлении. 

          Вы не представляете, насколько это реальный и типичный случай. Зачастую все даже еще хуже - потому что например, новая версия библиотеки, с одной стороны, не содержит уязвимостей, а с другой - требует более новой версии JVM. Ну или еще типовой неприятный случай - есть у вас скажем spring web, в которой в наличии кучка уязвимостей. Но - эти уязвимости - они в реализации серверной части, а вы используете например только RestTemplate, т.е. у вас клиент. Но при этом авторы оного spring web не подумали о таком сценарии, и разделили код так как разделили - то есть никак. И в одном компоненте лежит и серверная часть, и клиентская. И вот сидишь ты такой, и думаешь, как же мне доказать безопасникам (или сканеру типа SAST), что у тебя-то в приложении никаких уязвимостей нет, потому что кроме RestTemplate ничего не используется.

          Ну и кстати, никакой BOM тут не помогает от слова совсем.


    1. ChPr
      06.01.2024 09:01

      Можно через Shadow плагин, но нужно руками все прописывать. Условно скинуть свой подграф в один Gradle модуль, применть Shadow с relocate и зависить в дальнейшем от него.


    1. sshikov
      06.01.2024 09:01

      Ну, сделать префикс к классу (или еще лучше - поменять пакет) может разработчик библиотеки. И это означает сломать API, вообще говоря. У такого подхода есть свои недостатки.

      А пользователь библиотеки ограничен средствами системы сборки. Он может например использовать maven shade плагин, который сделает ровно то, что вы предлагаете (ну если я вас верно понял). А еще есть OSGI, где можно иметь несколько версий библиотек. достаточно несложно.


  1. navrotski
    06.01.2024 09:01
    +2

    Я столкнулся с тем, что если у некоторых компонентов (отдельных java проектов) файл build.gradle (да и сам gradle) не обновлялся достаточно давно, то задействование bom с помощью platform (и тем более force platform) приносит кучу проблем, по сути ломая механизм разрешения зависимостей. Так что лучше перед использованием bom избавиться от зоопарка версий gradle (если он конечно есть), а также привести скрипты build.gradle к "общему знаменателю" - убрать, например, compile/runtime и другие устаревшие штуки.