Пошук правильного балансу в стратегії залежностей Gradle

Пошук правильного балансу в стратегії залежностей Gradle

Опубліковано 25.11.2023 | Востаннє оновлено 03.01.2026

Вступ

У динамічній сфері розробки програмного забезпечення, опанування управлінням залежностями є важливим. З Gradle розробники мають численні стратегії для оголошення залежностей, плагінів та версій. Ми розглянемо ці методи та проаналізуємо причини вибору кожного підходу, заглиблюючись у їхні переваги та недоліки.

🎉 Ура! Ця стаття була включена до інформаційного бюлетеня Gradle за лютий 2024 року.

Оригінал був опублікований на dev.to — https://dev.to/y9vad9/finding-the-right-balance-in-gradle-dependency-strategy-4jdl

Навіщо?

Перш ніж занурюватися в методи, важливо зрозуміти значення добре структурованих залежностей. Які потенційні проблеми можуть виникнути в майбутньому, і які проблеми повинна вирішувати правильна структура? Давайте розглянемо.

Оновлення ваших залежностей

У багатомодульних проєктах оновлення залежностей, таких як версії Kotlin або версії бібліотек, може швидко перетворитися на складне завдання. Уявіть, що вам потрібно пройтися через кожен файл build.gradle.kts, знайти блок плагінів та змінити функцію версії для кожної залежності або плагіна. Справжній виклик виникає, коли ви маєте справу зі значною кількістю модулів. Неймовірно легко пропустити конкретну залежність, що призведе до конфліктів версій або, у випадках, як-от з Jetpack Compose, до проблем із нестабільними API, тісно пов'язаними з конкретними версіями компілятора Kotlin (що може легко зламати вашу збірку з неочікуваними помилками). Ви ж не хочете витрачати години на дебагінг через прості помилки, чи не так?

Вразливості безпеки

У сфері безпеки програмного забезпечення вразливості у вашому коді можуть становити значні ризики. Однак багато сканерів вразливостей мають проблеми із залежностями, які частково визначені в інших файлах, ніж build.gradle.kts (наприклад, версії у файлах properties). Важливо впроваджувати практики, що узгоджуються з системами безпеки, забезпечуючи надійність і безпеку вашого коду.

Відсутність централізації

У менших проєктах ручне управління залежностями може здаватися прийнятним. Однак, у міру розширення проєкту та команди, цей підхід швидко стає викликом. Набагато ефективніше виявляти проблеми в конкретному файлі (файлах), ніж перебирати численні конфігураційні файли, намагаючись ідентифікувати потенційні проблеми.

Централізація управління залежностями покращує документацію. Це дозволяє чітко документувати конкретні рішення, такі як вибір версій. Такий централізований підхід спрощує залишення нотаток або завдань (todos) для рев'ю або впливу на залежності. Послідовне форматування мінімізує плутанину та покращує читабельність, сприяючи спільному розумінню залежностей проєкту. Це створює ефективніше середовище для співпраці команди.

Нарешті, централізований підхід пропонує послідовність у стилі визначення. Коли всі дотримуються стандартизованого формату та структури, це мінімізує плутанину та покращує читабельність. Це сприяє цілісному розумінню залежностей проєкту, роблячи співпрацю більш плавною та ефективною.


Загалом, централізоване оголошення ваших методів робить вашу конфігурацію збірки чистішою, зрозумілішою та легшою для підтримки новачками.

Рішення

Тепер, коли ми вже обговорили можливі проблеми, давайте визначимо наші головні цілі:

  • Простота
  • Безпека
  • Підтримуваність

Зараз ми запропонуємо рішення, обговорюючи їхні проблеми та переваги. Почнемо з найпростішого.

Properties як контейнер версій

У постійно змінному ландшафті управління збірками Gradle використання файлів .properties як контейнерів версій залишається поширеним підходом. Цей метод централізує версії залежностей, забезпечуючи узгодженість у всьому проєкті.

