Дикая природа Gradle Task: руководство по выживанию

image-loader.svg

Приветствую, Gradle-адепт. В статье тебя ждёт авторский тур по Gradle Task. В маршрут включено хождение по граблям, изучение секретных практик buildscript-тасок, проведение раскопок по deprecated API, а ближе к концу зарядимся силой Custom Gradle Task, попрактикуемся в строительстве билд-кеша и узнаем, кто такой Worker API.

В предыдущих двух топиках разобрали основные принципы работы Gradle и работу Gradle Plugin. Кому интересно, добро пожаловать:

  1. Готовьсь, цельсь, пли! Как не обжечься при сборке Gradle-приложения, и настолько ли всё серьезно?

  2. Gradle Plugin: Что, зачем и как?

  3. Дикая природа Gradle Task: руководство по выживанию

В этой статье я постарался раскрыть основные особенности Gradle Task, изучив которые, можно подходить к решению боевых задач вооружившись. Для удобства навигации по статье, ниже представляю оглавление. Можно смело переходить к интересующей вас теме уже сейчас:

Что такое Gradle Task?

Состояния Gradle Task

Hello, Gradle!

Эффективное создание Buildscript Gradle Task

Передача аргументов в Buildscript Gradle Task

Зависимости между Gradle-тасками

Расширяем поведение Gradle Task

Резюме по использованию Buildscript Gradle Task

Custom Gradle Task

Инкрементальное выполнение

Передача аргументов через командную строку

Gradle Task и build-cache

Worker API

Итоги

Что такое 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 — в конец.

Очередь, прекрасно! На самом деле её наличие несёт в себе более чем философский смысл и добавляет возможности для удобного расширения таски.

Кажется, можно идти дальше и разобрать пример посложнее, но в коде до сих пор кроется одна загвоздка, на которую тоже следует обратить внимание, а именно — на способ создания.

Небольшой оффтоп про процесс изучения GradleНебольшой оффтоп про процесс изучения Gradle

Эффективное создание 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 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 является намного более удобным, и я бы рекомендовал использовать его.

image-loader.svgЧто получилось в итоге

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:

  1. Не работает для таски, которая уже была создана и добавлена в task-контейнер;

  2. В Kotlin DSL поддерживает замену только на кастомную таску (имплементированную с помощью класса);

  3. Gradle почему-то не удаляет старые Action из списка на выполнение, и это необходимо делать вручную с помощью явного вызова actions.clean().

Исходя из этого, использование replace выглядит странным, и проще выполнить подобную конструкцию:

tasks.named("createTextFile ").configure {  
  actions.clear()  
  doFirst {
    println("Stubbed")  }
}

Результат получим тот же самый.

Для изменения поведения всех тасок заданного типа можно воспользоваться функцией withType. А с помощью configureEach выполнить конфигурацию тасок по требованию:

tasks.withType().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 и task outputs.

Недостатки:

  • Логика таски не может быть распределена по классам и пакетам, как мы к этому привыкли;

  • Нет возможности создавать осознанные 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 в единственном экземпляре. Она как раз и будет служить точкой входа для выполнения таски.

Класс можно поместить в одно из трех мест. Начнём от простого к сложному:

  1. Реализация в build.gradle (.kts). Самый простой вариант — положить класс прямо в файл конфигурации. Подход хорош тем, что таска компилируется и подключается в buildscript без дополнительных действий. К недостаткам можно отнести отсутствие возможности для тестирования и низкую поддерживаемость кода.

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

  3. Реализация таски в отдельном 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 {  
  
  override fun apply(target: Project) {    
    with(target.tasks) {      
      val createFile = register("createFile")      
      val printFile = register("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
    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 {  
  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 уже перестали походить на везение.

Буду благодарен за любые замечания, предложения и другие обсуждения по материалу. Спасибо за внимание!

© Habrahabr.ru