Kotlin Script 倒底有多猛,一篇文章带你完全了解

Posted by rarnu on 10-05,2023

在先前说 3K 的时候,提到过一款神奇的,由 JetBrains 自行开发的 Kotlin Script。本篇即是对它进行一个完整的探索,以确保在实际使用时,不必再踩坑或是遇到其他不可预见的情况。

零、基础代码

要试用脚本,那么基础代码是必要的,这里给出最终的脚本调用代码作为基础代码:

import javax.script.ScriptContext
import javax.script.ScriptEngineManager
import javax.script.SimpleScriptContext

object Scripting {

    private var mgr: ScriptEngineManager = ScriptEngineManager()

    fun <T> runScript(code: String, params: Map<String, Any?>? = null): Pair<T?, Exception?> {
        val engine = mgr.getEngineByExtension("kts")
        val bindings = engine.createBindings()
        if (params != null) {
            for ((k, v) in params) {
                bindings[k] = v
            }
        }
        val ctx = SimpleScriptContext().apply {
            setBindings(bindings, ScriptContext.ENGINE_SCOPE)
        }
        return try {
            engine.eval(code, ctx) as? T to null
        } catch (e: Exception) {
            null to e
        }
    }
}

inline fun <reified T> Any.getValue(field: String): T? =
    this::class.java.getDeclaredField(field).apply { isAccessible = true }.get(this) as? T

一、性能

由于编译优化的存在,在测试性能时我们需要寻找一种不会被优化的算法,比如说计算质数,由于质数的出现没有规律,从理论上来说,编译器将无法对计算质数的代码进行优化。为了保险起见,采用两种不同的计算质数的方法,一种是试除法,一种是双循环,各运行 1000 次取其平均耗时,具体的代码实现如下:

// 试除法
fun isPrimeNum(n: Int): Boolean {
	if (n <= 3) {
        return n > 1
    }
    for (i in 2 until sqrt(n.toDouble()).toInt() + 1) {
        if (n % i == 0) {
            return false
        }
    }
    return true
}

var findCount = 0
val startTime = System.currentTimeMillis()
for (i in 2..300000) {
    if (isPrimeNum(i)) {
        findCount++
    }
}
val endTime = System.currentTimeMillis()
println("run time: ${endTime - startTime}ms")
println("findCount: $findCount")
// 双循环法
var findCount = 0
val startTime = System.currentTimeMillis()
for (i in 2..300000) {
    for (n in 2..i) {
        if (n == i) {
            findCount++
        }
        if (i % n == 0 && n < i) {
            break
        }
    }
}
val endTime = System.currentTimeMillis()
println("run time: ${endTime - startTime}ms")
println("findCount: $findCount")

由于 Kotlin 和 Kotlin Script 代码的高度一致性,上述的代码可以同时在 Kotlin/JVM 下运行,也可以在 Kotlin Script 下运行。下面是性能数据:

代码 运行方式 耗时 内存占用
试除法 Kotlin/JVM 7ms 63.8M
双循环法 Kotlin/JVM 3098ms 66.4M
试除法 Kotlin Script 7ms 286M
双循环法 Kotlin Script 3099ms 273M
试除法(对照) Java/JVM 10ms 70M
双循环法(对照) Java/JVM 3004ms 68.2M
试除法(对照) GraalVM/JS/JVM 2772ms 138.8M
双循环法(对照) GraalVM/JS/JVM 45986ms 135.1M
试除法(对照) GraalVM/JS/GraalVMJDK 189ms 120M
双循环法 GraalVM/JS/GraalVMJDK 3325ms 122.6M

可以清晰看到的是,Kotlin Script 对内存的占用是比较大的,一经开启,将近 300 M 的内存会被其直接收入囊中,但是从另一方面来说,Kotlin Script 的执行效率与 Kotlin/JVM 本身没有太大的区别,从性能上来说是值得信赖的。
另外,对于多次执行脚本,并行执行脚本的情况,其内存占用的提高程度不大,可以想到的是在 Kotlin Script 内部是有共享内存以及上下文隔离的机制存在的。

同样的,基于对照组的 GraalVM/JS 来看,Kotlin Script 也是明显的用空间换时间,占用较高的内存,来换取优异的性能,GraalVM/JS 虽然对内存的使用较为友好,但是性能方面是惨不忍睹的(跑1000次,出门吃个饭回来还在跑,最后不得不把次数降到10次来计算)。

从数据上看,Kotlin Script 和 GraalVM/JS 各有所长,如果对内存要求严苛,并且不执行复杂的任务,那么 GraalVM/JS 可以很好的胜任。而如果要在执行复杂任务的情况下保证性能,内存条件又比较宽裕的情况下,Kotlin Script 能很好的完成任务。

二、向脚本注入对象

正常情况下,为了使脚本能更好的为我们服务,在执行脚本之前,都需要向脚本的运行环境里注入属于宿主上下文的一些对象,然后在脚本内予以调用。Kotlin Script 是严格遵守 jsr223 的,也就是说可以使用 ScriptEngine 来进行对象的注入,其具体做法如下:

