Declarative Gradle: рывок или прорыв?
В мире Java разработки (особенно на Spring) большую часть рынка занимают две системы сборки: Gradle и Maven. Maven исповедует более консервативный подход, в котором конфигурация сборки описывается в декларативном pom.xml. Модный молодежный Gradle, являющийся дефолтом в start.spring.io, использует декларативно-императивные скрипты с DSL на Groovy или Kotlin. Казалось бы, что еще нужно? Тем не менее, полгода назад команда Gradle анонсировала свой новый продукт — Declarative Gradle. Редакция Spring АйО изучила документацию проекта и попробовала его в деле. Подробности под катом.
Что это?
Конечно, название проекта говорит само за себя — новый проект это все тот же Gradle, из которого убрали возможность писать императивные конструкции. Почему команда Gradle пошла на такой шаг? Казалось, что сила Gradle именно в его гибкости (помимо скорости сборки).
Разработчики Gradle разделяют своих пользователей на две группы: разработчики программного обеспечения и билд инженеры (речь про девопсов?). Задача первых — писать софт, и система сборки — всего лишь инструмент, которому они делегируют некоторые задачи. Билд инженеры же занимаются непосредственно сборками софта и оптимизациями этих сборок.
Очень часто эти роли пересекаются. Особенно, в небольших командах. Как правило, там есть только есть 1–2 человека, которые шарят за Gradle, занимаются исправлением всех проблем сборки, могут написать плагин. В больших же командах, для этих задач есть специальная роль — билд инженер. И одна из задач Declarative Gradle — сделать четкое разделение между этими двумя ролями.
Описание ПО и Логика сборки
Описание ПО — это то, что мы должны собрать. Она описывает ответы на такие вопросы, как:
Какого типа программное обеспечение создается?
На каких языках реализовано программное обеспечение?
Для каких платформ предназначено программное обеспечение?
Каковы зависимости программного обеспечения?
Какие инструменты должны использоваться для компиляции/связывания/инструментирования/сборки/документирования программного обеспечения?
Какие проверки качества нужно выполнить перед выпуском программного обеспечения?
Логика сборки — это то, как программное обеспечение будет построено. Это код, который добавляет новые возможности в Gradle, интегрирует различные инструменты и задает соглашения для определения программного обеспечения.
Описание программного обеспечения предназначено для чтения и модификации разработчиками программного обеспечения. Логика сборки предназначена для чтения и модификации билд инженерами.
Ключевые принципы Declarative Gradle
Простота использования для разработчиков программного обеспечения. Разработчики должны иметь возможность определять любое программное обеспечение и строить свои проекты без необходимости понимать детали работы системы сборки.
Гибкость для билд инженеров и продвинутых пользователей. Опытные пользователи Gradle должны сохранять текущий уровень гибкости и иметь возможность автоматизировать широкий спектр сценариев сборки программного обеспечения с помощью пользовательской логики сборки.
Улучшение интеграции с IDE. Импорт проекта в IDE и взаимодействие с ним должны быть быстрыми и надежными. IDE и другие инструменты должны иметь возможность автоматически или через пользовательский интерфейс изменять описание программного обеспечения надежным образом.
Рассмотрим каждый из этих принципов по порядку.
Простота использования
Взглянем на пример билд скрипта:
javaApplication {
javaVersion = 21
mainClass = "com.example.App"
dependencies {
implementation(project(":java-util"))
implementation("com.google.guava:guava:32.1.3-jre")
}
}
Выглядит крайне просто. Даже не знакомый с Gradle пользователь поймет, что тут написано.
Ограниченный DSL позволит использовать только ограниченный набор конструкций, таких как вложенные блоки, присваивание и вызовы определенных методов. Произвольный поток выполнения и вызов произвольных методов будут запрещены. Писать логику сборки можно будет на любом языке JVM, таком как Java, Kotlin или Groovy, но эта логика должна будет находиться в плагинах (локальных или публичных).
Ограниченный DSL является шагом в правильном направлении, но это лишь часть усилий, которые команда Gradle предпринимает для упрощения и удобства описания программного обеспечения для разработчиков.
Другая проблема заключается в том, что текущее описание программного обеспечения не всегда использует концепции, отражающие соответствующую область программного обеспечения. Чтобы работать с Gradle, разработчикам обычно нужно понимать специфические для Gradle конструкции, такие как конфигурации зависимостей и таски (tasks). Эти концепции относятся к логике сборки и должны касаться только билд инженеров. В большинстве случаев разработчики программного обеспечения должны иметь возможность настраивать все необходимое, используя понятия, с которыми они уже знакомы, такие как библиотеки, приложения, версии и тестовые наборы.
Интеграция с IDE
Часто разработчики выбирают те технологии, для которых есть отличная поддержка в IDE. Поэтому команда Gradle хочет упростить такую поддержку для разработчиков IDE. Автоматизированное редактирование императивных скриптов сборки — задача в общем случае не решаемая.
Для скриптов на Groovy едва ли можно было сделать хоть сколько-нибудь адекватную поддержку, ввиду его динамической природы. Поддержка Kotlin DSL была сильно лучше, включала в себя такие вещи, как: автокомплишн, быстрый доступ к документации, навигация к исходникам и даже рефакторинги. Но автоматически редактировать такие скрипты, особенно через средства UI, очень затруднительно.
Кроме того, раньше, чтобы отобразить всю структуру проекта, необходимо было выполнить множество сложных вычислений, таких как разрешение зависимостей. Та же IntelliJ IDEA для решения этой задачи запускала свой код внутри самого Gradle, чтобы получить доступ к структуре проекта.
Попробуем в деле
Прежде чем мы посмотрим, как выполняется третий принцип Declarative Gradle — гибкость для билд инженеров и продвинутых пользователей — нужно попробовать новую технологию в деле.
Склонируем репозиторий Declarative Gradle и откроем проект https://github.com/gradle/declarative-gradle/tree/main/unified-prototype в IntelliJ IDEA.
Помимо стандартного build.gradle.kts (который кстати пустой), мы видим файл settings.gradle.dcl. Никакой поддержки синтаксиса в IDEА пока что нет, однако структура файла достаточно простая и довольно очевидная. Проект включает в себя 20 модулей, с различными конфигурациями сборки, на таких технологиях как Java, Kotlin, Android, CPP, Swift. Нас интересует только Java, поэтому все помимо нее я закомментировал.
Проект распознается IntelliJ IDEA как Gradle проект (видимо за счет пустого build.gradle.kts), и среда разработки сразу запускает процедуру импорта.
Помимо демо проектов видим модуль unified-plugin, в котором, по всей видимости, и находится вся логика сборки. Этот модуль уже использует классический Gradle с Kotlin DSL. Опять отключаем лишние проекты в settings.gradle.dsl
:
dependencyResolutionManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
}
includeBuild("build-logic")
//include("plugin-android")
include("plugin-jvm")
//include("plugin-kmp")
//include("plugin-swift")
//include("plugin-cpp")
include("plugin-common")
//include("internal-testing-utils")
rootProject.name = "unified-plugin"
и перезапускаем импорт.
Идея смогла успешно распарсить проект, и отсутствие кода в build.gradle.kts
ее не смутило. Это и не удивительно, как я писал раньше, для этой задачи идея встраивается непосредственно в сам Gradle и считывает его модель. На сколько можно понять, внутренняя модель не сильно поменялась. Однако, запустить таску run из IntelliJ IDEA у меня не получилось.
> Directory '/Users/alexander/workspace/declarative-gradle/unified-prototype/testbed-java-application' does not contain a Gradle build.
Однако, запуск через терминал отработал успешно:
unified-prototype git:(main) ✗ ./gradlew :testbed-java-application:run
Welcome to Gradle 8.10-20240703002818+0000!
Starting a Gradle Daemon, 1 stopped Daemon could not be reused, use --status for details
> Task :testbed-java-application:run
Hello from Java 17.0.4.1
Welcome to the java-utils library!
BUILD SUCCESSFUL in 10s
24 actionable tasks: 7 executed, 17 up-to-date
Скрипт сборки
Конечно, скриптом наверно это называть не совсем корректно. В новой терминологии это скорее описание структуры нашего проекта.
javaApplication {
javaVersion = 17
mainClass = "com.example.App"
dependencies {
implementation(project(":java-util"))
implementation("com.google.guava:guava:32.1.3-jre")
}
testing {
// test on 21
javaVersion = 17
dependencies {
implementation("org.junit.jupiter:junit-jupiter:5.10.2")
}
}
}
Понятное, декларативное описание. Посмотрим, как это работает внутри. Код плагина находится в этом же репозитории, и буквально за 30 секунд я нахожу класс org.gradle.api.experimental.java.JavaApplication
:
@Restricted
public interface JavaApplication extends HasJavaTarget, HasJvmApplication, HasCliExecutables {
@Nested
Testing getTesting();
@Configuring
default void testing(Action super Testing> action) {
action.execute(getTesting());
}
}
Это и есть модель, которая будет построена из .gradle.dcl описания. Большинство параметров объявлены в базовых интерфейсах HasJavaTarget
, HasJvmApplication
, HasCliExecutables
.
В частности:
@Restricted
public interface HasJvmApplication extends HasApplicationDependencies {
@Restricted
Property getMainClass();
}
наследуется от модели, хранящей зависимости. Также, обращаем на аннотацию @Restricted
. Чтобы выяснить, для чего она нужна меняем свойство distributionUrl
в gradle-wrapper.properties
:
# было
distributionUrl=https\://services.gradle.org/distributions-snapshots/gradle-8.10-20240703002818+0000-bin.zip
# стало
distributionUrl=https\://services.gradle.org/distributions-snapshots/gradle-8.10-20240703002818+0000-all.zip
и подгружаем исходники. К сожалению, документации я не нахожу, зато нахожу комментарий:
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Restricted {} // TODO: should eventually be renamed to "Declarative"
Становится понятно, что аннотацией помечаются декларативные модели, а после поиска юсаджей JavaApplication
, находим в org.gradle.api.experimental.java.StandaloneJavaApplicationPlugin
способ их получения:
abstract public class StandaloneJavaApplicationPlugin implements Plugin {
public static final String JAVA_APPLICATION = "javaApplication";
@SoftwareType(name = JAVA_APPLICATION, modelPublicType = JavaApplication.class)
abstract public JavaApplication getApplication();
@Override
public void apply(Project project) {
JavaApplication dslModel = getApplication();
project.getExtensions().add(JAVA_APPLICATION, dslModel);
project.getPlugins().apply(ApplicationPlugin.class);
project.getPlugins().apply(CliApplicationConventionsPlugin.class);
project.getExtensions().getByType(TestingExtension.class).getSuites().withType(JvmTestSuite.class).named("test").configure(testSuite -> {
testSuite.useJUnitJupiter();
});
linkDslModelToPlugin(project, dslModel);
}
@Inject
protected abstract JavaToolchainService getJavaToolchainService();
private void linkDslModelToPlugin(Project project, JavaApplication dslModel) {
JvmPluginSupport.linkJavaVersion(project, dslModel);
JvmPluginSupport.linkApplicationMainClass(project, dslModel);
JvmPluginSupport.linkMainSourceSourceSetDependencies(project, dslModel.getDependencies());
JvmPluginSupport.linkTestJavaVersion(project, getJavaToolchainService(), dslModel.getTesting());
JvmPluginSupport.linkTestSourceSourceSetDependencies(project, dslModel.getTesting().getDependencies());
dslModel.getRunTasks().add(project.getTasks().named("run"));
}
}
Сам StandaloneJavaApplicationPlugin
регистрируется с помощью специальной аннотации в главном плагине JvmEcosystemPlugin
:
@RegistersSoftwareTypes({
StandaloneJavaApplicationPlugin.class,
StandaloneJavaLibraryPlugin.class,
StandaloneJvmLibraryPlugin.class,
StandaloneJvmApplicationPlugin.class
})
public class JvmEcosystemPlugin implements Plugin {
@Override
public void apply(Settings target) {
target.getPlugins().apply(JvmEcosystemConventionsPlugin.class);
}
}
Логика плагина достаточно примитивная. Он берет декларативное описание модели, а дальше с помощью нее настраивает стандартный java плагин от Gradle.
Поддержка Spring Boot
Я решил сделать примитивную поддержку Spring Boot. Для этого я объявляю свой класс модели:
@Restricted
public interface SpringApplication extends JavaApplication {
}
Далее свой класс плагина:
StandaloneSpringApplicationPlugin
abstract public class StandaloneSpringApplicationPlugin implements Plugin {
public static final String SPRING_APPLICATION = "springApplication";
@SoftwareType(name = SPRING_APPLICATION, modelPublicType = SpringApplication.class)
abstract public SpringApplication getApplication();
@Override
public void apply(Project project) {
SpringApplication dslModel = getApplication();
project.getExtensions().add(SPRING_APPLICATION, dslModel);
project.getPlugins().apply(ApplicationPlugin.class);
project.getPlugins().apply(SpringBootPlugin.class);
project.getExtensions().getByType(SpringBootExtension.class).getMainClass().set(dslModel.getMainClass());
project.getExtensions().getByType(TestingExtension.class).getSuites().withType(JvmTestSuite.class).named("test").configure(testSuite -> {
testSuite.useJUnitJupiter();
});
linkDslModelToPlugin(project, dslModel);
}
@Inject
protected abstract JavaToolchainService getJavaToolchainService();
private void linkDslModelToPlugin(Project project, SpringApplication dslModel) {
JvmPluginSupport.linkJavaVersion(project, dslModel);
JvmPluginSupport.linkApplicationMainClass(project, dslModel);
JvmPluginSupport.linkMainSourceSourceSetDependencies(project, dslModel.getDependencies());
JvmPluginSupport.linkTestJavaVersion(project, getJavaToolchainService(), dslModel.getTesting());
JvmPluginSupport.linkTestSourceSourceSetDependencies(project, dslModel.getTesting().getDependencies());
dslModel.getRunTasks().add(project.getTasks().named("run"));
}
}
в котором практически ничего не меняю, помимо подключения самого SpringBootPlugin (предварительно добавив его в зависимости), и пробрасывания ему main класса:
project.getExtensions().getByType(SpringBootExtension.class).getMainClass().set(dslModel.getMainClass());
Не забываем зарегистрировать новый плагин:
@RegistersSoftwareTypes({
StandaloneJavaApplicationPlugin.class,
StandaloneJavaLibraryPlugin.class,
StandaloneJvmLibraryPlugin.class,
StandaloneJvmApplicationPlugin.class,
StandaloneSpringApplicationPlugin.class
})
public class JvmEcosystemPlugin implements Plugin {
…
}
Меняем в скрипте сборки javaApplication
на springApplication
, добавляем в зависимости spring-boot-starter
, запускаем Gradle Sync, и видим, что в модуле появилась новая таска bootRun:
Переписываем класс App:
@SpringBootApplication
public class App implements ApplicationListener {
public static void main(String[] args) {
SpringApplication.run(App.class);
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
System.out.println("Hello from Spring Boot built with Declarative Gradle");
}
}
Запуск через IntelliJ IDEA все также не работает, однако в терминале:
unified-prototype git:(main) ✗ ./gradlew :testbed-java-application:bootRun
> Task :testbed-java-application:bootRun
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.1)
2024-07-14T17:31:55.246+04:00 INFO 22406 --- [ main] com.example.App : Starting App using Java 17.0.4.1 with PID 22406 (/Users/alexander/workspace/declarative-gradle/unified-prototype/testbed-java-application/build/classes/java/main started by alexander in /Users/alexander/workspace/declarative-gradle/unified-prototype/testbed-java-application)
2024-07-14T17:31:55.248+04:00 INFO 22406 --- [ main] com.example.App : No active profile set, falling back to 1 default profile: "default"
2024-07-14T17:31:55.441+04:00 INFO 22406 --- [ main] com.example.App : Started App in 0.327 seconds (process running for 0.495)
Hello from Spring Boot built with Declarative Gradle
BUILD SUCCESSFUL in 2s
25 actionable tasks: 6 executed, 19 up-to-date
➜ unified-prototype git:(main) ✗
У нас все получилось!
Что интересно, как только я сделал Gradle Sync, у меня завелся установленный в IDEA Amplicode.
Гибкость для билд инженеров и продвинутых пользователей
Осталось рассказать про последний принцип Declarative Gradle. Я думаю, что вышесказанное говорит само за себя. Я как продвинутый пользователь с легкостью подключил простейшую поддержку Spring Boot. Конечно, какие-то сложные кейсы я не рассмотрел, однако уже сейчас видно, что в гибкости профессиональные пользователи Gradle не потеряют. А если для какой-то задачи уже есть Gradle плагин, то очень просто сделать для него обертку в Declarative Gradle стиле.
Заключение
Что ж, я провел достаточно увлекательно эти несколько часов, пока изучал Declarative Gradle и писал эту статью. Технология пока сырая, разработчики так и пишут, что она не для Production использования. IDEA не может запустить таски из дерева Gradle проекта. Но, у меня получилось запустить небольшое SpringBoot приложение, которое мгновенно задетектилось сторонним плагином Amplicode. Какое распространение получит Declarative Gradle после выхода в stable, сказать сложно. Но меня прельщает простота скриптов сборки для обычных разработчиков и потенциал поддержки в IDE. Работа с декларативным описанием и работа с императивным кодом это как разница между счетным множеством и континуумом.
Кстати, идею декларативности также исповедует новая система сборки от JetBrains — Amper. Но в отличие от Declarative Gradle описание проект выполняется в yaml файле (как JetBrains смог предать Kotlin?).
Исходники вы можете посмотреть в моем репозитории: https://github.com/alexander-shustanov/declarative-gradle-with-spring-boot/tree/master/unified-prototype/testbed-spring-application.
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
Ждем всех, присоединяйтесь!