Композитная сборка как альтернатива buildSrc в Gradle

rbedwgjw12jetb3d3ajktzwaoss.png

В Gradle — системе автоматической сборки — подход с buildSrc уже успел стать стандартом для реализации собственных плагинов и задач, а также создания общих конфигураций, например списков зависимостей и версий. Но у него есть существенный недостаток: при изменении buildSrc кеш сборки становится недействительным.

При этом Gradle предоставляет альтернативный подход — композитные сборки, лишённые этого недостатка. В этой статье я расскажу, как использовать композитную сборку вместо buildSrc и с какими сложностями можно столкнуться при миграции.


Мой опыт конфигурирования Gradle

Система сборки Gradle для сборки Android приложений была представлена вместе с Android Studio. Последняя существовала не всегда: если вы разрабатываете Android-приложения более шести лет, то, вероятно, помните Eclipse с Android-плагином. Сегодня эта комбинация считается устаревшей и не поддерживается.

В то время Gradle и Groovy казались магией (и до сих пор кажутся) и я копировал всё подряд со Stack Overflow. Поскольку у приложений редко было больше одного модуля, считалось совершенно нормальным хранить всё в одном файле build.gradle.

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

// projectRoot/build.gradle

public void configureAndroid(Project project) {
  project.android {
    compileSdkVersion 9
  }
}
// projectRoot/app/build.gradle

configureAndroid(this)

android {
  // Module specific configuration
}

Стало намного лучше, но не идеально. Корневой файл build.gradle оказался слишком большим и сложным в поддержке. Когда в моду вошла модульность и мы начали разделять свои приложения на модули data, core, domain и presentation, я открыл другой подход: извлечь эти функции в отдельные скрипты Gradle и применять их.

// projectRoot/android.gradle

project.android {
  compileSdkVersion 9
}
// projectRoot/app/build.gradle

apply from: '../android.gradle'

Такому решению не хватает автодополнения в IDE, которое даже не получится добавить. В build.gradle эту проблему можно решить с помощью блока plugins { }, но для всех остальных файлов скриптов он недоступен.

Сейчас, годы спустя, многие разработчики, в том числе и я, используют buildSrc для управления общими конфигурациями. После многих лет страданий применение buildSrc в проектах стало настоящим счастьем. Вы можете использовать любой JVM-язык, иметь полноценное автодополнение и поддержку IDE. Вы можете даже писать тесты: модульные (с помощью JUnit или любого другого фреймворка) или интеграционные тесты, которые запускают отдельный экземпляр Gradle в тестовом окружении. Неужто мы, наконец, нашли святой Грааль конфигурирования Gradle?!


Недостаток buildSrc

К сожалению, вместе с обилием всех этих классных функций мы получаем и один большой недостаток. Любые изменения внутри buildSrc делают кеш сборки полностью недействительным. Также недействительным становится remote кеш, если вы его используете. В маленьких проектах это не является проблемой, однако в больших проектах с сотнями модулей всё гораздо сложнее. С другой стороны, изменения в файлах скриптов Gradle делают недействительным не весь кеш, а только некоторые задачи.

nblwynxdkowrcybuupj_lgy0wta.png

Представьте такую цепочку кешируемых задач: compile (Java-плагин) → report (наша собственная задача). compile имеет тип JavaCompile, который мы получили от встроенного Java-плагина. report — наша собственная задача, которую можно создать двумя способами: внутри buildSrc или build.gradle.

Теперь внесём в класс задачи report изменение, влияющее на байт-код. В случае с buildSrc-подходом задачи compile и report будут выполнены снова, даже если не менялись класс compile и входные и выходные данные. А при подходе с применением build.gradle снова будет выполнена лишь задача report. Входные и выходные данные, а также байт-код для задачи compile не менялись, поэтому результаты будут взяты из кеша. Gradle не может проверить, сгенерирует ли задача report такой же результат, поэтому запускает её снова, игнорируя кеш сборки.