Наприклад, ви можете додати ваші версії до gradle.properties:

kotlinVersion=1.9.20-RC
coroutinesVersion=1.7.3

І отримати доступ до них у вашому build.gradle.kts:

// ... plugins, repositories

val coroutinesVersion by project // це автоматично розрішить властивість за назвою

dependencies {
	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
}

Але це має очевидні недоліки:

  • Відсутність підтримки плагінів: Оскільки plugins оцінюється раніше за будь-який інший код у build.gradle.kts і має обмеження на операції всередині, ви не можете використовувати властивості (properties) для надання версій вашій збірці.
  • Бойлерплейт: Це додає багато шаблонного коду (якщо у нас більше однієї залежності, що, очевидно, завжди так), який захаращує іншу логіку і робить її менш підтримуваною та зрозумілою.

Однією з переваг є те, що ви можете перевизначати властивості на етапі збірки Gradle (Gradle Build stage), але вам, ймовірно, це ніколи не знадобиться.

У деяких випадках ви можете комбінувати такий підхід з іншим, якщо вам це дійсно потрібно, але для більшості проєктів це погана ідея.

Залежності як константи

Оскільки Gradle підтримує композитні білди (composite builds) та конвенційний модуль [buildSrc] (примітка: він також розглядається як композитний білд, але неявно), ви можете просто створити сінглтон (singleton) мовою Kotlin / Java / Groovy з константами, які мають визначення залежностей та версій, і використовувати його безпосередньо у ваших збірках наступним чином:

object Deps {
	object Libs {
		object Kotlinx {
			const val Coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}"
		}
	}

	object Versions {
		const val kotlin = "1.9.20-RC"
		const val coroutines = "1.7.3"
	}

	object Plugins {
		object Kotlin {
			// візьмемо jvm плагін просто для прикладу
			const val Id = "org.jetbrains.kotlin.jvm"
		}
	}
}

У випадку buildSrc, він завжди знаходиться в classpath (контексті) конфігурацій вашого Gradle Build, і весь код з src/main/kotlin (або будь-якого іншого, якщо застосовно) доступний у кожному build.gradle.kts в межах проєкту. Для композитних білдів ви повинні зареєструвати Gradle плагін і визначити його в бажаному модулі (ми не будемо обговорювати це в цій конкретній статті, це просто для вашого відома).

Навіть у цьому рішенні ми маємо кілька варіантів того, як ми можемо використовувати такий підхід у підсумку, але давайте використаємо найкращий, на мою думку. У файлі build.gradle.kts вашого кореневого gradle-проєкту ви можете зробити наступне:

plugins {
	// ми повинні робити це для кожного плагіна, який ми використовуємо в модулях
	// це вплине на classpath і звільнить нас від вказування версії
	// кожного разу
	id(Deps.Plugins.Kotlin.Id) version (Deps.Versions.kotlin) apply false
}

Пояснення У Gradle кореневий модуль задає тон для багатомодульного проєкту. Конфігурації, плагіни та залежності, визначені в кореневому модулі, автоматично поширюються на всі підмодулі. Це забезпечує єдине середовище збірки для всього проєкту. Ієрархічна структура спрощує управління, дозволяючи спільні налаштування в корені з гнучкістю для кастомізації в окремих підмодулях.

У наших модулях ми можемо використовувати цей плагін і нашу залежність наступним чином:

plugins {
	// тепер нам не потрібно вказувати версію, вона вже є в classpath
	id(Deps.Plugins.Kotlin.Id)
}

// ... repositories

dependencies {
	implementation(Deps.Libs.kotlinxCoroutines)
}