object System {
    fun sayHello(name: String): String = "hello $name"
}

val engine = mgr.getEngineByExtension("kts")
engine.put("system", System)
engine.put("name", "rarnu")
engine.eval("""println(system.sayHello(name))""")    // 将输出 Hello rarnu

另外,Kotlin Script 也支持与 Kotlin 一致的引用方式,如我们需要对文件进行操作,就可以在脚本代码内写下 import 语句:

import java.io.File

val f = File("sample.txt")
println(f.exists())

当然了,由于是 Kotlin Script,所以脚本还可以拥有 Kotlin 的强大能力,比如说我希望像在 Kotlin 里面那样,直接对文件做写入操作,这将引入一个类型扩展,在 Kotlin Script 中同样是可以支持的,也就是说,我们可以把代码写得非常简洁:

import java.io.File
import kotlin.io.writeText

val f = File("sample.txt")
f.writeText("23333")

三、在脚本内定义对象

不同于其他的 jsr223 脚本,在 JVM 的环境下,Kotlin Script 与 Java、Kotlin 都拥有完好的互操作性,用户可以在脚本内定义类型,并且将脚本内的类型拿到宿主中使用,这是非常自由的,具体的做法也很简单:

val CODE = """
    |data class SystemInfo(val osName: String, val osArch: String)
    |val si = SystemInfo(System.getProperty("os.name"), System.getProperty("os.arch"))
    |println("inScript = ${"$"}{si}")
    |si // return it
""".trimMargin()
val (data, _) = Scripting.runScript<Any>(CODE)
println("inHost = $data")
println("osName = ${data?.getValue<String>("osName")}")
println("osArch = ${data?.getValue<String>("osArch")}")

在这段代码里,我们在脚本中定义了 SystemInfo 这个类,并且针对这个类还进行了操作,最终将这个类返回给宿主程序。

在之前的一些交流中,我知道有一些开发同学对于在脚本中定义类型这个事情是抱有怀疑的,例如被说的最多的一个问题是,如果宿主程序中已经有了某个名称的类,再次定义就会出现错误,因此在脚本中定义类是不可取的。我没有仔细的研究过其他的脚本,但是在 Kotlin Script 中,你大可以放心,因为它的实现机制是“隔离”的。

具体来说,就是它拥有一套自己的编译规则,对于脚本内定义的类,Kotlin Script 将把这个类编译为以下形式:

ScriptinHost<UUID>_Line_<Line>$<ClassName>

对于每一个脚本实例来说,它的 UUID 都是唯一的,因此不论执行多少次脚本,都会生成仅属于该脚本的的类,这样在脚本内定义的类型,就不会在任何情况下产生冲突了,也就是可以放心的进行定义。

那么下一个问题就是,既然 Kotlin Script 拥有如此复杂的编译规则,那么脚本内定义的类返回宿主时,宿主要怎么访问它呢,答案也是很简单的,就是用反射:

inline fun <reified T> Any.getValue(field: String): T? =
    this::class.java.getDeclaredField(field).apply { isAccessible = true }.get(this) as? T

通过在任意类型上挂载扩展函数就可以实现了,其实上面的代码已经明确告诉了我们可以这么使用。对于在返回对象中的函数,要调用它也是采用相同的方法,即反射。需要注意的是,由于这里的反射很特殊,它无法被预知,因此我们也无法在使用 GraalVM 编译时,对其采用配置的方式预先配置好,也就是说,如果你的 Kotlin Script 内包含自定义类型,并且该类型的实例还需要返回给宿主使用,那么你将无法用 GraalVM 来对它进行原生编译。

四、Kotlin 语法特性

Kotlin 语法的优势相信我不需要再多说了,在脚本里面可以用相关的语法也会让脚本的编写变得更简单。在 Kotlin Script 中,我们可以正常使用一切的 Kotlin 语法,以及各种神奇的扩展特性,比如说你可以在脚本里使用 Lambda 表达式,使用类型扩展和内联泛型。

举个例子:

val CODE0 = """
    |fun String.count(): Int = this.length
    |println("rarnu".count())
""".trimMargin()
val (_, err) = Scripting.runScript<Any>(CODE0)
println(err)

再举个例子:

val CODE0 = """
    |inline fun <reified T> T.methodCount(): Int {
    |    return this!!::class.java.declaredMethods.size
    |}
    |println("rarnu".methodCount())
""".trimMargin()
val (_, err) = Scripting.runScript<Any>(CODE0)
println(err)

五、在脚本内使用协程

Kotlin 的一大特色就是支持协程,并且相对于 Go,Kotlin 可以对协程进行更精细化的控制,这样的能力同样也被 Kotlin Script 所沿用,也就是说,你可以在编写脚本的时候,也使用协程,并且在使用协程的情况下,对协程进行各种处理。下面来看一个简单的例子:

