Создание и использование BOM в Gradle

3f85539fa115bceaf9bcc88ca75dcb24.png

Привет Хабр!

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

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

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

© Habrahabr.ru