Як і навіщо переходити з Groovy на Kotlin у скриптах збирання
Усім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу поділитися з вами своїм досвідом міграції коду з Groovy на Kotlin. Я вже описав переваги Kotlin в окремій статті. Популярність Kotlin зростає, Groovy падає, і ті завдання, для вирішення яких раніше застосовувався Groovy, тепер можна написати на Kotlin. У статті йтиметься про міграцію скриптів збірки Gradle. Ну і за останні роки у нас накопичилося достатньо досвіду роботи з таким підходом, і ми розглядаємо ці технології на тренінгах з Kotlin
Мета міграції
Якщо вашому Java-проєкту не більше як 10 років, то під час його створення у вас був вибір серед двох систем збирання — Maven та Gradle. Maven ще з початку
Раніше Gradle підтримував лише Groovy для написання скриптів, але у травні 2016 року (у версії 3.0) оголосив про підтримку Kotlin як другої мови. Ймовірно, це було пов’язано з виходом Kotlin 1.0 — першої стабільної версії. Стало очевидно, що Kotlin — це не чиясь забава, а досить багатонадійна технологія, за якою майбутнє.
Самі розробники Gradle пояснили, що з додаванням підтримки Kotlin програмістам простіше писати скрипти завдяки:
- автоматичному доповненню до IDE;
- зручній документація;
- можливості рефакторингу;
- навігації за вихідними джерелами.
Працювати з Kotlin зручніше, ніж з Groovy, оскільки це статично типізована мова. Цікаво, що зараз, за 7 років, Gradle все ще підтримує Groovy — можливо, тому що компіляція Groovy-скриптів досі працює швидше. Тим не менш, через це в Gradle-дистрибутив вбудовані й Groovy-бібліотеки (11 МБ), і Kotlin-компілятор (56 МБ) та багато інших Kotlin-бібліотек. А це перетворило дистрибутив Gradle на справжнього
У будь-якому випадку Kotlin є перспективнішою мовою, ніж Groovy. А все тому, що Kotlin, як і Gradle, використовується для Android-проєктів. Як перейти на Kotlin у скриптах збирання? Це не так просто, і проєкт OpenRewrite, який я нещодавно використовував, нам не допоможе. На жаль, сам Gradle не надає засобів для автоматичної міграції, хоча це не так складно. Потрібно лише завантажити Groovy-скрипт (у внутрішнє уявлення DSL), а потім зберегти його в форматі Kotlin. На жаль, це досі не підтримується, тому такий перехід потрібно робити вручну — для цього необхідно добре знати і Groovy, і Kotlin.
Спробуємо крок за кроком переписати наші скрипти, а заразом і порівняємо розмір скриптів та продуктивність складання.
Початок міграції
Перший природний крок — перейменування скрипта збірки, оскільки Gradle інтерпретує файли з розширенням *.gradle як Groovy-скрипти. Візьмемо перший скрипт і перейменуємо його з build.gradle на build.gradle.kts. На щастя, Gradle дозволяє змішувати в одному проєкті скрипти на Groovy та Kotlin. Спочатку файл мав такий вигляд:
dependencies { implementation project(':common') implementation("jakarta.validation:jakarta.validation-api:3.0.2") api 'io.swagger.core.v3:swagger-annotations:2.2.8'
testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")}
Запускаємо збiрку gradle assemble та отримуємо два види помилок:
implementation project(':common')^ Too many characters in a character literal '':common''
Причина помилки: Kotlin не підтримує апострофи для оголошення рядка (на відміну від Groovy), тому доведеться перейти на лапки:
implementation project(":common")Знову збираємо складання і тепер отримуємо іншу помилку:
api "io.swagger.core.v3:swagger-annotations:2.2.8"
^ Unexpected tokens (use ';' to separate expressions on the same line)
У Groovy можна вказувати значення аргументів, розділяючи їх прогалиною від назви методу. У Kotlin таке неприпустимо, тому потрібно використовувати звичний спосіб — дужки:
api("io.swagger.core.v3:swagger-annotations:2.2.8") Унаслідок цього скрипт збирання має такий вигляд:
dependencies { implementation(project(":common")) implementation("jakarta.validation:jakarta.validation-api:3.0.2") api("io.swagger.core.v3:swagger-annotations:2.2.8") testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")}
Цікаво, що блок dependencies перебудовувати не довелося, оскільки він у Kotlin DSL не змінився і приймає об’єкт типу Groovy Closure:
abstract fun dependencies(configureClosure: Closure)
А Groovy Closure — це аналог лямбд у Kotlin з ідентичним синтаксисом. Тепер перейдемо до другого скрипту збирання, де трапляється інша помилка:
api("io.swagger.core.v3:swagger-annotations:$swaggerVersion")^ Unresolved reference: swaggerVersion
Змінна swaggerVersion оголошена в кореневому скрипті:
ext {swaggerVersion = "2.2.8"
}
Тут блок ext {} — це синонім оголошення project.ext, і коли ви в Groovy звертаєтеся до свого swaggerVersion, то це ж саме, що б ви написали project.ext.swaggerVersion. У Kotlin це працює трохи інакше. Зрозуміло, він підтримує string templates, але не підтримує блок ext {}. Які ж є альтернативи?
По-перше, можна просто оголосити змінну у скрипті:
val swaggerVersion = "2.2.8"
Такий підхід не дуже хороший, тому що це глобальна змінна, до якої всі можуть мати доступ. А ООП вчить нас намагатися обмежувати область видимості.
Другий підхід — створити Singleton та оголосити там версії як константи:
object Versions {const val swaggerVersion = "2.2.8"
}
Тоді оголошення залежності буде виглядати трохи інакше:
api("io.swagger.core.v3:swagger-annotations:${Versions.swaggerVersion}") Третій підхід — оголосити всі версії глобальних залежностей в окремому тестовому файлі gradle.properties:
# Dependency versions
swaggerVersion=2.2.8
Правда саму змінну все ж таки доведеться оголосити (як делегована властивість):
val swaggerVersion:String by project
Четвертий варіант використовує так звані Extra properties, схожі на ті, які ми оголошували в Groovy:
extra["swaggerVersion"] = "2.2.8"
Щоправда, використовувати їх більш громіздко, тому що доводиться додавати щоразу pro-ject.extra:
api("io.swagger.core.v3:swagger-annotations:${project.extra["swaggerVersion"]}") На жаль, це плата за втрату гнучкості, яка була у Groovy. Якщо розглянути всі чотири варіанти з точки зору простоти міграції, то оптимальнішим здається розмістити версії в gra-dle.properties, а потім звертатися до них через делегування.
Перейдемо до наступних скриптів. При їх збираннi отримуємо помилку при імпорті плагіна:
id "me.champeau.gradle.jmh" version "0.5.3" apply true
^ Unexpected tokens (use ';' to separate expressions on the same line)
Тут теж потрібно замінити прогалину на дужку:
plugins { id("me.champeau.gradle.jmh") version "0.5.3" apply true}
Переходимо до скрипту для war-проєкту. Тут одразу падає збірка на використанні плагіна:
apply plugin: 'war'
^ Function invocation 'apply()' expected
Потрібно просто оголосити плагін за допомогою блоку plugins {}:
plugins {war
}
Далі блок оголошень змінних:
ext {elVersion = '3.0.0'
weldVersion = '5.1.0.Final'
metricsVersion = '4.2.17'
resilience4jVersion = '2.0.2'
}
який проситься замінити на:
const val elVersion="3.0.0"
const val weldVersion="5.1.0.Final"
const val metricsVersion="4.2.17"
const val resilience4jVersion="2.0.2"
Але, на жаль, це не проходить, тому що
Const 'val' are only allowed on top level, in named objects, or in companion objects
Тому доведеться обійтися просто val. У цьому скрипті в нас є Groovy-функція, яка точно буде забракована компілятором Kotlin:
def isWildflyDependency(File file) { return(["jboss", "weld", "cdi", "javax.faces", "validation-api", "javax.el"].findIndexOf { file.getName().contains(it) } >= 0}
Її потрібно просто переписати як:
fun isWildflyDependency(file: File): Boolean { return arrayOf("jboss", "weld", "cdi", "javax.faces", "validation-api", "javax.el").find({ file.getName().contains(it) }) != null}
Далі йде конфігураційний блок для War-плагіна, який не підходить у Kotlin-варіанті:
war {^ Expression 'war' cannot be invoked as a function. The function 'invoke()' is not found
Тут доведеться додати префікс tasks (який опускається для Groovy-скриптів):
tasks.war { У його тілі виклик методу isWildflyDependency тепер не компілюється:
classpath = classpath.filter { !project.hasProperty("wildfly") || !isWildflyDependency(it) }^ Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type FileCollection?
Змінна classpath належить до nullable типу, тому доведеться додати ? для безпечного виконання:
classpath = classpath?.filter { !project.hasProperty("wildfly") || !isWildflyDependency(it) }Далі йде сторонній репозиторій:
repositories { maven { url "https://repository.primefaces.org" }}
Тут потрібно викликати maven як функцію:
repositories {maven(url="https://repository.primefaces.org")
}
У Groovy теж є свій спосіб іменування значень аргументів (за допомогою двокрапок):
ant.native2ascii(
src : project.file("src/main/resources/txt"), dest : project.file("src/main/resources/ui"))
Переробимо цей блок під Kotlin:
ant.native2ascii(
src = project.file("src/main/resources/txt"), dest = project.file("src/main/resources/ui"))
Остання проблема в цьому скрипті — відсутність методу native2ascii в об’єкті ant:
^ Unresolved reference: native2ascii
Виникає питання, а що це за об’єкт ant і звідки він узявся? Ми його ніде не оголошували. Насправді цей об’єкт неявно створюється під час складання, його тип — AntBuilder. По суті, він є обгорткою над типом AntBuilder з Groovy. Але найцікавіше інше: методу native2ascii справді немає в цьому класі, й абсолютно незрозуміло, чому це працювало в Groovy скрипті. Одне з пояснень: native2ascii є назвою завдання в файлі Ant, і Groovy спеціальними способами його викликає. Я додав тикет на цю проблему, зачекаємо на відповідь від офіційних осіб.
Але як же викликати цю функцію? Можна спробувати оголосити Ant завдання, ініціалізувати та викликати її:
import org.apache.tools.ant.taskdefs.optional.Native2Ascii
val task = Native2Ascii()
task.setSrc(project.file("src/main/resources/txt"))task.setDest(project.file("src/main/resources/ui"))task.execute()
На жаль, отримуємо помилку:
Cannot invoke "org.apache.tools.ant.Project.log(String, int)" because "p" is null
Тому поки що відкладемо цю частину та йдемо далі. У Spring Boot-проєктах також не все гладко. По-перше, apply from елемент (для імпорту іншого скрипта) більше не працює та видає відразу кілька помилок:
apply from: "../gradle-scripts/build-spring-boot.gradle"
^ Function invocation 'apply()' expected
^ Not enough information to infer type variable T
Доводиться переробляти apply у виклик функції:
apply(from ="../gradle-scripts/build-spring-boot.gradle")
Далі переробити на функцію:
runtimeOnly("io.jsonwebtoken:jjwt-jackson:${jwtVersion}") { exclude group: "com.fasterxml.jackson.core", module: "jackson-databind" }виклик exclude:
runtimeOnly("io.jsonwebtoken:jjwt-jackson:${jwtVersion}") { exclude(group="com.fasterxml.jackson.core", module= "jackson-databind") }Потім йде блок конфігурації, який перейменовує результуючий jar-файл:
bootJar {archiveFileName = "gateway.jar"
}
^ Expression 'bootJar' cannot be invoked as a function. The function 'invoke()' is not found
Цікаве тут те, що приклад із Reference Guide виявився неробочим:
tasks.named<BootJar>("bootJar") { archiveFileName.set("gateway.jar")}
Після деякого дослідження з’ясувалося, що правильним буде такий блок:
tasks.bootJar { archiveFileName.set("gateway.jar")}
Але головні проблеми попереду. Чомусь Gradle взагалі відмовляється компілювати скрипт, який ми імпортуємо:
apply(from ="../gradle-scripts/build-spring-boot.gradle.kts")
З помилок випливає, що він не бачить там конфігурацію (implementation) з Java-плагіна, який був оголошений у скрипті-контейнері:
implementation(platform("org.springframework.cloud:spring-cloud-dependencies:$cloudStarterVersion"))^ Unresolved reference: implementation
Помилка не зникає, якщо в скрипті, що імпортується, вказати Spring Boot-плагін:
plugins { id("org.springframework.boot") version "3.0.5" apply true}
Спробуємо зібрати build-spring-boot-gradle.kts окремо від проєкту:
gradle -b build-spring-boot-gradle.kts
Скрипт збирається успішно. Причина такої поведінки незрозуміла, я створив тикет на цю помилку, а поки що назад повернув Groovy-скрипт build-spring-boot.gradle та імпортував саме його (з Kotlin):
apply(from ="../gradle-scripts/build-spring-boot.gradle")
Пізніше я отримав відповідь від інженерів Gradle щодо цієї проблеми. Виявилося, що в Kotlin DSL є два типи використання моделі та конфігурації: type-safe та type-unsafe. Коли ми вказуємо плагіни через plugins {}, можемо використовувати type-safe-конфігурацію, хоча назва конфігурації (api) визначається лише runtime:
api("io.swagger.core.v3:swagger-annotations:2.2.8") Але якщо ми імпортуємо один скрипт з іншого, то нам доводиться використовувати type-unsafe-підхід і вказувати назви властивостей та методів як рядки:
"api"("io.swagger.core.v3:swagger-annotations:2.2.8") Такий варіант є гіршим, тому ми залишимо чинний підхід з імпортом Groovy-скрипта.
Переходимо до скрипту збирання для iншого сервісу. Тут не компілюється блок конфігурації для Spring REST Docs:
test {outputs.dir snippetsDir
}
Перепишемо його як:
tasks.test {outputs.dir(snippetsDir)
}
Ще один рядок, який більше не компілюється, — це визначення залежності між задачами (tasks):
dependsOn test
Що у Kotlin буде мати такий вигляд:
dependsOn(tasks.test)
Складніше буде з конфігурацією JVM-аргументів для тесту. Річ у тому, що з цього блоку не зрозуміло, який тип властивостей jvmArgs:
test {jvmArgs '-Djersey.config.test.container.port=9997'
}
Спочатку здається, що це рядок, але з документації випливає, що все це список, і навіть одиничний елемент потрібно перетворити на список за допомогою listOf:
tasks.test { jvmArgs(listOf("-Djersey.config.test.container.port=9997")) }
Переходимо до сервісу, який використовує Micronaut. Тут у нас окремий конфігураційний блок для GraalVM:
graalvmNative { binaries { main {sharedLibrary = false
buildArgs.add("--libc=musl") buildArgs.add("--static")}
}
}
який компілюється з помилкою:
^ Expression 'main' cannot be invoked as a function. The function 'invoke()' is not found
Думаю, що попередньо мало хто скаже, який вигляд такий блок матиме в Kotlin варіанті. А ось документація підказує, що потрібно main перетворити на named("main"):
graalvmNative { binaries { named("main") {sharedLibrary.set(false)
buildArgs.add("--libc=musl") buildArgs.add("--static")}
}
}
named — це метод колекції tasks, який повертає завдання за назвою. Перейдемо до клієнтської програми. Тут ми використали свій клас:
@CacheableTask
public abstract class CacheableYarnTask extends com.github.gradle.node.yarn.task.YarnTask {@InputFiles
@PathSensitive(PathSensitivity.ABSOLUTE)
FileCollection sourceFiles = project.files("client/src", "client/package.json", "client/angular.json")@OutputDirectory
File outputDir = project.file("client/dist") }
який потрібно переробити під синтаксис Kotlin:
@CacheableTask
public abstract class CacheableYarnTask() : com.github.gradle.node.yarn.task.YarnTask() {@InputFiles
@PathSensitive(PathSensitivity.ABSOLUTE)
val sourceFiles = project.files("client/src", "client/package.json", "client/angular.json")@OutputDirectory
val outputDir = project.file("client/dist") }
Винятковий момент, коли код у Kotlin вийшов меншим через те, що в ньому можна не вказувати тип, якщо оголошення змінної поєднується з ініціалізацією. Водночас зручність Groovy в тому, що для вказівки залежності між завданнями просто написати:
assemble.dependsOn(tasks.named("buildAngular")) У Kotlin же доведеться dependsOn обернути в лямбду:
tasks.assemble { dependsOn(tasks.named("buildAngular"))}
Начебто все працює успішно, тільки ось це оголошення завдання:
tasks.register<CacheableYarnTask>("buildAngular", CacheableYarnTask) { dependsOn(tasks.named("yarn_install")) args = listOf("run","build")}
призводить до дивної помилки:
^ Classifier 'CacheableYarnTask' does not have a companion object, and thus must be initialized here
Річ у тому, що спочатку цей блок мав такий вигляд:
tasks.register("buildAngular", CacheableYarnTask) { dependsOn tasks.named("yarn_install")args = ['run','build']
}
Яку б мову ви не використовували, Gradle вимагає вказати тип завдання під час її створення. У Groovy можна вказати просто назву типу (CacheableYarnTask), але Kotlin звичніше передати тип як generic-аргумент:
tasks.register<CacheableYarnTask>
І тоді можна не вказувати другий аргумент:
tasks.register<CacheableYarnTask>("buildAngular") { Аналогічного результату можна було б досягти й без generics, але передавши клас як другий аргумент:
tasks.register("buildAngular", CacheableYarnTask::class) {Тепер лишилося оновити кореневий скрипт. Тут потрібно змінити оголошення версій:
sourceCompatibility = 1.17
targetCompatibility = 1.17
на явне використання Java-плагіна та спеціального перерахування JavaVersion:
java {sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
}
Тепер міграція здається закінченою, але все ще залишився блок Ant-коду, який не хоче компілюватись у Kotlin. Тут є два варіанти вирішення. Перший (найпростіший і найбезпечніший з точки зору міграції): перенести його в окремий Groovy-скрипт ant.build.gradle:
processResources { doFirst {ant.native2ascii(
src : project.file("src/main/resources/txt"), dest : project.file("src/main/resources/ui"))
}
exclude("txt", "docker", "admin.xml")}
Й імпортувати його з Kotlin-скрипту:
apply(from="ant.build.gradle")
Другий варіант для тих, хто хоче позбавитися повністю від Groovy коду: використовувати метод withGroovyBuilder, який дозволяє динамічно звертатися до Groovy-коду (у нашому випадку до методів та властивостей об’єкта ant:
ant.withGroovyBuilder { "native2ascii"("src" to project.file("txt"), "dest" to project.file("ui"))}
Зверніть увагу, що назви та значення аргументів передаються як пари ключ — значення в Map.
Перезбираємо проєкт, і тоді складання відбувається успішно. У чому ми виграли, перейшовши на Kotlin? Перевіримо два досить важливі показники: час збирання та розмір скриптів. Gradle за замовчуванням кешує артефакти збирання, тому для збiрки проєкту з нуля використовуємо опцію --no-build-cache:
gradle —no-build-cache clean assemble
Отримуємо:
- Kotlin — 19 секунд
- Groovy — 18 секунд
Тепер перейдемо до загального розміру скриптів:
- Groovy — 16.8 КБ
- Kotlin — 17.6 КБ
Як ви бачите, після міграції ми не втратили в продуктивності, але приблизно на 5% втратили в обсязі. У цьому є іронія, тому що однією з переваг Gradle (у порівнянні з Maven) була його компактність. І ось тут ми трохи нею пожертвували.
Висновки
Якщо підбивати підсумки міграції, то вона пройшла успішно, хоч і не без проблем. Було знайдено дві помилки, які свідчили про те, що синтаксис та можливості Kotlin DSL відрізняються від аналогічних у Groovy DSL. Міграція вимагає хорошого знання Groovy/Kotlin і DSL. Водночас ви можете в одному проєкті поєднувати Groovy/Kotlin скрипти і навіть імпортувати Groovy код з Kotlin, що допомогло нам вирішити наші проблеми. З Kotlin ми отримали статичну типізацію, але втратили легкість Groovy, яка дозволяла писати більш простий читабельний код.
Чи варто переходити з Groovy на Kotlin? Вирішувати вам, але вже зараз можна порадити писати в тому стилі, який притаманний обом мовам і в майбутньому полегшить міграцію. Памʼятаємо про:
- лапки, а не апострофи;
- використання дужок для виклику функцій.
Не забувайте, що Gradle поки що підтримує Groovy, але ця підтримка може закінчитися будь-якої миті. Чомусь мені здається, що підтримка одразу двох мов вимагає надто багато зусиль і створює надто багато багів. У Gradle репозиторії на Github зараз понад 2 300 відкритих тикетів, і їхня кількість лише зростає. Але навіть якщо підтримка Groovy залишиться, для роботи зі скриптами збірки на ньому потрібна людина, яка добре знається на Groovy, а таку знайти складніше, ніж Kotlin-розробника.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
19 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів