Пишем скрипты на Котлине

Недавно написал CI Job. Изначально хотелось всё на Python в одном файле и без библиотечных зависимостей. Получилось порядка 600 строк. Явно перебор для такого подхода. Так что явно нужно рефакторить.

Решил не продолжать на Python:

  • слабая типизация ведет к более сложной отладке
  • кривая / сложная работа со списками

Сейчас такие ощущения:

  • BASH для однострочников и простых скриптов до 10-20 строк
  • Python без библиотек – для скриптов побольше (до 200 строк) и когда нужны специфические библиотеки (AI, например)
  • Kotlin для более длинных скриптов и cli (раньше еще рассматривал Golang для cli, но у него свои проблемы, а Kotlin продвинулся в этом направлении)

Скрипты в Kotlin

Для второй версии беру Kotlin в режиме скрипта. 2 библиотеки:

  • Kotlin Serialization – современный скрипт без JSON вряд ли будет
  • Klickt – нужно обрабатывать аргументы командной строки, в самом Котлине вообще ничего нет

Кратко как использовать Kotlin для скриптов:

  • Создаем файл с расширением main.kts и запускаем: kotlin my-script.main.kts
  • Можно использовать shebang: #!/usr/bin/env kotlin
  • Можно использовать несколько файлов: @file:Import("utils.main.kts") , но IDEA это не поддерживает: https://youtrack.jetbrains.com/issue/KTIJ-16352 (хотя скрипт будет работать)
  • Можно загружать зависимости: @file:DependsOn("com.google.code.gson:gson:2.11.0")
  • Компилятор закеширует зависимости и результаты сборки скрипта
  • Есть вспомогательная библиотека для работы с файлами для скриптов – kotlin-shell
  • Можно и тесты писать
  • Все это можно положить в Docker
  • Если хочется большего – есть Kscript

Оказалось, что Kotlin Serialization требует плагин компилятора с полным путем до соответствующего jar (является частью компилятора), а @file:CompilerOptions("-Xplugin=${KOTLIN_HOME}/lib/kotlinx-serialization-compiler-plugin.jar") не работает.

Получился такой скрип простой работы с JSON:

#!/bin/sh
///bin/echo >/dev/null <<EOC
/*
EOC
kotlinc -script -Xplugin="${KOTLIN_HOME}/lib/kotlinx-serialization-compiler-plugin.jar" -- "$0" "$@"
exit $?
*/
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
@file:DependsOn("com.github.ajalt.clikt:clikt:4.4.0")

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString

@Serializable
data class Data(val a: Int, val b: String)

val json = Json.encodeToString(Data(42, "str"))
val obj = Json.decodeFromString<Data>(json)

println(json)
println(obj)

В начале немного “черной магии”: один и тот же файл сначала интепретируется как shell-скрипт (и устанавливает путь с учетом переменной окружения), а затем как Kotlin-файл.

Дальше импортируем зависимости и работаем с JSON.

И вот такой Dockerfile:

FROM eclipse-temurin:21-jre

# Install dependencies for the script (jc, git), unzip for compiler unpack,
# and jq just in case it will be useful
RUN apt-get update \
&& apt-get -y install jc jq git unzip \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir /app

# Install Kotlin compiler
ARG KOTLINC_VERSION="2.0.0"
RUN wget -q "https://github.com/JetBrains/kotlin/releases/download/v$KOTLINC_VERSION/kotlin-compiler-$KOTLINC_VERSION.zip" && \
    unzip "kotlin-compiler-$KOTLINC_VERSION.zip" -d /usr/lib && \
    rm "kotlin-compiler-$KOTLINC_VERSION.zip"

ENV PATH $PATH:/usr/lib/kotlinc/bin:/app
WORKDIR /app

COPY json.main.kts ./

# Cache dependencies and compilation result for better start-up speed
ENV KOTLIN_MAIN_KTS_COMPILED_SCRIPTS_CACHE_DIR /app
RUN /app/json.main.kts && find /app/.m2 -type f -exec chmod 644 {} \;

ENTRYPOINT ["/app/json.main.kts"]

Из плюсов явно задаем версии JRE и Kotlin, а из минусов довольно большой для простого скрипта. Готовых образов под скрипты не нашел.

Kscript

Из-за этих 2х пунктов решил посмотреть Kscript. Докер там чище:

FROM kscripting/kscript
ENV KOTLIN_HOME /opt/kotlinc

RUN mkdir /app
ENV PATH $PATH:/app
WORKDIR /app

COPY json.kts ./

# Cache dependencies and compilation result for better start-up speed
ENV KOTLIN_MAIN_KTS_COMPILED_SCRIPTS_CACHE_DIR /app
RUN /app/json.kts

ENTRYPOINT ["json.kts"]

Сам скрипт весьма похож:

#!/usr/bin/env kscript
@file:CompilerOptions("-Xplugin=${KOTLIN_HOME}/lib/kotlinx-serialization-compiler-plugin.jar")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
@file:DependsOn("com.github.ajalt.clikt:clikt:4.4.0")

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString

@Serializable
data class Data(val a: Int, val b: String)

val json = Json.encodeToString(Data(42, "str"))
val obj = Json.decodeFromString<Data>(json)

println(json)
println(obj)

Docker-образ kscript устарел: там kotlin 1.7. А kotlinx-serialization-json зависит от компилятора – нужно подбирать версию. Так же clikt зависит от версии Kotlin и тоже нужно подбирать более старую версию.

На этом знакомство с Kscript заканчивается. Возвращаемся к стандартным скриптам.

Упрощаем работу

Тогда решил сделать базовый образ, чтобы Dockerfile-скрипта выглядел как-то так:

FROM stepin/kotlin-scripting

COPY json.main.kts ./

# Cache dependencies and compilation result for better start-up speed
RUN /app/json.main.kts

ENTRYPOINT ["/app/json.main.kts"]

Ага, оно работает :).

Так же можно запускать без создания Dockerfile:

# run inline script
docker run --rm -i stepin/kotlin-scripting 'println("Hello, world!")'

# run script from file
docker run --rm -i stepin/kotlin-scripting - < script.main.kts

# map current folder into container if it's needed
docker run --rm -i -v "$(PWD):/data" -v "$(HOME)/scripts/my.main.kts:/app/my.main.kts" -w /data --user "$(id -u)" stepin/kotlin-scripting

Собираем в локальный файл

Как бонус, посмотрим как скрипт можно упаковать в один файл со всеми зависимостями, да еще и скомпилируем в нативный бинарник.

Есть 2 варианта:

  • сборка в JVM, затем Graal преобразует в нативный код
  • Kotlin/Native сразу работает с нативным кодом

Fat jar

Можно собрать с использованием Gradle. Либо через Kscript. Простого способа через kotlin / kotlinc нет.

Kotlin/Native

Попробуем Kotlin/Native, для этого нужно отдельно поставить особый компилятор (https://github.com/JetBrains/kotlin/releases/tag/v2.0.0 kotlin-native-prebuilt- )– либо из релизов с github, либо так:

brew install --cask kotlin-native

И тогда собираем:

kotlinc-native 1.main.kts -o 1 -language-version=1.9

Но это не работает на версии 2.0.0 (даже с опцией -language-version=1.9). Но работает для обычных kt-файлов:

fun main() {
    println("Hello, World!")
}

И собираем:

kotlinc-native hello.kt -o hello -opt
chmod +x hello.kexe
mv hello.kexe hello

Получается 2 файла:

  • hello.kexe на 499K – собственно скрипт
  • hello.kexe.dSYM на 96B – необязательная отладочная информация (папка)

Запускается быстро:

$ time ./hello
Hello, World!
./hello  0,00s user 0,00s system 70% cpu 0,008 total

Так что либо искать какую-то предыдущую версию, которая работает со скриптами. Либо ждать новую. Я подожду, мне особо нативные cli сейчас не нужны.

Отмечу, что бинарник содержит некоторые зависимости:

$ file hello
hello: Mach-O 64-bit executable arm64

$ otool -L hello
hello:
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.100.2)
	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.0)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
	/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 2420.0.0)
	/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 2420.0.0)

Грубо говоря, это похоже на C++ приложение, а не как Golang вообще без зависимостей. Поэтому FROM scratch не получится, но это обычно и не надо. В любом случае, это какой-то Linux-базовый образ (мегабайт на 60), а не JVM на 600.

Нативная сборка через Graalvm

Собираем fat jar (пример выше), затем https://www.graalvm.org/22.0/examples/java-kotlin-aot/ .

Использовал Graalvm раньше, не очень понравилось: компьютер прям что-то изо всех сил делает, достаточно долго и не каждый раз успешно.

Подитог

Когда нужно нативное приложение и есть Java-зависимости, то используем Graalvm. Когда нет Java-зависимостей, то Kotlin/Native быстрее собирает.

Конечно, нативный cli все еще слишком большой (с позиции перфекционизма) – все-таки hello world – это меньше 1кб (или около того), а не в 500 раз больше (на примере Kotlin/Native). Тут Golang лучше (быстрее собирается в случае Graalvm и меньше бинарник), но сам код писать проще и читабельнее в Kotlin.

Так что будем надеяться, что компилятор в нативную сторону тоже активно будет развиваться. Хотя больше проблем с второклассной поддержкой скриптов, чем с размером.

Замена awk

Еще бонус. Сразу к коду:

kscript -t 'lines.split().select(10,1,12).print()' some_flights.tsv 

Или на чистом Котлине:

kotlin -e 'generateSequence(::readlnOrNull).forEach{ val f = it.split("\t"); println("${f[2]}\t${f[1]}") }' -- - < example.tsv

Весьма удобно. А то уж больно awk страннен, но при этом время от времени нужен.

Больше примеров здесь: https://holgerbrandl.github.io/kotlin/2017/05/08/kscript_as_awk_substitute.html

Код

Исходники из этой статьи в https://github.com/stepin/kotlin-scripting .