Які переваги?

  1. Доступність у коді: У випадку композитних білдів ми можемо використовувати це як звичайний код без будь-яких проблем з інших композитних білдів (мій приклад) або навіть всередині нашого коду. Що стосується каталогів версій (version catalogs), які ми обговоримо далі, ви не можете використовувати їх як звичайний код у композитних білдах (важливе зауваження: ви не можете використовувати його всередині src/main/kotlin, але всередині його конфігурації можете) без хаків (також це можливо лише в межах правильного контексту).
  2. Краща навігація та рефакторинг: Використання одного й того ж механізму у всьому вашому коді покращує підтримку IDE, роблячи навігацію та рефакторинг ефективнішими. Ця вбудована узгодженість покращує загальний досвід розробки.

Тепер поговоримо про недоліки цього методу:

  • Відсутність стандартизації: Оскільки це не рекомендовано Gradle і не є поширеною практикою в спільноті, це робить логіку вашої збірки складнішою та менш зрозумілою.
  • Сканування безпеки: Майже всі сканери безпеки (крім сканерів, які працюють як gradle плагіни) не підтримують цей метод (наприклад, відповідна проблема в Dependabot), повністю усуваючи ефективність аналізу безпеки в проєкті.
  • Автоматичне оновлення: Такий метод не має жодної підтримки функції автоматичного оновлення / міграції в IDE (і, як згадувалося раніше, те саме стосується Dependabot).

Певні проблеми з методами управління залежностями дійсно можуть бути вирішені (або проігноровані як неважливі для певних проєктів), хоча і не без компромісів.

Підсумовуючи, цей метод не є найбільш підтримуваним або безпечним, йому бракує інтеграції з потужними інструментами, такими як Dependabot. Вони відносно прості, але мають такі обмеження. У майбутньому це може бути вирішено, але наразі це велика проблема, якщо ви хочете використовувати Dependabot.

📝 Примітка Це також стосується інших варіантів, наприклад, файлів dependencies.gradle, написаних на Groovy, які надають константи для ваших скриптів збірки – це не перевіряється більшістю сканерів.

💡 Бонус Щодо buildSrc, це також створює виклики. Його використання може перешкоджати повторному використанню, додавати складності та ускладнювати поступові міграції проєкту. Це може навіть призвести до проблем з classpath та проблем продуктивності в складних сценаріях використання. Отже, я уникаю використання buildSrc у своїх проєктах через ці потенційні ускладнення. Для композитних білдів це не завжди так, але в будь-якому випадку робить вашу конфігурацію збірки менш зрозумілою.

Version catalogs (Каталоги версій)

Тепер давайте обговоримо відносно недавню інновацію в Gradle — Version catalogs. Що таке version catalogs?

❓ Визначення Version catalog (Каталог версій) — це централізований файл (у форматі TOML) у проєкті Gradle, що містить структуровану інформацію про версії для бібліотек та плагінів (зазвичай розташований у gradle/libs.versions.toml).

Ось приклад такого визначення:

[versions]
kotlin = "1.9.20-RC"
coroutines = "1.7.3"

[libraries]
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

Всередині build.gradle.kts:

plugins {
	// є спеціальна функція для визначення version catalog
	alias(libs.plugins.kotlin.jvm)
}

dependencies {
	implementation(libs.kotlinx.coroutines)
}

📝 Примітка Ви можете створити кілька TOML файлів визначень, для деталей, будь ласка, зверніться до офіційного посібника.

Чому version catalogs?

  1. Стандартизація та знайомість: Version catalogs рекомендовані Gradle, що робить їх зрозумілими для широкого кола розробників. Їхній структурований підхід спрощує управління залежностями, роблячи його доступним для широкої аудиторії.
  2. Сумісність з Dependabot: Version catalogs безшовно інтегруються з інструментами, такими як Dependabot, забезпечуючи, щоб ваш проєкт залишався оновленим до останніх версій бібліотек. Ця сумісність оптимізує процес управління залежностями та усунення вразливостей безпеки.
  3. Підтримка IDE: IDE, такі як IntelliJ IDEA, надають вбудовану підтримку для version catalogs. Розробники можуть зручно шукати оновлення безпосередньо в IDE, покращуючи робочий процес розробки та сприяючи ефективному управлінню залежностями.