Как нам сделать так, задача compile не выполнялась повторно? Ведь мы точно не хотим возвращаться назад и терять все эти классные функции buildSrc ради сохранения скорости сборки.


Композитные сборки

Говоря простым языком, композитная сборка содержит разные корневые проекты. Если у вас просто многомодульный проект, тогда все модули используют общую конфигурацию, которая задаётся корневым build.gradle. Однако есть способ добавить проект без влияния на него общей конфигурации. Это полезно для сборки внешних библиотек и частей вашего проекта, которые совершенно независимы друг от друга, а также для Gradle-плагинов. Вы сможете ссылаться на плагины по идентификатору из включённой (included) в основной проект сборки.

Если извлечь из папки buildSrc логику конфигурации, то классы из включённой сборки не будут расцениваться как часть buildSrc и Gradle не отметит кеш сборки как полностью недействительный. Логика конфигурации будет предоставляться основному проекту как внешняя зависимость (как и другие плагины, например Android Gradle plugin). В этом случае Gradle может корректно проверить входные и выходные данные задачи и использовать кеш сборки.

Важно отметить, что последующие изменения влияют только на кеш сборки. Он содержит сериализованные результаты выполнения задач с ключами, которые определяются входными данными и classpath. Когда результат выполнения задачи берётся из кеша, вы видите у задачи статус FROM-CACHE. Эти изменения не влияют на инкрементальные сборки, и задачи в любом случае будут становиться недействительными.

Перед запуском задачи Gradle может проверить, менялись ли входные и выходные данные после предыдущего запуска. Если не менялись, вы увидите статус UP-TO-DATE.


Миграция с buildSrc на композитную сборку

Теперь я расскажу о миграции нашей библиотеки Reaktive на композитную сборку. Это прекрасный пример по ряду причин:


  • у неё есть buildSrc, в котором находятся собственные плагины;
  • у неё есть внешний конфигурационный файл binary-compatibility.gradle;
  • у неё общая логика находится внутри корневого файла build.gradle.

То есть применены все три подхода, описанные в первой части статьи. Я покажу, как работать с каждым из них и как преобразовать их в отдельные плагины.


Копирование

Первый шаг прост. Скопируем папку buildSrc в папку buildSrc2. Если вы ещё не используете плагины в папке buildSrc, то самое время начать. Без плагинов классы из нового модуля не будут загружены в classpath скрипта. Пока не удаляйте исходную папку buildSrc, чтобы была возможность синхронизировать проект. Чтобы сообщить Gradle о появлении нового модуля, добавим в settings.gradle следующий код:

// projectRoot/settings.gradle.kts

pluginManagement {
    repositories {
        google()
        jcenter()
        gradlePluginPortal()
    }
}

includeBuild("buildSrc2")

// include(":module")

Первая строка добавлена для нашего удобства и для удаления блока buildscript { repositories { } }. С помощью функции includeBuild мы заставляем Gradle обращаться с проектом в папке buildSrc2 как с включённой сборкой.


Миграция плагинов

Как начать использовать плагины и классы из buildSrc2? Сначала объявим плагины.

// projectRoot/buildSrc2/build.gradle.kts

plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
}

gradlePlugin {
    // Объявим пустой плагин, чтобы с помощью него можно было загрузить классы
    plugins.register("class-loader-plugin") {
        id = "class-loader-plugin"
        implementationClass = "com.example.ClassLoaderPlugin"
    }
    // Или, если у вас уже есть плагины, их можно зарегистрировать здесь таким же образом
}

java-gradle-plugin создаст для плагинов соответствующие файлы properties, так что теперь эти файлы можно удалить. Почитать о java-gradle-plugin можно здесь.

Если у вас пока нет плагинов для управления зависимостями и вы используете только buildSrc, нужно создать пустой плагин и применить его к проекту, чтобы классы стали доступны в Gradle-скрипте.

// ClassLoaderPlugin.kt

