MENU

Catalog

【代码札记】在JVM上优雅的使用Lua脚本

August 4, 2022 • 瞎折腾

本文将记述如何在JVM上使用Lua脚本。

众所周知,JVM上运行的都是字节码,而要得到字节码,就离不开编译。而脚本的目的就在于不进行事先编译的情况下提供一个灵活的控制系统。

谈及脚本语言,JavaScript和Python算是两个比较火的编程语言了,但是我认为他们都太复杂了,不够优雅。我心目中比较理想的脚本语言是Lua,它比较简单,而且能够面向特定领域。如果说Python是为了解决任何可以通过编程解决的问题而设计的,那么Lua可能就是为了解决某个特定领域中的问而设计的。例如大名鼎鼎的Cheat Engine,他就支持用户使用Lua脚本来操作程序的内存,或者将Lua脚本编译成机器指令插入到源程序中,这可比写汇编容易不少。

除了Lua,似乎也可以为某一个特定领域创建一门语言,来自JetBrains的MSP工具就是为此而生。但是为此你需要自定义一套语法规则,相当于为了你的领域而单独创造一门编程语言,即便它非常简单。这对我来说有些因小失大了——引入脚本最初是为了简化开发,同时提供足够的灵活性,而为了达到这个目的要设计一个全新的语言,我想这对开发者、对用户的心智负担都不小。

本文将记述我在JVM平台上使用Lua脚本的过程。

LuaK

LuaK是隶属于KorGE的Lua库,其旨在将LuaJ迁移到Kotlin上。其中KorGE是一个基于Kotlin的游戏引擎。但这个库看起来还在开发中,暂时没有可以使用的文档与制品。我个人比较期待这个库,但是目前还没有完工,只能寻求别的代替品了。

Luaj

经过一番搜索,我找到了一个有年头的库,LuaJ,有年头到什么程度呢?就是最后一次更新的时候(2019年3月,3.0.2发布),社区开发者没能联系上原作者,进而无法为Maven仓库上的制品进行更新。于是使用时只得将库下载到硬盘上进行使用。这让我想起了早年间我学习Java,还不会用maven、gradle这些工具时,就从网络上下载依赖的jar,让IDEA帮我管理依赖。

总之,通过类似implementation(files("lib/luaj-jse-3.0.2.jar"))的语句就可以让gradle使用本地的jar依赖了。这个库在功能上还是比较丰富的:例如执行Lua脚本(使用库的API),提供脚本执行引擎(使用Java Scripting API),Lua解释器(将Lua脚本处理为语法树,使用Java访问语法树),Lua编译器(将Lua脚本编译成LuaVM的字节码),Lua到Java编译器(将LuaVM的字节码翻译成JVM字节码)。而以上所有内容都跟C代码没有任何关系——纯Java实现。

当然了,本文的目标很简单:让Java能够为Lua脚本提供新的能力,并且在Lua脚本中调用Java提供的能力。

经过一番查阅,LuaJ为扩展提供了较为方便的接口,其中比较重要的就是TwoArgFunction。从名字来看,这个抽象类似乎是描述了一个具有两个参数的函数,但LuaJ将其用作加载库的方式。具体来说,当我们在Lua脚本中require 'MyCustomLib'时,LuaJ会搜索classpath中所有实现了LuaFunction、并且具有默认构造函数(即无参数的构造函数)的类,如果找到了name为MyCustomLib,就把这个类当作一个函数来执行,其中第一个参数是模块名(modname,也就是MyCustomLib),第二个参数就是调用者的环境。通过操作环境可以在环境中注册库,之后的代码便可以调用我们的库了。需要注意的是,前文说的name并不是传统意义上的类名,而是Class.javagetName()返回的类名。因此对于info.skyblond.demo.lua.luaj包中的MyCustomLib类,他的name是info.skyblond.demo.lua.luaj.MyCustomLib。如果仅用MyCustomLib,LuaJ是找不到对应类的。

这里我编写了一个简单的例子,展示了LuaJ提供的无参函数、单参函数、双参函数、三参函数和可变参函数:

package info.skyblond.demo.lua.luaj

import org.luaj.vm2.LuaFunction
import org.luaj.vm2.LuaTable
import org.luaj.vm2.LuaValue
import org.luaj.vm2.Varargs
import org.luaj.vm2.lib.*
import java.math.BigDecimal
import java.math.BigInteger
import java.time.Duration

class MyCustomLib : TwoArgFunction() {
    /**
     * Will be called when executing `require 'MyCustomLib'`.
     * Then the "MyCustomLib" will be the moduleName.
     * The env is the environment of that env.
     * And this func needs to return a [LuaTable].
     * The key is func name, the value is [LuaFunction]
     * */
    override fun call(modname: LuaValue, env: LuaValue): LuaTable {
        val library = tableOf()

        library.set("getMilli", GetMilliSecondFunc())
        library.set("addOne", AddOneFunc())
        library.set("longestStr", LongestStringFunc())
        library.set("futureMilli", FutureMilliFunc())
        library.set("hello", TestFunc())

        env.set("MyCustomLib", library)

        return library
    }

    class GetMilliSecondFunc : ZeroArgFunction() {
        override fun call(): LuaValue {
            return LuaValue.valueOf(System.currentTimeMillis().toString())
        }
    }

