Недавно написал 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 .