📝 Примітка Існує кілька способів визначення version catalogs, але, наприклад, Dependabot підтримує лише читання з файлу (варто зазначити, що це покриє більшість випадків). Але це може бути вирішено в майбутньому.

Недоліки:

  1. Обмеження в композитних білдах: Version catalogs мають труднощі з інтеграцією генерованого коду в код композитних білдів. Це обмеження перешкоджає гнучкості використання генерованого коду як звичайних компонентів, вимагаючи додаткових зусиль та обхідних шляхів у сценаріях композитних білдів (і не тільки).
  2. Рефакторинг: Якщо ви хочете змінити імена або шляхи ваших залежностей, вам доведеться змінювати це у ваших конфігураціях збірки вручну, оскільки немає вбудованої підтримки цього в IDE (принаймні, на момент написання цієї статті).

Загалом, я обираю цей метод для всіх моїх нових проєктів, оскільки це вже стандарт.

Classpath (Класпас)

Існує кілька способів, як ви можете надати версії ваших плагінів, поширюючи їх через classpath.

BuildScript

У старіших версіях Gradle вказування версій плагінів безпосередньо в блоці buildscript було поширеною практикою. Це дозволяло розробникам явно визначати версію плагіна. Ось як це зазвичай робилося:

buildscript { 
	repositories { 
		mavenCentral() 
	} 
	
	dependencies { 
		classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20-RC") 
	} 
}

У цьому підході версія Kotlin Gradle плагіна (1.9.20-RC у цьому прикладі) оголошена в блоці buildscript. Це обмеження версії гарантує, що проєкт використовує вказану версію плагіна (але це може бути перезаписано підмодулями за допомогою спеціальних зусиль).

Після того, як версія плагіна вказана в блоці buildscript, застосування плагіна стає спрощеним. Розробники можуть застосовувати плагін без явного вказування його версії:

plugins {
	kotlin("jvm")
}

У цьому фрагменті плагін kotlin("jvm") застосовується без згадки версії. Gradle автоматично посилається на версію, вказану в блоці buildscript (якщо точніше, buildscript оцінюється перед будь-яким іншим блоком у вашому скрипті та додає вказані залежності в classpath, який завжди використовується для розв'язання плагінів та залежностей без вказаної версії; також це примушує використання конкретної версії, оскільки ви не можете мати той самий плагін, але з різними версіями).

Плюси:

  • Явне версіонування: Обмеження версії чітко визначено в скрипті збірки, гарантуючи, що проєкт використовує конкретну версію плагіна.
  • Узгоджені версії: Усі модулі в проєкті автоматично використовують вказану версію, сприяючи узгодженості.
  • Автоматичне оновлення: Intellij Idea (а також Android Studio) підтримує автоматичну міграцію таких оголошень.

Мінуси:

  • Накладні витрати на підтримку: Ручне оновлення версії в блоці buildscript для кожного плагіна може бути нудним, особливо у великих проєктах з численними плагінами.
  • Менш лаконічні скрипти збірки: Блок buildscript додає багатослівності скрипту збірки, потенційно роблячи його важчим для читання та підтримки, особливо зі збільшенням кількості плагінів.
  • Сумісність зі сканерами безпеки: Dependabot не підтримує перевірку вразливостей таких оголошень.

Хоча цей метод широко використовувався в минулому, зараз він вважається менш рекомендованим через накладні витрати на підтримку та багатослівність, які він вносить.

❓ Пояснення Сучасні практики Gradle не рекомендують вказувати версії плагінів безпосередньо в блоці buildscript з практичних міркувань. Натомість, рекомендується централізувати управління версіями за допомогою version catalogs або pluginManagement (ми обговоримо це нижче). Підхід plugin management дозволяє, наприклад, динамічне розв'язання версій, спрощуючи скрипти збірки та запобігаючи непотрібному захаращенню.

pluginManagement

Як обговорювалося раніше, це сучасний спосіб оголошення версії вашого плагіна, репозиторіїв та правил розв'язання. Приклад:

pluginManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
        google()
    }
    plugins {
        id("org.jetbrains.kotlin.jvm") version "1.9.20-RC"
    }
}

