Система сборки проектов Gradle стала значительной вехой в эволюции инструментов подготовки артефактов и заменила во многих проектах ранее популярный Maven (который ранее стал заменой для make и ant). Де-факто Gradle является стандартом для сборки проектов для Android, но в действительности он может использоваться и для других целевых платформ и технологий разработки, отличных от JVM. Подобно maven в gradle используются устанавливаемые дополнения, которые могут добавлять свои цели и элементы конфигурации, а также встраиваться в существующие цели и добавлять операции как и исходными текстами (например, форматирование), так и с вспомогательными объектами (как пример можно привести кодогенерацию), а также вызов внешних команд или объектов классов (например, компилятора kotlin или инструментов сборки ресурсов для android).

В этой статье мы пошагово создадим и протестируем простой plugin трансформации текстовых файлов для gradle (при разработке будем в основном использовать API, который поддерживается версиями 6.0+, но отдельно отметим, какие функции поддерживаются только в Gradle 7.0 и более новых).

Сначала начнем с определения контекста. Сборка любого проекта состоит из последовательности действий (actions), которые в конечном счете приводят к появлению целевого состояния (task). Конфигурация сборки описывается в виде gradle-сценария, который может взаимодействовать с методами, импортированными из plugin'ов или входящих в gradle-core, либо с версии Gradle 4.0 с использованием Kotlin Scripting (.kts) с возможностью использования объектов стандартной библиотеки Kotlin и импортируемых библиотек.

В стандартной поставке предоставляет набор core-plugins, обеспечивающих операции над кодом Java (компиляция, тесты), C++, Swift, сборку артефактов WAR, EAR, публикации в Maven и Ivy, интеграции с инструментами анализа кода (Checkstyle, PMD) и создания отчетов о тестировании (JaCoCo). Сейчас наиболее актуальными для нас будет Base (основные задачи жизненного цикла), Build Init (подготовка начального проекта для сборки) и Plugin Development (набор инструментов для создания и тестирования плагинов).

Создадим пустой проект gradle:

gradle init

При инициализации будет выдан запрос на тип проекта (basic, application, library, gradle plugin). Для проверки создадим проект basic. Вторым вопросом будет уточнено, какой тип сценария будет использоваться (gradle или kotlin), выберем Kotlin. Последний вопрос - создавать ли проект с поддержкой новейших API (при этом не гарантируется совместимость с предыдущими версиями Gradle). Ответим yes. Последним вопросом запрашивается название проекта (Project name).

Поскольку мы создаем не приложение (application), то init plugin не запрашивает информацию, специфичную для технологии разработки (например, название пакета). Сейчас мы имеем каталог с подготовленным для запуска gradle с пустой конфигурацией и можем запросить список доступных задач для выполнения.

gradle tasks

По умолчанию нам сейчас доступна задача init и вспомогательные задачи для получения списка свойств проекта (определяются в gradle.properties, могут использоваться в сценарии), просмотра дерева зависимостей, обнаруженного окружения сборки для java (javaToolchains), а также конфигурации репозиториев, используемых для обнаружения плагинов (buildEnvironment).

Начнем с определения собственной задачи, которая будет использовать возможности стандартной библиотеки Kotlin. Отредактируем файл build.gradle.kts в каталоге проекта.

tasks.register("hello") {
  group="habr"
  description="Simple hello world task"
  doLast {
    println("Hello world!")
  }
}

Для записи команд используется Kotlin DSL, при этом для управления доступными задачами доступен объект tasks, через который можно регистрировать новые задачи (register), получать доступ к существующим (named) и удалять задачи. Кроме того, можно подписываться на события реестра задач (например, whenTaskAdded вызывается при добавлении любой задачи).

Для задачи кроме названия (передается аргументом в register) можно задать описание (description) и группу (group), которые используется при отображении через gradle tasks.

Внутри контекста описания задачи есть возможность отключить задачу (enabled=false), это можно применять в том числе к встроенным задачам, а также управлять зависимостями (taskDependencies возвращает все задачи, которые зависят от этой, dependsOn - список зависимостей, от которых зависит эта, finalizedBy - список задач, которые выполняются после успешного выполнения этой задачи, mustRunAfter - список задач, после которых ДОЛЖНА выполняться эта, shouldRunAfter - список задач, после которых МОЖЕТ выполняться эта), а также получать доступ к логу для этой задачи (logger). Также задача может быть выполнена только при соблюдении некоторых условий (onlyIf), при этом можно использовать доступ к состоянию и переменным проекта (через поле project).

Для каждой задачи представляется свой каталог для хранения временных файлов (temporaryDir), набор входных (inputs) и выходных ресурсов (outputs), которые определяются со стороны плагина, реализующего задачу и представлены в виде списка свойств, файлов или каталогов. Также можно получить текущее состояние задачи (state - еще не была запущена, выполняется, выполнена, пропущена или произошла ошибка), для реализации более сложных зависимостей.

Для настройки задачи могут использоваться свойства properties, которые доступны внутри объекта Task через методы hasProperty (проверяет наличие значения свойства), property (извлекает значение свойства) и метод setProperty для изменения значения свойства. Свойства задаются при конфигурировании задачи (в DSL-функции в register).

Задача состоит из действий (actions), которые могут быть запущены в начале (doFirst) или в конце выполнения (doLast). Если указать несколько методов doLast, то они будут вызваны в порядке объявления, несколько методов doFirst - в обратном порядке. Обратите внимание - если разместить код задачи вне doFirst/doLast, то он будет выполнен при создании объекта задачи (т.е. всегда при запуске gradle).

В последних версиях Gradle появилась возможность регистрировать сервисы (через вызов BuildServiceRegistry.registerIfAbsent), которые являются общими для всех задач и позволяют хранить внутреннее состояние, общее для нескольких плагинов и/или задач. Получить доступ к сервису из задачи можно через вызов usesService(экземпляр_BuildService).

Кроме того, плагины могут добавлять новые методы к Task (в kts используются функции-расширения Kotlin), а также создавать расширения для DSL (с использованием project.getExtensions().create) - в это случае создается дополнительная ветка конфигурации в DSL, содержание которой определяется интерфейсом или абстрактным классом, переданным в метод create. При необходимости создания нескольких уровней конфигурирования расширения могут объединяться с использованием аннотации Nested.

Попробуем запустить созданную нами задачу, для этого сначала убедимся, что она стала доступна:

gradle tasks

Habr tasks
hello - Simple hello world task

и проверим корректность выполнения задачи

gradle -q hello

Hello World!

Перенесем нашу реализацию в отдельный класс и свяжем его с задачей "hello"

abstract class HelloTask : DefaultTask() {
  @TaskAction
  fun printHello() {
		println("Hello World!")
  }
}
tasks.register<HelloTask>("hello") {
  group="habr"
  description="Simple hello world task"
}

Запуск выполняется аналогично. В классе может быть создано несколько функций с аннотацией @TaskAction и они будут вызываться последовательно. Теперь добавим возможность изменить имя для приветствия. Для это можно дополнить код и добавить свойство с аннотацией @get:Input

abstract class HelloTask : DefaultTask() {
  @get:Input
  abstract val greeting: Property
  @TaskAction
  fun printHello() {
    println("Hello ${greeting.get()}")
  }
}
tasks.register("hello") {
  setProperty("greeting", "everyone")
  group="habr"
  description="Simple hello world task"
}

С помощью аннотаций @get: можно связывать свойство как с входными параметрами (Input, InputFile, InputDirectory, InputFiles), так и с выходными (OutputFile, OutputFiles, OutputDirectory). Параметры также можно передавать через конструктор класса задачи.

В коде задачи мы можем использовать методы проекта: exec (для запуска внешних приложений), copy (копирование файлов и каталогов), delete (удаление файлов и каталогов), mkdir (создание каталога), tarTree/zipTree (для упаковки каталогов в tar/zip), files (получение значения для сохранения в свойстве задачи), коллекцию artifacts для накопления артефактов сборки (в DSL указываются параметры и сборочная задача в buildBy).

Теперь, когда мы умеем создавать задачи в виде сценариев Kotlin или классами-расширениями DefaultTask, перейдем к созданию плагинов.

В целом плагин представляет собой класс-реализацию Plugin<T> (где T определяет целевой объект для применения плагина, во многих случаях будет Project) и реализуется в переопределении метода apply. В apply могут быть зарегистрированы задачи (через экземпляр объекта project, который передается в apply), а также добавляться расширения, которые становятся доступными в gradle-сценарии, при этом само расширение является интерфейсом (или абстрактным классом, при необходимости инициализации) с описанием свойств. Расширения используются для создания дополнительных ветвей конфигурации.

