最近在使用 gopher-lua 来处理 Go 框架内的一些复杂逻辑,就想整理一下这部分的知识。Lua 是一门非常简单,小巧的语言,也正是得益于这个特性,使得它可以和高性能的静态语言结合起来,例如 api 网关场景,openresty 系列,Kong, apisix 都在使用 lua 来处理网关层面的处理逻辑。我这里主要的处理场景是实时缓存更新场景,由于其他的处理流程比较固定,例如订阅 MySQL binlog 消息,将消息发送到下游等,但是缓存更新会附带很多的业务逻辑,每次上线一个业务可能就就需要写一个消费者去订阅消息队列,这些消费者还都要保证消息不丢,不乱序,对于性能有要求的还需要有足够的处理速度,所以这里就可以将一些公共的东西抽象成 Go 框架,框架保证消息处理的一致性和性能,而对消息处理的业务逻辑由 lua 来编写,提高开发效率。

Lua 虚拟机和 Lua API

首先我们来看一下 lua 脚本的执行过程。

首先 lua 文件通过 luac 编译器编译成二进制的 chunk,二进制的 chunk 被加载到 Lua 虚拟机解释执行。lua 里的 chunk 是指可以执行的 lua 代码,可以是一两句语句,也可以包含很多语句的函数,为了获得较高的执行效率,Lua 并不是直接解释执行 chunk,而是先由编译器编译成内部结构(其中包含字节码等信息),然后再由虚拟机执行字节码。这种内部结构在 Lua 里就叫作预编译(Precompiled)chunk,由于采用了二进制格式,所以也叫二进制(Binary)chunk。

再来看一下二进制 chunk 的结构

由 header + prototype 组成,是一个树形结构,里面除字节码之外还有一些其他的信息,例如常量,upvalue (闭包捕获变量)。当然这里我们不过多的陷入细节,感兴趣的可以阅读 《自动动手实现 lua》。

接下来我们来看看 lua 是怎么通过 lua api 于宿主机进行交互的。

lua 和宿主机交互的核心是 Lua State。Lua State 可以理解为一段栈空间,但是这这个栈不仅仅只能 push,pop,还可以根据索引下标访问。Lua API 就定义了一系列访问 Lua State 的接口,使得宿主机可以和 Lua 进行交互。

Lua 虚拟机的执行过程。

Lua 解释器在执行一段 Lua 脚本之前,会先把它包在一个主函数里编译成 Lua 虚拟机指令序列,然后连同其他信息一起,打包成一个二进制 chunk,然后 Lua 虚拟机会接管二进制 chunk,执行里面的指令,指令的执行其实通过 Go(宿主机 VM) 来模拟的的,例如 load 指令将一个立即数加载到一个地址,就是通过 Go 语言调用 Lua API 对 Lua State 里的某个地址(下标)进行更改赋值。

函数调用

当我们调用一个函数时,要先往调用栈里推入一个调用帧,然后把参数传递给调用帧。函数依托调用帧执行指令,可能会调用其他函数,以此类推。当函数执行完毕之后,调用帧里会留下函数需要返回的值。我们把调用帧从调用栈顶弹出,并且把返回值返回给底部的调用帧,这样一次函数调用就结束了。

函数调用有两个重要的 Lua API:Load 和 Call。Load() 方法加载二进制 chunk,把主函数原型实例化为闭包并推入栈顶。Call() 方法对 Lua 函数进行调用。在执行 Call() 方法之前,必须先把被调函数推入栈顶,然后把参数值依次推入栈顶。Call() 方法结束之后,参数值和函数会被弹出栈顶,取而代之的是指定数量的返回值。

那么在 Lua 里如何调用 Go 函数呢 ?

要想让 Lua 函数调用 Go 语言编写函数,就需要一种机制能够给 Go 函数传递参数,并且接收 Go 函数返回的值。可是 Lua 函数只能操作 Lua 栈,这可如何是好?答案就在问题之中。我们已经知道,Lua 栈对于 Lua 函数的调用和执行至关重要。在执行 Lua 函数时,Lua 栈充当虚拟寄存器以供指令操作。在调用 Lua 函数时,Lua 栈充当栈帧以供参数和返回值传递。那么我们自然也可以利用 Lua 栈来给 Go 函数传递参数和接收返回值。我们约定,Go 函数必须满足这样的签名:接收一个 LuaState 接口类型的参数,返回一个整数。在 Go 函数开始执行之前,Lua 栈里是传入的参数值,别无它值。当 Go 函数结束之后,把需要返回的值留在栈顶,然后返回一个整数表示返回值个数。由于 Go 函数返回了返回值数量,这样它在执行完毕时就不用对栈进行清理了,把返回值留在栈顶即可。