Крім того, ви можете примусово встановити конкретні версії, використовуючи resolutionStrategy:

pluginManagement {
    repositories {
        mavenCentral()
    }
    resolutionStrategy {
        eachPlugin {
            if (requested.id.namespace == "com.example") {
                useVersion("1.2.3") // Вкажіть бажану версію тут
            }
        }
    }
}
Плюси pluginManagement:
  1. Відсутність синтаксису обмежень: Ви можете використовувати версії з властивостей (properties) або будь-якого іншого підтримуваного джерела. Ви не могли б зробити те саме з плагінами, тому що блок plugins завжди оцінюється окремо від іншої частини скрипта збірки.
  2. Динамічне розв'язання версій: Ви можете замінювати версії або цілі джерела плагінів, використовуючи resolutionStrategy.
Мінуси pluginManagement:
  1. Потенційна складність:
    • Мінус: Надмірне використання (або використання без реальної потреби) може призвести до складних скриптів, які важче підтримувати.
  2. Сумісність зі сканерами безпеки: Dependabot не підтримує перевірку вразливостей таких оголошень.

Загалом, цю функцію слід використовувати лише якщо у вас є особлива потреба, розгляньте простіші варіанти, якщо все, що вам потрібно, — це контроль версій.

BOM (Bill of Materials)

BOM (Bill of Materials) у Gradle — це інструмент централізованого управління версіями для кількох залежностей. Це файл, який перелічує сумісні версії бібліотек та їхні залежності. Імпорт BOM забезпечує узгоджені версії, мінімізуючи конфлікти в складних проєктах. BOM спрощують контроль версій, забезпечуючи стабільність та узгодженість у всьому проєкті.

Ось приклад оголошення BOM:

javaPlatform {
    allowDependencies()
}

dependencies {
    constraints {
        api("org.slf4j:slf4j-api:2.0.9") 
        api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
        // ...
    }
}

І як його використовувати:

dependencies {
	implementation(enforcedPlatform(project(":some-bom")))
}

Як саме це допомагає з організацією проєкту?

  • Запобігає конфліктам: Забезпечує використання модулями сумісних версій, уникаючи конфліктів.
  • Можливість публікації (Publishable): Ви можете легко опублікувати ваш BOM в Maven, наприклад, для споживачів вашої бібліотеки; ви не можете зробити те саме з version catalogs (з такою ж простотою), properties тощо.
  • Сумісність зі сканерами: формат .pom, який використовується для BOM, що публікуються, легко розпізнається більшістю сканерів (важливе зауваження: які сканують maven, Dependabot не підтримує це безпосередньо; але ви завжди можете використовувати це разом з version catalogs, наприклад).

Це особливо корисно у випадках, коли ваші бібліотеки тісно пов'язані з конкретними версіями інших бібліотек, API компілятора тощо.

Основним недоліком є ймовірність того, що вам може не знадобитися такий рівень оголошення залежностей і ви можете надати перевагу простішим варіантам, таким як version catalogs.

Висновок

Ми розглянули різні методи визначення залежностей та управління плагінами та їхніми версіями. Важливо підкреслити, що ваш вибір повинен узгоджуватися з конкретними вимогами вашого проєкту. Наприклад, блок pluginManagement може обробляти версії плагінів, але він може надмірно ускладнити вашу конфігурацію збірки без надання суттєвих переваг. Аналогічно, вибір констант у композитних білдах або buildSrc повинен бути рішенням, заснованим на реальній необхідності, враховуючи як переваги, так і потенційні недоліки. Завжди оцінюйте реальні потреби вашого проєкту, перш ніж робити цей вибір.