Плагин применяется в gradle при вызове функции apply с указанием класса-реализации плагина:

apply<MyPlugin>()

Теперь добавим расширение для конфигурирования плагина после добавления:

abstract class HelloPluginExtension {
  abstract val user: Property<String>
  init {
    user.convention("Hello from GreetingPlugin")
  }
}

class HelloPlugin: Plugin<Project> {
  override fun apply(project: Project) {
    val extension = project.extensions.create<HelloPluginExtension>("user")
    project.task("hello") {
      println(this::class.java)
      group = "habr"
      doLast {
        println("Hello ${extension.user.get()}!")
      }
    }
  }
}

apply<HelloPlugin>()
configure<HelloPluginExtension> {
  user.set("Great User")
}

Теперь добавим возможность получения списка файлов для обработки и напишем код для изменения текстовых файлов. Для этого перенесем задачу во вложенный класс и на этом этапе сделаем фиксированное название входного и выходного файла (convention задает значение по умолчанию).

class TextTransformerPlugin: Plugin<Project> {

  abstract class AddLineNumbersTask : DefaultTask() {
    @get:InputFile
    val inputFile:RegularFileProperty = project.objects.fileProperty().convention(project.layout.projectDirectory.file("test.txt"))
    @get:OutputFile
    val outputFile:RegularFileProperty = project.objects.fileProperty().convention(project.layout.buildDirectory.file("test-with-linenumbers.txt"))
    @TaskAction
    fun processLines() {
      val source = inputFile.get().asFile
      var target = outputFile.get().asFile
      var index = 1
      var outputWriter = target.bufferedWriter()
      with(source.bufferedReader()) {
        lines().forEach {
          outputWriter.write("${index++} $it")
          outputWriter.newLine()
        }
      }
      outputWriter.close()
    }
  }
  
  override fun apply(project: Project) {
    project.task<AddLineNumbersTask>("addLineNumbers") {
      group="habr"
    }
  }
}

apply<TextTransformerPlugin>()

Создадим произвольный текстовый файл test.txt. После запуска gradle addLineNumbers в каталоге build будет создан преобразованный файл test-with-linenumbers.txt, где в начале каждой строки будет добавлен ее номер.

Конфигурация местоположения может быть задана с использованием расширения (аналогично тому, как было сделано ранее),

Созданный плагин может быть зарегистрирован по уникальному идентификатору (или загружен в виде jar-артефакта в репозиторий и подключен через buildScripts). Для подключения будем использовать java-gradle-plugin.

gradlePlugin {
    plugins {
        create("textProcessorPlugin") {
            id = "ru.example.textprocessor"
            implementationClass = "TextTransformerPluginKt"
        }
    }
}

plugins {
    `java-gradle-plugin`
    id("ru.example.textprocessor")
}

Теперь дополнительно появилась задача validatePlugins, с помощью которой можно проверить корректность заполнения входных параметров и соответствие типов входных и выходных параметров между зависимыми задачами.

Последним шагом добавим возможность тестирования нашего плагина. Для выполнения тестов может использоваться любой фреймворк (например, JUnit5). Альтернативно можно запустить в тестовой функции экземпляр GradleRunner и подготовить для него build.gradle, который будет включать в себя необходимые команды для подключения плагина и его применения к тестовому файлу.

Для создания теста необходимо добавить в build.gradle конфигурацию для запуска тестов и непосредственно в коде теста создать экземпляр проекта и применить на нем разработанный плагин:

build.gradle

testing {
    suites {
        // Configure the built-in test suite
        val test by getting(JvmTestSuite::class) {
            // Use Kotlin Test test framework
            useKotlinTest()
        }
    }
}
class TextTransformerPluginTest {
    @Test fun `add numbers task`() {
        val project = ProjectBuilder.builder().build()
        project.plugins.apply("ru.example.textprocessor")
        assertNotNull(project.tasks.findByName("addLineNumbers"))
    }
}

Заготовку кода для разработки и тестирования можно сформировать через gradle init (тип создаваемого проекта - Gradle Plugin)

Исходные тексты размещены на https://github.com/dzolotov/sample-gradle-plugin.

На этом все. Уже сегодня я проведу бесплатный вебинар по теме: "Тестирование веб-сокетов на Ktor". Приглашаю всех желающих, регистрация открыта по ссылке.

Комментарии (0)