gopher-lua 使用

go 调用 lua

1
2
3
4
5
L := lua.NewState()
defer L.Close()
if err := L.DoString(`print("hello")`); err != nil {
    panic(err)
}

lua 调用 go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func Double(L *lua.LState) int {
    lv := L.ToInt(1)             /* get argument */
    L.Push(lua.LNumber(lv * 2)) /* push result */
    return 1                     /* number of results */
}

func main() {
    L := lua.NewState()
    defer L.Close()
    L.SetGlobal("double", L.NewFunction(Double)) /* Original lua_setglobal uses stack... */
}
1
print(double(20)) -- > "40"

lua State Pool

适用于多个协程都需要创建各种的 LState 的场景,例如 http 请求场景,可以通过连接池来复用 LState,减少内存占用。注意 LState 不是线程安全的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
type lStatePool struct {
    m     sync.Mutex
    saved []*lua.LState
}

func (pl *lStatePool) Get() *lua.LState {
    pl.m.Lock()
    defer pl.m.Unlock()
    n := len(pl.saved)
    if n == 0 {
        return pl.New()
    }
    x := pl.saved[n-1]
    pl.saved = pl.saved[0 : n-1]
    return x
}

func (pl *lStatePool) New() *lua.LState {
    L := lua.NewState()
    // setting the L up here.
    // load scripts, set global variables, share channels, etc...
    return L
}

func (pl *lStatePool) Put(L *lua.LState) {
    pl.m.Lock()
    defer pl.m.Unlock()
    pl.saved = append(pl.saved, L)
}

func (pl *lStatePool) Shutdown() {
    for _, L := range pl.saved {
        L.Close()
    }
}

// Global LState pool
var luaPool = &lStatePool{
    saved: make([]*lua.LState, 0, 4),
}

通过提前编译 lua 在不同的 Lstate 里共享字节码

因为每次调用 DoFile 都会加载脚本文件,编译成字节码执行,对于重复执行相同代码的场景来说,提前编译好字节码并在不同的 Lstate 里进行共享能够提升性能,因为字节码是只读的,所以多个 Lstate 共享是安全的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// CompileLua reads the passed lua file from disk and compiles it.
func CompileLua(filePath string) (*lua.FunctionProto, error) {
    file, err := os.Open(filePath)
    defer file.Close()
    if err != nil {
        return nil, err
    }
    reader := bufio.NewReader(file)
    chunk, err := parse.Parse(reader, filePath)
    if err != nil {
        return nil, err
    }
    proto, err := lua.Compile(chunk, filePath)
    if err != nil {
        return nil, err
    }
    return proto, nil
}

// DoCompiledFile takes a FunctionProto, as returned by CompileLua, and runs it in the LState. It is equivalent
// to calling DoFile on the LState with the original source file.
func DoCompiledFile(L *lua.LState, proto *lua.FunctionProto) error {
    lfunc := L.NewFunctionFromProto(proto)
    L.Push(lfunc)
    return L.PCall(0, lua.MultRet, nil)
}

// Example shows how to share the compiled byte code from a lua script between multiple VMs.
func Example() {
    codeToShare := CompileLua("mylua.lua")
    a := lua.NewState()
    b := lua.NewState()
    c := lua.NewState()
    DoCompiledFile(a, codeToShare)
    DoCompiledFile(b, codeToShare)
    DoCompiledFile(c, codeToShare)
}

常用的库

gopher-lua-lib

包含的了一些常用库的 go 实现,例如文件读写,数据库,网络操作等。其实这个库更多的给我们展示了如果在 lua 里调用 Go ,我们使用 lua 只需要知道 lua 的基本语法就够了,例如 变量定义,函数调用,分支跳转等最基本的语法就可以了,其他的东西我们可以通过 lua 调用 go 来实现,比如微服务寻址,消息队列使用等,甚至是如果你不知道 lua 里的 string split 怎么使用,你也可以通过调用 go 的 string split 来实现。

总结

通过 《自动动手实现 lua》理解了 lua 解释执行的过程,更加深入地理解了 gopher-lua 的使用方式。