class ClassLoaderPlugin: Plugin {
    override fun apply(target: Project) {
        // no-op
    }
}

// Deps.kt

object Deps {
    const val kotlinStdLib = "..."
}

После применения class-loader-plugin к проекту становится доступен класс Deps. И автодополнение будет работать как прежде.

// projectRoot/app/build.gradle

plugins {
    id 'class-loader-plugin'
}

dependencies {
    implementation(Deps.kotlinStdLib)
}


Миграция общих функций

В build.gradle есть функция setupMultiplatformLibrary.

// projectRoot/build.gradle

void setupMultiplatformLibrary(Project project) {
    project.apply plugin: 'org.jetbrains.kotlin.multiplatform'
    project.kotlin {
        sourceSets {
            commonMain {
                dependencies {
                    implementation Deps.kotlin.stdlib.common
                }
            }

            commonTest {
                dependencies {
                    implementation Deps.kotlin.test.common
                    implementation Deps.kotlin.test.annotationsCommon
                }
            }
        }
    }
}

Она определяет некую общую конфигурацию для всех модулей. Мы применили плагин Kotlin Multiplatform и объявили некоторые важные зависимости.

Для преобразования этой функции в Gradle-плагин нужно добавить зависимость и объявить его:

// projectRoot/buildSrc2/build.gradle.kts

dependencies {
    // Зависимость на Kotlin Gradle plugin, чтобы у нас появилась возможность использовать его классы
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72")
}
gradlePlugin {
    // Зарегистрируем наш плагин
    plugins.register("mpp-configuration") {
        id = "mpp-configuration"
        implementationClass = "com.badoo.reaktive.configuration.MppConfigurationPlugin"
    }
}
// MppConfigurationPlugin.kt

class MppConfigurationPlugin : Plugin {
    override fun apply(target: Project) {
        // Каждый плагин может зарегистрировать расширения как "kotlin" или "android"
        target.extensions.create("configuration", MppConfigurationExtension::class.java, target)
        // В этой функции сделаем всё то же самое, что и в оригинальной, только с использованием Kotlin
        setupMultiplatformLibrary(target)
    }

    private fun setupMultiplatformLibrary(target: Project) {
        // project.apply plugin: 'org.jetbrains.kotlin.multiplatform'
        target.apply(plugin = "org.jetbrains.kotlin.multiplatform")
        // project.kotlin {
        target.extensions.configure(KotlinMultiplatformExtension::class.java) {
            sourceSets {
                maybeCreate("commonMain").dependencies { implementation(Deps.kotlin.stdlib.common) }
                maybeCreate("commonTest").dependencies {
                    implementation(Deps.kotlin.test.common)
                    implementation(Deps.kotlin.test.annotationsCommon)
                }
            }
        }
    }
}

Также у нас есть параметризованная настройка для setupAllTargetsWithDefaultSourceSets с параметром isLinuxArm32HfpEnabled. Корутины не поддерживают linuxArm32Hfp, а мы поддерживаем. Поэтому нужно уметь настраивать модули как с поддержкой linuxArm32Hfp, так и без неё. Для этого можно отфильтровать project.name, но поддерживать отдельно такой список проектов не хочется. Давайте реализуем это с помощью расширения, как это делают другие плагины.

// MppConfigurationExtension.kt

open class MppConfigurationExtension @Inject constructor(
    private val project: Project
) {
    var isLinuxArm32HfpEnabled: Boolean = false
        private set

    // Мы вызовем этот метод, если в модуле нужна поддержка ARM32
    fun enableLinuxArm32Hfp() {
        if (isLinuxArm32HfpEnabled) return
        project.plugins.findPlugin(MppConfigurationPlugin::class.java)?.setupLinuxArm32HfpTarget(project)
        isLinuxArm32HfpEnabled = true
    }
}
// MppConfigurationPlugin.kt

class MppConfigurationPlugin : Plugin {
    override fun apply(target: Project) {
        target.extensions.create("configuration", MppConfigurationExtension::class.java, target)
        ...
    }

