Scripting in Kotlin

最近又重新非常心仪 kotlin,甚至将自己的 rss 时间线聚合都重新用 kotlin 写了一次。kotlin 的各种函数式的写法确实很招我喜欢。因此就想要把它更加应用到自己的生活中,其中 code 在日常生活中最重要的部份,还是写点小脚本解决日常问题,于是就考察了一下使用 kotlin 来写脚本的方式。

kotlin 官方

一搜 kotlin scripting,第一个结果就是官方关于 scripting 的 文档,我也到其提供的代码示例库去研究了一下,它主要提供了三种方式的 kotlin scripting 使用方式。

jsr223

java 里面的一个已经实现了的提案,提供了一个可用于运行脚本的 host,通过使用外部依赖,可以使这个 host 支持 kotlin。main.kts 的功能似乎更强,可以导入别的脚本 @file:Import("import-common.main.kts")

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import javax.script.ScriptEngineManager

val engine = ScriptEngineManager().getEngineByExtension("main.kts")!!
// 或者
// val engine = ScriptEngineManager().getEngineByExtension("kts")!!

print("> ")
System.`in`.reader().forEachLine {
    val res = engine.eval(it)
    println(res)
    print("> ")
}

需要依赖

runtimeOnly("org.jetbrains.kotlin:kotlin-main-kts:$kotlinVersion") // 可选
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-jsr223:$kotlinVersion")

它的输出是直接打到 System.out 里面,但是也不会直接显示出来,需要截获它的输出,有点诡异

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun captureOut(body: () -> Unit): String {
    val outStream = ByteArrayOutputStream()
    val prevOut = System.out
    System.setOut(PrintStream(outStream))
    try {
        body()
    } finally {
        System.out.flush()
        System.setOut(prevOut)
    }
    return outStream.toString().trim()
}

simple-main-kts

官方提供了 kotlin-scripting-common|jvm|jvm-host 的依赖库(但是在 experimental 中,而且在很久了)。

这种方式本质也是通过创建一个 jvm 运行时 host 来执行,不过这个可以通过注解的方式,添加许多编译配置,从而获取声明的 maven 依赖。不过这样需要的额外配置还是很多,自定义工作很多。

一方面通过注解来声明 kotlin script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import kotlin.script.experimental.annotations.KotlinScript

// The KotlinScript annotation marks a class that can serve as a reference to the script definition for
// `createJvmCompilationConfigurationFromTemplate` call as well as for the discovery mechanism
// The marked class also become the base class for defined script type (unless redefined in the configuration)
@KotlinScript(
    // file name extension by which this script type is recognized by mechanisms built into scripting compiler plugin
    // and IDE support, it is recommendend to use double extension with the last one being "kts", so some non-specific
    // scripting support could be used, e.g. in IDE, if the specific support is not installed.
    fileExtension = "simplescript.kts"
)
// the class is used as the script base class, therefore it should be open or abstract
abstract class SimpleScript

可以通过配置 @KotlinScript 注解来声明脚本的编译配置和执行配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@KotlinScript(
    fileExtension = "smain.kts",
    // the class or object that defines script compilation configuration for this type of scripts
    compilationConfiguration = SimpleMainKtsScriptDefinition::class,
    // the class or object that defines script evaluation configuration for this type of scripts
    evaluationConfiguration = MainKtsEvaluationConfiguration::class
)
// the class is used as the script base class, therefore it should be open or abstract. Also the constructor parameters
// of the base class are copied to the script constructor, so with this definition the script will require `args` to be
// passed to the constructor, and `args` could be used in the script as a defined variable.
abstract class SimpleMainKtsScript(val args: Array<String>)

根据声明的 script class 来创建运行的 host。host 的创建其实就是通过之前注解声明的 Script class 来创建编译的 configuration,另外根据需要来创建 evaluate 时的 configuration,来编译执行。

1
2
3
4
5
6
7
8
9
    val compilationConfiguration = createJvmCompilationConfigurationFromTemplate<SimpleScript> {
        jvm {
            // 声明需要的依赖,用于编译
            dependenciesFromCurrentContext(
                "script" /* script library jar name (exact or without a version) */
            )
        }
    }
BasicJvmScriptingHost().eval(scriptFile.toScriptSource(), compilationConfiguration, null)

main-kts

通过上面的配置,其实就已经可以实现一个简单的 kotlin scripting。只需要再封装一下,添加点功能,比如缓存,就可以变成一个可用的 scripting 工具。实际上 jetbrains 官方将这个封装成来一个 kotlin-main-kts.jar 供运行。

可以把 kotlin script 命名成*.main.kts,即可以直接用 kotlin 来运行脚本

kotlinc -cp <path/to/kotlin-main-kts.jar> script.main.kts

在 Kotlin version 1.3.70 后,可以直接把这个 jar 都省掉,直接

kotlin script.main.kts

或者直接在脚本里面写 shebang

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#!/usr/bin/env kotlin
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-html-jvm:0.8.0")

import kotlinx.html.*; import kotlinx.html.stream.*; import kotlinx.html.attributes.*

val addressee = args.firstOrNull() ?: "World"

print(createHTML().html {
    body {
        h1 { +"Hello, $addressee!" }
    }
})

真爽,看起来就是我想要的方案了。这个 main-kts 的方法,会对脚本进行缓存,第一次运行后,会编译保存到本地 cache 目录,如果内容没有更改,会直接把已编译版本拿来用。

而且官方仓库还说 idea 支持 main.kts 脚本的补全提示,看起来就更香了,用起来也还行,补全还是挺香的,尤其是加上 GitHub Copliot,起飞。

Starting from the Kotlin IntelliJ plugin version 1.3.70, the .main.kts scripts are supported automatically in the IntelliJ IDEA, provided that they are placed outside of the regular source folders. E.g. if this project is imported into the IntelliJ, the demo scripts in the scripts folders should be properly highlighted and support navigation, including navigation into imported libraries.

第三方支持

除了官方提供的 scripting 方法以外,也有一些第三方库因为各种原因,比如之前官方的 scripting 功能还没出的时候,造出一些用 kotlin scripting 写代码的方法

  • kscript 是 GitHub 一个比较有名的 kotlin 脚本库。
    • 可以提供更加丰富功能特性的 kotlin 脚本。比如编译缓存,使用外部依赖,设置运行时参数,从输入或者链接读取脚本内容以及将脚本发布成独立二进制文件。
    • 不过随着官方发布了正式的 scripting 功能,好像提到的大多功能特性都被覆盖到了。
  • jbang 是一个使用 jvm 语言写脚本的工具软件。jbang 支持的脚本语言就更多了,java,kotlin 都可以,而且 jbang 感觉是有意做成一个脚本平台广场,还可以将写好的代码发布到其提供的 app store 中,看起来也不错,idea 也可以通过插件提供自动补全对支持。不过其对于 kotlin 的支持好像也不怎么样,前两年刚接触的时候,还帮其修了一个 kotlin 编译的 bug。

后记

写脚本最主要的还是方便,而方便有两方面,一是写要方便,二是跑起来要方便,比如有个小任务,随手打开个 vscode 甚至都不同打开目录,就能有自动补全开写,并在命令行一行命令执行,完成。这样才叫方便。

这样调研了一轮,看起来用 kotlin 脚本,方便地跑起来现在已经是没有问题了,问题就在于如何方便地写。写个小东西打开 idea 是否方便呢?如果是以前的 Windows,打开一次得等上个几分钟,那可以直接否掉了。但是现在新电脑打开 idea 几乎是秒开,看起来使用 idea 来写脚本好像又变得可以接受了。

不过最后好像还是用F#来写香😂·