Приветствую, Gradle-адепт. В статье тебя ждёт авторский тур по Gradle Task. В маршрут включено хождение по граблям, изучение секретных практик buildscript-тасок, проведение раскопок по deprecated API, а ближе к концу зарядимся силой Custom Gradle Task, попрактикуемся в строительстве билд-кеша и узнаем, кто такой Worker API.
В предыдущих двух топиках разобрали основные принципы работы Gradle и работу Gradle Plugin. Кому интересно, добро пожаловать:
Готовьсь, цельсь, пли! Как не обжечься при сборке Gradle-приложения, и настолько ли всё серьезно?
Дикая природа Gradle Task: руководство по выживанию
В этой статье я постарался раскрыть основные особенности Gradle Task, изучив которые, можно подходить к решению боевых задач вооружившись. Для удобства навигации по статье, ниже представляю оглавление. Можно смело переходить к интересующей вас теме уже сейчас:
Эффективное создание Buildscript Gradle Task
Передача аргументов в Buildscript Gradle Task
Зависимости между Gradle-тасками
Расширяем поведение Gradle Task
Резюме по использованию Buildscript Gradle Task
Передача аргументов через командную строку
Что такое Gradle Task?
Gradle Task представляет собой атомарную единицу работы при сборке проекта. Например, это может быть компиляция классов, создание Javadoc, публикация в репозиторий и т. д. Gradle-таска является рабочим, который выполняет строго возложенную на него обязанность. Рабочих можно упорядочить так, чтобы результатом их работы стал собранный проект.
Перед выполнением Gradle помечает таску одним из доступных состояний. Состояние таски основано на том, есть ли у неё задачи, которые нужно выполнить и принесёт ли выполнение этих задач какие-либо изменения.
Состояния Gradle Task
Всего для Gradle Task представлено 5 состояний, в первых четырёх из которых таска пропускает своё выполнение.
-
UP-TO-DATE - возникает, если у таски не изменились входные и выходные параметры. Это происходит в одном из следующих случаев:
Входные и выходные параметры таски не изменились с момента её последнего выполнения;
Таска самостоятельно сообщила о состоянии UP-TO-DATE;
Все зависимости таски находятся в состояниях UP-TO-DATE / SKIPPED / FROM-CACHE / NO-SOURCE;
У таски нет зависимостей и задач к выполнению.
FROM-CACHE - Таска не выполняется, поскольку результат её работы был получен из билд-кеша.
SKIPPED - Таска не выполняется, поскольку она была явно исключена из выполнения или содержит предикат
onlyIf
, который вернулfalse
.NO-SOURCE - У таски есть входные и выходные параметры, но входные параметры не содержат source-файлов, над которыми необходимо проводить какие-либо действия. Например, нет .java файлов для компиляции.
EXECUTED - Таска выполняется.
Зачем так много состояний, при которых таска не выполняется - спросите вы. Какого-то сакрального смысла здесь нет, но так намного проще ориентироваться в том, что и когда происходит при выполнении сборки. Несложно догадаться, что наиболее желательным состоянием, к которому стоит стремиться при разработке, является UP-TO-DATE. По ходу статьи мы с вами будем рассматривать примеры и по возможности делать так, чтобы состояние UP-TO-DATE достигалось наиболее часто.
Hello, Gradle!
Для начала создадим примитивную таску, которая будет выводить сообщение в консоль при сборке проекта. Самый простой способ создать таску для Gradle – реализовать её в buildscript-е, давайте так и поступим. Для примеров, как и раньше, я буду использовать Kotlin DSL. Не особо задумываясь, пробуем написать в build.gradle.kts следующее:
val myFirstTask: Task = tasks.create("myFirstTask") {
println("Hello, Gradle!")
}
Жмём Gradle Sync и видим следующее:
> Configure project :
Hello, Gradle!
Здесь закрадываются подозрения, что что-то идёт не так. Покопавшись в исходниках и документации становится понятно, что с помощью лямбды в функции create
выполняется конфигурация таски, а для выполнения кода при сборке проекта следует воспользоваться функциями doFirst
и doLast
. Так и поступим:
val myFirstTask: Task = tasks.create("myFirstTask") {
doFirst {
println("Hello, Gradle")
}
}
Выполняем созданную таску и смотрим, что получилось:
./gradlew myFirstTask
> Task :myFirstTask
> Hello, Gradle
Задача выполнена. Но тогда для чего нужен doLast
? Из документации и комментариев на Stackoverflow, можно выяснить, что таска состоит из очереди Action-ов, которые упорядоченно выполняются на этапе сборки проекта, а функции doFirst
и doLast
служат для управления порядком их выполнения. При использовании doFirst
Action добавляется в начало очереди, а при использовании doLast
- в конец.
Очередь, прекрасно! На самом деле её наличие несёт в себе более чем философский смысл и добавляет возможности для удобного расширения таски.
Кажется, можно идти дальше и разобрать пример посложнее, но в коде до сих пор кроется одна загвоздка, на которую тоже следует обратить внимание, а именно – на способ создания.
Эффективное создание Buildscript Gradle Task
Создание таски является довольно трудоёмкой операцией для Gradle, поскольку для этого необходимо провести её конфигурацию, up-to-date проверки и добавить её в таск-контейнер. При использовании функции create
это будет происходить каждый раз при конфигурации проекта, вне зависимости от того, будет ли таска использоваться. При этом то же самое будет происходить с тасками, от которых зависит создаваемая таска, и так далее по цепочке.
Конфигурация проекта и без того выполняется в однопоточном режиме (на момент Gradle 7.2), а здесь мы добавляем на неё неоправданную нагрузку.
Для решения этой проблемы в Gradle был придуман механизм Task Configuration Avoidance, в рамках которого был разработан API для ленивого создания и конфигурирования тасок. При использовании этого API работа происходит не напрямую с объектами Task
, а с обёрткой TaskProvider
.
TaskProvider
содержит методы для конфигурации таски, а также позволяет создать таску по необходимости. Например, функцию create
и её аналоги (createMaybe
, creating
и т.д. ) можно безболезненно заменить на register
, и получить прирост к скорости конфигурации проекта, а иногда и очень значительный.
В итоге правильным вариантом написания “Hello, Gradle“-таски будет следующий:
val myFirstTask: TaskProvider<Task> by tasks.registering {
doFirst {
println("Hello, Gradle!")
}
}
P.S. Также не будет лишним проверить ваши скрипты сборки на наличие старого API и заменить его на новый по официальному гайду. Здесь, как и везде в Gradle, не без подводных камней, но результат того стоит. На своём рабочем проекте мне удалось снизить среднее время конфигурации с 1 минуты 10 секунд до 40 секунд без особых сложностей.
Теперь предлагаю двинуться дальше и разобраться в том, каким образом в buildscript-таску передавать аргументы.
Передача аргументов в Buildscript Gradle Task
Усложним «Hello, Gradle»-пример и добавим таске возможность выводить строку из файла, который передадим в качестве параметра. Здесь-то мы и сталкиваемся с первым весомым ограничением buildscript-таски. Поскольку таска не представляет собой отдельного класса, то и каких-либо осознанных property или функций для передачи аргументов сделать не получится. Для начала попробуем захардкодить файл, из которого будем считывать:
val printFileContent by tasks.registering {
val inputFileProvider = project.layout.projectDirectory
.file("input.txt")
doFirst {
val inputFile = inputFileProvider.asFile
println(inputFile.readText())
}
}
Сам файл создадим самостоятельно. Запускаем:
> Task :printFileContent
Hello, Gradle!
Всё работает. Но возникает вполне резонный вопрос – неужели нельзя по-другому, ведь не факт, что считывать всегда будем из одного и того же файла. Закапываемся в документацию и находим решение – TaskInputs. TaskInputs
представляет собой контейнер, который призван хранить в себе аргументы для таски. По сути, это интерфейс, который предоставляет необходимые функции для передачи аргументов различного типа, а также доступа к ним. Попробуем воспользоваться:
val printFileContent by tasks.registering {
doFirst {
val inputFile = inputs.files.singleFile
println(inputFile.readText())
}
}
printFileContent.configure { inputs.file("input.txt") }
С помощью inputs.files.singleFile
обращаемся к файлу, который ожидается в качестве входного параметра, а с помощью inputs.file("input.txt")
кладём файл в TaskInputs
.
Запускаем, и… Всё работает! Но подход всё ещё остаётся не самым очевидным, поскольку при обращении к inputs
мы не знаем, что там на самом деле лежит. Тем не менее, такой вариант уже намного лучше.
Другим часто встречающимся кейсом является передача аргументов через командную строку. Рассмотрим его чуть позже, когда затронем тему кастомных тасок.
Также сложно поспорить с тем, что в подавляющем большинстве случаев файл создаём не мы, а какая-нибудь другая Gradle-таска. Дальше давайте попробуем устроить такое взаимодействие.
Зависимости между Gradle-тасками
Для начала попробуем написать таску, создающую файл. Начнём с простого и захардкодим путь к выходному файлу:
val createTextFile by tasks.registering {
val outputFileProvider = project.layout.projectDirectory
.file("input.txt")
doFirst {
val outputFile = outputFileProvider.asFile
outputFile.writeText("Hello, Gradle!")
printFileContent.get().inputs.file("input.txt")
}
}
Замечаем, что в doFirst
приходится самостоятельно резолвить таску printFileContent
и регистрировать ей inputs
, что добавляет негативных впечатлений. Для связывания тасок документация предлагает воспользоваться функцией dependsOn
, с помощью которой можно явно объявить зависимость:
val printFileContent by tasks.registering {
dependsOn(createTextFile)
//…
}
Запускаем:
./gradlew printFileContent
> Task :createTextFile
> Task :printFileContent
Hello, Gradle!
Работает. Однако при использовании dependsOn
необходима уверенность, что таска createTextFile
обязательно положит в аргументы таски printFileContent
файл. Если этого не произойдёт, всё сломается. Поломка возможна, если таска createTextFile
будет выполняться инкрементально, то есть пропускать своё выполнение.
Здесь на помощь приходит TaskOutputs – контейнер для хранения результатов выполнения таски. Gradle обещает работоспособность, если связать TaskOutputs
одной таски с TaskInputs
другой даже при инкрементальном выполнении. Давайте в этом убедимся.
Немного переделаем таску createTextFile
:
val createTextFile by tasks.registering {
outputs.file("input.txt")
doFirst {
val outputFile = outputs.files.singleFile
outputFile.writeText("Hello, Gradle!")
}
}
Здесь с помощью outputs.file("input.txt")
регистрируем выходной файл, а с помощью outputs.files.singleFile
обращаемся к нему при выполнении таски. Теперь давайте свяжем таски друг с другом через их inputs
и outputs
:
val printFileContent by tasks.registering {
//…
inputs.file(createTextFile.get().outputs.files.singleFile)
//…
}
Но тут мы вспоминаем, что createTextFile
— это TaskProvider
и нежелательно резолвить таску на этапе конфигурации. Поэтому для отложенной конфигурации воспользуемся оператором map
, который позволяет смаппить один Provider
в другой:
inputs.file(createTextFile.map { it.outputs.files.singleFile })
Всё готово, пробуем:
./gradlew printFileContent
> Task :createTextFile
> Task :printFileContent
Hello, Gradle!
Порядок. Попробуем ещё раз:
./gradlew printFileContent
> Task :createTextFile UP-TO-DATE
> Task :printFileContent
Hello, Gradle!
Произошло что-то интересное – таска createTextFile
перешла в состояние UP-TO-DATE. Почему так? В нашем примере у createTextFile
нет зависимостей, и поэтому нет смысла выполнять её каждый раз чтобы получить один и тот же output
. Gradle понимает это благодаря механизму инкрементального выполнения. Оказывается, TaskInputs
и TaskOutputs
спроектированы таким образом, что при отсутствии в них изменений таска автоматически переходит в состояние UP-TO-DATE!
Более того, если outputs
одной таски связан с inputs
другой, то dependsOn
также можно убрать. Gradle самостоятельно поймёт, что таски связаны друг с другом. По этим причинам способ объявления зависимостей через inputs
и outputs
является намного более удобным, и я бы рекомендовал использовать его.
Что получилось в итоге
val createTextFile by tasks.registering {
outputs.file("input.txt")
doFirst {
val outputFile = outputs.files.singleFile
outputFile.writeText("Hello, Gradle!")
}
}
val printFileContent by tasks.registering {
inputs.file(
createTextFile.map { it.outputs.files.singleFile }
)
doFirst {
val inputFile = inputs.files.singleFile
println(inputFile.readText())
}
}
Расширяем поведение Gradle Task
Самый простой способ расширить существующую Gradle-таску – это воспользоваться функциями doFirst
и doLast
.
Например, несложно дополнить таску для вывода в файл какой-нибудь дополнительной строкой. Сделать это можно следующим образом:
tasks.named("createTextFile").configure {
doLast {
val outputFile = outputs.files.singleFile
outputFile.appendText("Additional text")
}
}
С помощью doFirst
и doLast
можно добавить сколько угодно действий существующей таске.
Но как правило, необходимость возникает именно в изменении поведения таски, а не в дополнении. Для этого в Gradle Task API существует функция replace
, призванная обеспечить возможность замены существующей таски на другую. К сожалению, после некоторых попыток ей воспользоваться, у меня сложилось впечатление сломанного API:
Не работает для таски, которая уже была создана и добавлена в task-контейнер;
В Kotlin DSL поддерживает замену только на кастомную таску (имплементированную с помощью класса);
Gradle почему-то не удаляет старые Action из списка на выполнение, и это необходимо делать вручную с помощью явного вызова
actions.clean()
.
Исходя из этого, использование replace
выглядит странным, и проще выполнить подобную конструкцию:
tasks.named("createTextFile ").configure {
actions.clear()
doFirst {
println("Stubbed") }
}
Результат получим тот же самый.
Для изменения поведения всех тасок заданного типа можно воспользоваться функцией withType
. А с помощью configureEach
выполнить конфигурацию тасок по требованию:
tasks.withType<KotlinCompile>().configureEach {
doFirst {
println("Before Kotlin Compile")
}
}
Также у Gradle есть некоторое количество стандартных тасок, которые призваны упростить жизнь разработчикам. Например, для копирования директорий удобно пользоваться таской Copy
:
val copyMyFiles by tasks.registering(Copy::class) {
from(project.layout.projectDirectory.dir("from"))
into(project.layout.projectDirectory.dir("to"))
//...
}
, а для удаления – таской Delete
:
val deleteMyFiles by tasks.registering(Delete::class) {
delete {
delete(project.layout.projectDirectory.file("delete.txt"))
}
//...
}
О том, какие таски уже реализованы в стандартном API, можно посмотреть в документации в разделе «Task Types».
Теперь самое время подвести промежуточные итоги и выпить чаю.
Резюме по использованию Buildscript Gradle Task
Резюме составим по основным преимуществам и недостаткам buildscript-тасок:
Преимущества:
Быстрая и простая реализация;
Возможность повторного использования таски в других Gradle Project;
Конфигурируемость: скрипт-потребитель должен знать только про входные параметры таски;
Инкрементальное выполнение с помощью task
inputs
и taskoutputs
.
Недостатки:
Логика таски не может быть распределена по классам и пакетам, как мы к этому привыкли;
Нет возможности создавать осознанные property для передачи аргументов;
Чем больше тасок в скрипте, тем менее поддерживаемым становится скрипт;
Невозможность интеграционного/юнит тестирования;
Невозможность использования параллелизма внутри таски.
Таким образом, buildscript-таски хорошо подходят для разовых задач, не требующих дальнейшего развития и поддержки. Несмотря на то, что нам удалось создать действительно конфигурируемый и полностью рабочий код, возможности для его поддержки и тестирования оставляют желать лучшего.
Поэтому если ваша таска представляет собой полноценную самостоятельную логику, лучшим вариантом для её имплементации будет создание кастомной Gradle-таски. Дальше рассмотрим примеры имплементации и во всём убедимся.
Custom Gradle Task
Custom Gradle Task представляет собой класс, унаследованный от DefaultTask
или любого другого его наследника:
open class MyCustomTask : DefaultTask() {
@TaskAction
fun execute() {
//...
}
}
Поскольку все Gradle-таски обязаны быть open или abstract
для Kotlin и public
для Java, унаследоваться можно от любой существующей таски. Такое же правило распространяется и на только что реализованный нами класс.
Аннотацией @TaskAction
помечается функция, которая будет выполняться при выполнении таски. Это как раз тот самый Action, который Gradle добавит в начало выполнения. Если для таски определено несколько@TaskAction
функций, они будут выполняться в обратном порядке.
Для очевидности происходящего лучше оставить функцию @TaskAction
в единственном экземпляре. Она как раз и будет служить точкой входа для выполнения таски.
Класс можно поместить в одно из трех мест. Начнём от простого к сложному:
Реализация в build.gradle(.kts). Самый простой вариант - положить класс прямо в файл конфигурации. Подход хорош тем, что таска компилируется и подключается в buildscript без дополнительных действий. К недостаткам можно отнести отсутствие возможности для тестирования и низкую поддерживаемость кода.
Реализация в buildSrc. При таком подходе получаем тестируемую и поддерживаемую таску, но вместе с этим получаем все недостатки buildSrc , связанные с инвалидацией кеша, а также отсутствие возможности повторного использования таски в других проектах. Если у вас небольшой проект, нет необходимости распространять таску, и вы не пишете полноценный плагин, то такой вариант вполне может подойти.
Реализация таски в отдельном Gradle-проекте. Такой вариант актуален, если вы пишете полноценный Gradle-плагин, или пишете библиотеку, которая будет состоять из нескольких тасок. Как правило, если возникла необходимость в реализации кастомных тасок, то почти всегда есть смысл упаковать их в плагин и поставлять в проект как полноценную логическую единицу. Поэтому такая реализация будет предпочтительна в большинстве случаев.
Давайте убедимся в том, насколько удобным и очевидным будет подход к реализации тасок из предыдущих примеров. Таска для создания файла будет выглядеть следующим образом:
open class CreateTextFileTask : DefaultTask() {
@OutputFile
val outputFileProp: RegularFileProperty = project.objects.fileProperty()
.convention { project.file("default.txt") }
@TaskAction
fun execute() {
val outputText = "Hello, Gradle!"
outputFileProp.get().asFile.writeText(outputText)
}
}
Во-первых, теперь для входного параметра есть полноценное property. Во-вторых, вместо использования TaskOutputs
напрямую, теперь используем аннотацию @OutputFile
, с помощью которой Gradle самостоятельно зарегистрирует аннотируемую property в TaskOutputs
.
Чтобы сохранить ленивость доступа ко входным параметрам, вместо стандартных типов данных необходимо воспользоваться специальными ленивыми контейнерами от Gradle. В нашем случае будем использовать не File
, а контейнер RegularFileProperty
. RegularFileProperty
позволяет положить в него файл когда потребуется, и достать его при выполнении Gradle-таски.
Такие контейнеры также предлагают удобный API для регистрации дефолтных значений, если на момент обращения в контейнер никто ничего не положил. В примере выше осуществляем это с помощью функции convention
.
Все возможные варианты контейнеров можно посмотреть в документации и выбрать подходящий для вас.
При этом важно знать, что при попытке сделать сам контейнер мутабельным (var
), получим ошибку компиляции. Для создания контейнеров в Gradle следует воспользоваться классом ObjectFactory, инстанс которого есть у каждого Project
. Обращаемся к нему как project.objects
.
Другой вариант – объявить таску как abstract class
. В таком случае Gradle самостоятельно займётся инициализацией контейнера:
abstract class PrintFileContentTask : DefaultTask() {
@get: InputFile
abstract val inputFileProp: RegularFileProperty
//...
}
Но тогда и о дефолтных значениях говорить не приходится.
Теперь создадим таску для печати содержимого файла в консоль:
open class PrintFileContentTask : DefaultTask() {
@InputFile
val inputFileProp: RegularFileProperty = project.objects.fileProperty()
@TaskAction
fun execute() {
println(inputFileProp.get().asFile.readText())
}
}
Для входного параметра тоже появилось осознанное property, что, несомненно, радует. Чтобы Gradle положил входной файл в TaskInputs
, воспользовались аннотацией @InputFile
.
Теперь связываем таски друг с другом:
build.gradle.kts
val createTextFile by tasks.registering(CreateTextFileTask::class) {
outputFileProp.set(project.layout.projectDirectory.file("text.txt"))
}
val printFile by tasks.registering(PrintFileContentTask::class) {
inputFileProp.set(createTextFile.flatMap { it.outputFile })
}
Супер! Больше никакого слепого связывания через inputs
и outputs
. А чтобы связка параметров происходила отложено при использовании printFile
, воспользуемся функцией flatMap
, которая позволяет смаппить один Provider
в другой.
P.S. В большинстве случаев связывание лучше проводить именно внутри плагина, в котором таски будут жить:
class MyPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target.tasks) {
val createFile = register<CreateTextFileTask>("createFile")
val printFile = register<PrintFileContentTask>("printFileContent")
printFile.configure {
inputFileProp.set(createFile.flatMap { it.outputFileProp })
}
}
}
}
Такой плагин может быть реализован как в buildSrc, так и в отдельном Gradle-проекте. Подробнее о том, как реализовать и подключить плагин в проект, можно узнать из предыдущей статьи.
Передача аргументов через командную строку
Популярным вариантом использования Gradle-тасок является CI (Continuous integration), где зачастую аргументы необходимо передавать через командную строку. Кастомная Gradle-таска позволяет нам это сделать. Для наглядности попробуем написать таску-справочник, которая выводит в консоль информацию по одной из доступных тем:
open class AboutGradleTask : DefaultTask() {
@get: Input
@set: Option(
option = "about",
description = "Specify this parameter to learn more about Gradle")
lateinit var about: About
@get: OptionValues("about")
val availableInputs: List<About>
get() = About.values().toList()
@TaskAction
fun execute() {
println(about.description)
}
enum class About(val description: String) {
PROJECT("About Gradle Project"),
PLUGIN("About Gradle Plugin"),
TASK("About Gradle Task")
}
}
Аргумент командной строки помечаем аннотацией @Option
. В параметрах аннотации указываем название параметра командной строки и его описание. А чтобы ограничить входное значение несколькими вариантами, воспользуемся аннотацией @OptionValues
, которая позволяет вернуть список доступных к использованию значений.
Далее регистрируем таску, например, в build.gradle.kts:
val aboutGradle by tasks.registering(AboutGradleTask::class)
По заветам документации, для указания параметра в командной строке необходимо воспользоваться двойным тире. Пробуем:
./gradlew aboutGradle --about=PROJECT
> Task :aboutGradle
About Gradle Project
Порядок! Также необходимо учесть, что аргументы для командной строки имеют ограниченное количество типов данных. О доступных к использованию типах можно узнать в документации.
Gradle Task и build-cache
Предположим, на нашем проекте реализован билд-кеш, который используется командой разработчиков на разных хостах, и мы хотим, чтобы параметры таски также кешировались и распространялись по билд-машинам.
Первым делом таску с выходными параметрами необходимо пометить как @CacheableTask
. Таким образом Gradle поймёт, что выходные параметры таски можно складывать в билд-кеш:
@CacheableTask
open class CreateTextFileTask : DefaultTask() {
//...
}
Если у таски в качестве входных параметров определены файлы, директории или другие коллекции файлов, то для них необходимо определить чувствительность к расположению и наименованию. Для этого ко входному параметру применяем аннотацию @PathSensitivity
, куда передаём одну из доступных стратегий. Если исходя из стратегии входной параметр был признан недействительным, его расчет будет произведён заново.
ABSOLUTE – кеш этого параметра завязан на абсолютный путь к нему. То есть при попытке запустить таску на другом хосте, значение данного параметра всегда будет считаться недействительным. Является стратегией по умолчанию.
RELATIVE – кеш этого параметра завязан на относительный путь его содержимого, и поэтому актуален только для
DirectoryProperty
/FileCollection
. При изменении структуры директории / коллекции, кеш таски считается недействительным. При этом кеш считается недействительным также при изменении содержимого (названия и контент файлов).NAME_ONLY – при использовании этой стратегии кеш будет считаться недействительным при изменении названия или контента хотя бы одного файла внутри
DirectoryProperty
/FileCollection
. ДляRegularFileProperty
кеш станет недействительным при изменении самого файла или его названия.NONE – кеш завязан только на содержимое. Если это файл, то для определения валидности кеша производится расчёт контрольной суммы. Если это директория, то производится расчёт контрольной суммы всего её содержимого. Например, данную стратегию можно применить к файлам конфигурации.
В нашем случае применим @PathSensitivity(NONE)
. Такая стратегия будет подходящей, поскольку название файла нас никак не интересует.
open class PrintSingleContentTask : DefaultTask() {
@InputFile
@PathSensitive(NONE)
val inputFileProp: RegularFileProperty = project.objects.fileProperty()
//...
}
Теперь Gradle может бережно складывать выходной файл в билд-кеш и отправлять в путешествие по разным билд-машинам.
Инкрементальное выполнение
Как мы говорили, Gradle пропускает выполнение таски, если все её зависимости находятся в состоянии UP-TO-DATE. Но бывают случаи, когда с момента последнего выполнения изменилась только часть входных данных, и мы хотим обработать только её. Для этого Gradle может передать в Action объект InputChanges
, исходя из которого мы можем понять, какая часть файла или коллекции изменилась. В этом объекте будет храниться информация об изменении входных параметров, помеченных аннотацией @Incremental
. С помощью InputChanges
можно узнать, что изменилось во входном параметре, выяснить характер изменений и сделать только нужные вычисления.
Например, немного переделаем таску для вывода в консоль и будем выводить содержимое коллекции файлов. Выводить в консоль будем только те файлы, которые изменились с момента последнего билда:
open class PrintFilesContentTask : DefaultTask() {
@InputFiles
@Incremental
@PathSensitive(RELATIVE)
lateinit var filesDir: DirectoryProperty
@TaskAction
fun execute(inputChanges: InputChanges) {
inputChanges.getFileChanges(filesDir).forEach { fileChange ->
if (
fileChange.fileType == FILE &&
fileChange.changeType in ADDED..MODIFIED
) {
println(fileChange.file.readText())
}
}
}
}
Как видно из примера, c помощью InputChanges
также удобно итерироваться по коллекции файлов.
И напоследок предлагаю коснуться темы параллельных вычислений в Gradle и того, как их можно осуществлять при помощи Worker API.
Worker API
Чаще всего таски не выглядят так примитивно и требуют весомого времени для выполнения. Поскольку время сборки проекта достаточно дорогое, а проблема медленных сборок актуальна для большинства пользователей Gradle, необходим механизм для распараллеливания вычислений. Этим механизмом в Gradle выступает Worker API, который построен на основе очереди задач и воркеров. Воркеры расхватывают задачи из очереди и выполняют их. Идея не нова, а также давно используется в Gradle для распараллеливания тасок.
Представим, что таска создаёт несколько файлов и проводит тяжёлые вычисления для расчёта содержимого. В реальном мире это может быть компиляция (например, KotlinCompile или JavaCompile).
Прежде всего, необходимо создать WorkerAction
и WorkerParameters
. WorkerAction
будет непосредственно отвечать за выполнение задачи, а WorkerParameters
будет служить параметрами для неё:
interface GenerateTextFileWorkerParams : WorkParameters {
var content: String
var outputFile: File
}
abstract class GenerateFileWorkerAction :
WorkAction<GenerateTextFileWorkerParams> {
override fun execute() {
val params = parameters
params.outputFile.bufferedWriter().use { writer ->
Thread.sleep(3000)
writer.write(params.content)
}
}
}
В таске осталось создать очередь и отправить в неё WorkerAction
-ы.
Пример таски с использованием Worker API
@CacheableTask
open class CreateTextFilesTask @Inject constructor(
private val workerExecutor: WorkerExecutor
) : DefaultTask() {
@OutputDirectory
val outputDir: DirectoryProperty = project.objects
.directoryProperty()
.convention(
project.layout.buildDirectory.dir("filesToPrint")
)
@TaskAction
fun execute() {
/**
* Создаём очередь
*/
val queue = workerExecutor.noIsolation()
val fileNamesToContent = listOf(
"content1.txt" to "Love Gradle",
"content2.txt" to "Love Gradle Tasks",
"content3.txt" to "Love Gradle Worker API"
)
val outputDir = outputDir.get().asFile
/**
* Отправляем задачи в очередь
*/
fileNamesToContent.forEach { (fileName, fileContent) ->
queue.submit(GenerateFileWorkerAction::class) {
outputFile = File(outputDir, fileName)
content = fileContent
}
}
}
}
Для создания очереди необходимо воспользоваться классом WorkerExecutor
. Этот класс содержит функции для создания и конфигурации очередей.
Например, можно изолировать выполнение задач по разным процессам с помощью очереди processIsolation
. Если есть необходимость для каждой задачи использовать свой classloader, то можно воспользоваться очередью classLoaderIsolation
. Изоляция по classloader может пригодиться, если для выполнения задачи вам необходимо различное состояние классов (например, состояние статических переменных).
В нашем случае нет необходимости выполнять работу в разных процессах или classLoader, поэтому достаточно noIsolation
очереди.
С помощью submit
отправляем задачу в очередь, а в лямбде задаём задаче параметры. Теперь выполнение функции execute
() внутри WorkerAction
будет подхватываться несколькими потоками.
Тонкость использования Worker API заключается в том, что таска не дожидается завершения WorkerAction
-ов и считается завершённой сразу после отправки задач в очередь. Если необходимо дождаться их завершения, следует воспользоваться функцией await
, доступной в WorkerExecutor
.
Итоги
Gradle Task представляет собой одну из фундаментальных единиц сборки проекта. В статье я постарался рассмотреть базовые принципы работы с ними, разобравшись в которых, можно значительно облегчить понимание процесса сборки.
К сожалению, Gradle API по-прежнему способствует хождению по граблям, однако с приходом Kotlin DSL процесс изучения значительно упростился и, по крайней мере для меня, успехи в использовании Gradle уже перестали походить на везение.
Буду благодарен за любые замечания, предложения и другие обсуждения по материалу. Спасибо за внимание!
Комментарии (2)
Nashev
19.10.2021 08:42+1Хорошим дополнением к этой статье можно счесть статью Подробно о задачах Gradle 2014 года написания
MEJIOMAH
Спасибо за столь подробный Гайд! На мой взгляд его можно давать людям, которые переходят с мавена на грейдл и им начинает быть что-то нужно за пределами сбора dependency.