    fun setupLinuxArm32HfpTarget(project: Project) {
        if (!Target.shouldDefineTarget(project, Target.LINUX)) return
        project.kotlin {
            linuxArm32Hfp()
            sourceSets {
                maybeCreate("linuxArm32HfpMain").dependsOn(getByName("linuxCommonMain"))
                maybeCreate("linuxArm32HfpTest").dependsOn(getByName("linuxCommonTest"))
            }
        }
    }
}

К сожалению, здесь мы не можем сделать наоборот (disableLinuxArm32Hfp() и включать по умолчанию), потому что плагин Kotlin не обрабатывает удаление целевой платформы компиляции (только добавление). Зато мы можем применить конфигурацию с помощью плагина mpp-configuration и его расширения configuration.

// projectRoot/reaktive/build.gradle

plugins {
    id 'mpp-configuration'
}

// Опционально
configuration {
    enableLinuxArm32Hfp()
}

Автодополнение работает как надо.

josjupvt52-cig6vgz59g9hhxt0.png


Миграция внешнего файла скрипта

Мы используем Binary compatibility validator, который я описывал в другой статье. Его конфигурация определяется внутри binary-compatibility.gradle и применяется в корневом файле build.gradle. По сути, скрипт просто применяет плагин и задаёт игнорируемые модули.

// projectRoot/binary-compatibility.gradle

if (Target.shouldDefineTarget(target, Target.ALL_LINUX_HOSTED)) {
    apply plugin: kotlinx.validation.BinaryCompatibilityValidatorPlugin

    apiValidation {
        ignoredProjects += [
                'benchmarks',
                'jmh',
                'sample-mpp-module',
                'sample-android-app',
                'sample-js-browser-app',
                'sample-linuxx64-app',
                'sample-ios-app',
                'sample-macos-app'
        ]
    }
}

Теперь можно преобразовать этот скрипт в плагин с помощью описанного выше подхода.

// projectRoot/buildSrc2/build.gradle.kts

dependencies {
    // Добавим зависимость на Binary Compatibility Plugin
    implementation("org.jetbrains.kotlinx:binary-compatibility-validator:0.2.3")
}
gradlePlugin {
    // Зарегистрируем наш плагин
    plugins.register("binary-compatibility-configuration") {
        id = "binary-compatibility-configuration"
        implementationClass = "com.badoo.reaktive.compatibility.BinaryCompatibilityConfigurationPlugin"
    }
}
// BinaryCompatibilityConfigurationPlugin.kt

class BinaryCompatibilityConfigurationPlugin : Plugin {
    override fun apply(target: Project) {
        if (Target.shouldDefineTarget(target, Target.ALL_LINUX_HOSTED)) {
            target.apply(plugin = "binary-compatibility-validator")
            target.extensions.configure(ApiValidationExtension::class) {
                ignoredProjects.addAll(
                    listOf(
                        "benchmarks",
                        "jmh",
                        "sample-mpp-module",
                        "sample-android-app",
                        "sample-js-browser-app",
                        "sample-linuxx64-app",
                        "sample-ios-app",
                        "sample-macos-app"
                    )
                )
            }
        }
    }
}

Затем применим наш новый плагин внутри корневого файла build.gradle.

// projectRoot/build.gradle

plugins {
    id 'binary-compatibility-configuration'
}


Зависимости

С нашим новым подходом необходимо управлять зависимостями в основном проекте и во включённых сборках. В последних мы будем использовать в качестве зависимостей разные плагины, чтобы обеспечить доступ к соответствующим классам, таким как расширения проектов, задачи и т. д. Для управления зависимостями создадим ещё одну включённую сборку.

// rootProject/dependencies/build.gradle.kts

plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
}

// Зададим параметры публикации
group = "com.badoo.reaktive.dependencies"
version = "SNAPSHOT"

repositories {
    jcenter()
}