    class AddOneFunc : OneArgFunction() {
        override fun call(p0: LuaValue): LuaValue {
            // in Lua, int, double, long are all "number"
            return when (p0.typename()) {
                "number" -> {
                    val number = p0.checknumber()
                    number.add(1)
                }

                "string" -> {
                    val str = p0.checkstring().tojstring().lowercase()
                    if (str.contains(".") || str.contains("e")) {
                        // decimal
                        val result = str.toBigDecimal().add(BigDecimal.ONE)
                        val er = result.toEngineeringString()
                        val pr = result.toPlainString()
                        if (er.length < pr.length) {
                            LuaValue.valueOf(er)
                        } else {
                            LuaValue.valueOf(pr)
                        }
                    } else {
                        // integer
                        val result = str.toBigInteger().add(BigInteger.ONE)
                        LuaValue.valueOf(result.toString())
                    }
                }

                else -> error("Invalid arg type: ${p0.typename()}")
            }
        }
    }

    class LongestStringFunc : TwoArgFunction() {
        override fun call(p1: LuaValue, p2: LuaValue): LuaValue {
            val str1 = p1.checkstring().length()
            val str2 = p2.checkstring().length()
            return if (str1 >= str2) p1 else p2
        }
    }

    class FutureMilliFunc : ThreeArgFunction() {
        override fun call(p0: LuaValue, p1: LuaValue, p2: LuaValue): LuaValue {
            val currentMilli = p0.checkstring().tojstring().toLong()
            val number = p2.checkstring().tojstring().toLong()
            val offset = when (p1.checkstring().tojstring().lowercase()) {
                "millis" -> Duration.ofMillis(number)
                "seconds" -> Duration.ofSeconds(number)
                "minutes" -> Duration.ofMinutes(number)
                "hours" -> Duration.ofHours(number)
                "days" -> Duration.ofDays(number)
                else -> null
            }?.toMillis()

            return if (offset != null) {
                LuaValue.valueOf((currentMilli + offset).toString())
            } else {
                error("Invalid duration type: $p1")
            }
        }

    }

    class TestFunc : VarArgFunction() {
        override fun invoke(args: Varargs): Varargs {
            // note: Lua start index from 1
            for (i in 1..args.narg()) {
                val arg = args.arg(i)
                println("Arg#$i: $arg (${arg.typename()})")
            }
            return LuaValue.valueOf("Vararg result!")
        }
    }

}

这些代码都可以在Lua中被调用。测试用的Lua脚本如下:

-- get Java lib loading in the env
require 'info.skyblond.demo.lua.luaj.MyCustomLib'
-- test normal print
print('Hello world!')
-- zero arg func
print('GetMilli', MyCustomLib.getMilli())
-- one arg with multiple arg types
print('AddOne', MyCustomLib.addOne(123))
print('AddOne', MyCustomLib.addOne(128.5))
print('AddOne', MyCustomLib.addOne('999999999999999999'))
print('AddOne', MyCustomLib.addOne('1e-6'))
-- two arg func
print('LongestStr', MyCustomLib.longestStr('aa', 'bbb'))
-- three arg func
print('FutureMilli', MyCustomLib.futureMilli(MyCustomLib.getMilli(), 'days', 1))
-- var arg func
print('VarArg', MyCustomLib.hello('str', 222, true))

为了方便起见,我直接从字符串加载Lua脚本了:

package info.skyblond.demo.lua.luaj

import org.luaj.vm2.lib.jse.JsePlatform

object TestLuajBasic {
    @JvmStatic
    fun main(args: Array<String>) {
        val globals = JsePlatform.standardGlobals()
        globals.load(MyCustomLib())
        println(MyCustomLib::class.java.name)
        val chunk = globals.load("...Lua脚本...")
        chunk.call()
    }
}

这里为了确认类的名字,我打印了MyCustomLib::class.java.name。这一行println并不是执行代码必须的。

执行结果如下:

info.skyblond.demo.lua.luaj.MyCustomLib
Hello world!
GetMilli     1659595842222
AddOne     124
AddOne     129.5
AddOne     1000000000000000000
AddOne     1.000001
LongestStr     bbb
FutureMilli     1659682242233
Arg#1: str (string)
Arg#2: 222 (number)
Arg#3: true (boolean)
VarArg     Vararg result!

嗯,目的达成。不过有些老代码确实让我耿耿于怀,这个库支持的Lua版本是5.2,而最新版已经到了5.4。虽然说功能很强大,但是通过GitHub来看,大部分代码都已经大约七八年没有更新了,最老的代码已经有12年之久。如果是业务代码到还无所谓,我比较担心的是将LuaVM字节码翻译成JVM字节码时,能否利用到最新的字节码(这部分相关的代码是2012年10月更新的,那时候Java7刚问世一年)。不过抛开这部分功能,Lua 5.2还是够用的,并且Java的一个好处就是能够保持代码不变,而仅通过升级JVM版本就能获得性能提升。并且得益于Java API的稳定性,这样的老代码能够完全兼容Kotlin并运行在Java17上。

-全文完-


知识共享许可协议
【代码札记】在JVM上优雅的使用Lua脚本天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code