val CODE0 = """
    |import com.isyscore.kotlin.common.*
    |import kotlinx.coroutines.*
    |
    |@Volatile var done = false
    |
    |suspend fun getResult1(): Int {
    |    delay(300)
    |    println("ret1")
    |    return 1
    |}
    |
    |suspend fun getResult2(): Int {
    |    delay(400)
    |    println("ret2")
    |    return 2
    |}
    |
    |suspend fun getResult3(): Int {
    |    delay(500)
    |    println("ret3")
    |    return 3
    |}
    |
    |val startTime = System.currentTimeMillis()
    |go {
    |    val ret1 = async { getResult1() }
    |    val ret2 = async { getResult2() }
    |    val ret3 = async { getResult3() }
    |    val ret = ret1.await() + ret2.await() + ret3.await()
    |    val endTime = System.currentTimeMillis()
    |    println("ret = ${"$"}ret, time = ${"$"}{endTime - startTime}")
    |    done = true
    |}
    |
    |while (!done) {
    |    // wait
    |}
    """.trimMargin()

val (ret, err) = Scripting.runScript<Any>(CODE0)
println("ret = $ret, err = $err")

在这里,为了方便起见,我特地包装了一个协程函数叫做 go,为的也是可以简单的用一个 go 关键字来启动协程(与 go 语言里的 go 关键字作用相同),该函数的原型如下:

@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
annotation class KtDsl

@KtDsl
fun go(dispatcher: CoroutineDispatcher = Dispatchers.IO, block: suspend CoroutineScope.() -> Unit): Pair<CoroutineScope, Job> {
    val scope = CoroutineScope(dispatcher)
    val job = scope.launch { block() }
    return scope to job
}

同样的,为了编码时方便,例如防止其他人占用 go 关键字等,也特地使用 IDE 注解来让 go 函数变色,这样在 Idea 里面编写代码时,输入 go 之后就会变得和关键字一样的颜色了。

当然了,由于脚本里的代码在协程里运行,这个对测试会造成一定的困难,运行测试用例时,会一闪而过,从而看不到脚本真正的执行情况。当然了这里的脚本代码是特殊的,因为在里面有等待协程执行完毕的代码,我们可以简单的进行测试:

@Test
fun test() {
    val (ret, err) = Scripting.runScript<Any>(CODE0)
    println("ret = $ret, err = $err")
}

但是如果是复杂的异步脚本,要运行用例就不那么方便了,我们在测试的时候也必须用上协程:

@Volatile var done = false

@Test
fun test() {
    go {
        Scripting.runScript<Any>(CODE0)
        async { delay(10000L) }.await()
        done = true
    }
    while (!done) { }
}

这里需要启用另一个协程,并且我们必须预判脚本需要执行的时间,这样在等待时间结束时,测试用例的执行才会被终止,这样就可以为脚本内的异步代码执行留出足够的时间。

六、上下文隔离

对于一个脚本引擎而言,上下文隔离是非常有必要的,特别是并发场景,我们通常会用到一些共享的变量等,如果没有上下文隔离,光是对变量加锁是很难满足实际需要的。Kotlin Script 得益于其严格遵守 jsr223,让我们也可以轻松的完成这项工作。下面就简单的看一下吧:

fun initScript(): ScriptEngine = 
    mgr.getEngineByExtension("kts")

在 initScript 时,从 ScriptEngineManager 中获取一个 ScriptEngine 的实例,这个实例即会拥有自己的上下文。在很多时候,我们都不需要使用 Context,而是使用这种最原始的方法来进行隔离。那么接下去,在这个上下文内,我们就可以连续的做很多事情了:

fun <T> ScriptEngine.runScript(code: String, params: Map<String, Any?>? = null): Pair<T?, Exception?> {
    if (params != null) {
        for ((k, v) in params) {
            put(k, v)
        }
    }
    return try {
        eval(code) as? T to null
    } catch (e: Exception) {
        null to e
    }
}

和上面的基础代码不同,这里的 runScript 是一个对于 ScriptEngine 的扩展,也就是不通过继承,但是往一个类上贴一个新方法的手段。由于扩展在 ScriptEngine 上,它可以获得该 ScriptEngine 实例的上下文,也就是说这里所执行的全部代码,都在该上下文中执行。下面用一些代码来证明:

val CODE0 = """
    |fun sayHello(name: String): String = "Hello ${"$"}name"
    |
""".trimMargin()
val CODE1 = """
    |val s = sayHello("rarnu")
    |s
""".trimMargin()
val eng = Scripting.initScript()
val (_, err1) = eng.runScript<Any>(CODE0)    // 这一步没有输出,但是输入了 sayHello 这个方法
val (ret2, err2) = eng.runScript<Any>(CODE1) // 输出 Hello rarnu

在需要先输入脚本然后再执行,或者在不同执行阶段获取脚本内数据的场景下,这样的代码尤其有用。当然了你也可以通过对 Context 的切换,来使多个脚本共享同一个 ScriptEngine,这样将使得资源开销变得更小(副作用是编码难度变得更大)。

好了,就讲到这里吧,最上面的基础代码已经可以在大部分场景下使用,当我们需要使用脚本来实现一些动态的代码时,Kotlin Script 将会是一个优秀的选项,我也很期待看到它可以在我们的实际项目中发挥威力。