gradlePlugin {
    // Создадим пустой плагин, чтобы получить доступ к классам
    plugins.register("dependencies") {
        id = "dependencies"
        implementationClass = "com.badoo.reaktive.dependencies.DependenciesPlugin"
    }
}

Теперь мы можем создать класс Deps для всех внешних зависимостей. Посмотреть реализацию можно здесь. Добавим в settings.gradle новый модуль с помощью includeBuild("dependencies"). Теперь можно в любом проекте использовать плагин dependencies и класс Deps.

// projectRoot/buildSrc2/build.gradle.kts

import com.badoo.reaktive.dependencies.Deps

plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
    id("dependencies")
}

dependencies {
    // Теперь в другой включённой сборке можно использовать Deps
    implementation(Deps.kotlin.plugin)
    // implementation(implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT"))
}

// Почти то же самое, что и implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT"), но с работающим автодополнением
kotlin.sourceSets.getByName("main").kotlin.srcDir("../dependencies/src/main/kotlin")

В процессе реализации этого подхода я обнаружил, что если использовать включённую сборку в качестве зависимости внутри другой включённой сборки, то IDEA не подсвечивает её классы в редакторе (хотя проект компилируется без ошибок). Чтобы это исправить, я вручную добавил класс Deps в компиляцию buildSrc2, чтобы он был доступен везде, где применяются плагины из buildSrc2. Это довольно грубый и нестабильный костыль, но я надеюсь, что это поведение исправят. После этого достаточно будет использовать обычную нотацию implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT").

Плагин dependencies можно использовать в модулях основного проекта вышеописанным способом.


Недостатки

Единственной разницей между использованием композитных сборок и buildSrc является доступность классов без соответствующих плагинов. Она проявляется, когда мы применяем блок plugins { } вместо apply plugin: 'id' или прямых вызовов функций. Главное достоинство использования блока plugins в том, что в Groovy-скриптах работает автодополнение. У вас будет доступ к классам и расширениям, относящимся к конкретным плагинам. Но вы не можете быть уверены в том, что расширения полностью сконфигурированы на момент применения плагина.

Допустим, у вас такой набор плагинов:

apply plugin: 'android-library'

android {
    compileSdkVersion 30
}

apply plugin: 'custom-plugin'
class CustomPlugin: Plugin {
    override fun apply(target: Project) {
        target.logger.warn(
            target.extentions.getByType(BaseExtension::class)
                .compileSdkVersion.toString()
        )
    }
}

Плагин при конфигурировании просто выводит в консоли текущий compileSdkVersion. В этой реализации будет выведено значение 30. Теперь попробуем использовать блок plugins { }.

plugins {
    id 'android-library'
    id 'custom-plugin'
}

android {
    compileSdkVersion 30
}

Теперь вы увидите в консоли null, потому что custom-plugin применился до конфигурации android. Исправить это можно несколькими способами:


  1. Продолжать использовать apply plugin: 'custom-plugin' или статические функции. Если мигрировать трудно, то никто не запрещает делать по-старому.
  2. Использовать внутри плагина блок project.afterEvaluate { }. Но будьте осторожны: если злоупотребить этим, то блоки afterEvaluate начнут зависеть от других блоков afterEvaluate и порядка их исполнения.
  3. Попробовать преобразовать логику в плагине с помощью ленивого API, задач и других механизмов. Для этого нужно хорошо разбираться в Gradle API, но зато вы сможете создавать переиспользуемые и независимые плагины.


Заключение

Композитную сборку можно использовать в качестве альтернативы buildSrc, чтобы избегать инвалидации кеша Gradle. Перейти на неё можно просто и безболезненно с помощью предложенного подхода. Чтобы оценить все возможности автодополнения в Groovy-скриптах, нужно использовать блоки plugins { }. А если у вас нет плагинов, то просто создайте пустой плагин и примените его для загрузки классов из того же модуля.

© Habrahabr.ru