0%

将Go对象绑定至lua

通过lua调用go对象的成员函数

与c、c++类似,将原生go对象传入lua,并由lua调用其成员函数。有如下步骤:

1. 注册元表

以gin.Context类为例,将Context类的成员函数,以元表的形似,提前注入lua虚拟机

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
// func (c *Context) GetRawData() ([]byte, error)
func lua_Context_GetRawData(L *lua.State) int {
receiver := (*gin.Context)(*(*unsafe.Pointer)(l.ToUserdata(1)))
result0, result1 := receiver.GetRawData()
L.PushBytes(result0)
if result1 == nil {
L.PushNil()
} else {
L.PushString(result1.Error())
}
return 2
}

// 注册元表
func RegistGoMetaTable(L *lua.State) {
created := l.NewMetaTable("Context")
var funcs = map[string]lua.LuaGoFunction{
"GetRawData" = lua_Context_GetRawData,
}
l.PushString("__index")
l.CreateTable(0, len(funcs))
for fname, f := range funcs {
l.PushGoFunction(f)
l.SetField(-2, fname)
}
l.SetTable(-3)
l.Pop(1)
}

2. 将go对象,传入lua虚拟机,并设置元表

1
2
3
4
5
6
7
8
9
10
11
func Hello(l *Lua.State, ptr *gin.Context) {
// 将Context的结构体指针,通过Userdata的形式,传入lua虚拟机
value := reflect.ValueOf(ptr)
ptrVal := unsafe.Pointer(value.Pointer())
rawptr := l.NewUserdata(unsafe.Sizeof(ptrVal))
*(*unsafe.Pointer)(rawptr) = unsafe.Pointer(ptrVal)

// 并对Userdata设置Context元表
l.LGetMetaTable("Context")
l.SetMetaTable(-2)
}

3. 在lua中调用Context对象的方法

1
2
3
4
5
-- 参数c是Userdata类型,并有Context元表
local function Hello(c)
-- 会执行go中的lua_Context_GetRawData方法
local data, err = c:GetRawData()
end

在lua中还原go继承关系的思考

通过元表,已经可以实现调用go对象的成员函数。

现在以gin.Engine类为例

1
2
3
4
type Engine struct {
RouterGroup
// ...
}

如果想调用Engine.RouterGroup.BasePath方法呢?

  • 一、直接编写lua_Engine_BasePath方法,并写入Engine元表
    1
    2
    3
    4
    5
    6
    // go实现
    func lua_Engine_BasePath(L *lua.State) int {
    receiver := (*gin.Engine)(*(*unsafe.Pointer)(l.ToUserdata(1)))
    result0 := receiver.BasePath()
    //...
    }
    1
    2
    -- lua调用
    local path = engine:BasePath()
  • 二、编写lua_RouterGroup_Handle方法,写入RouterGroup元表,然后让lua对象调用到RouterGroup元表中的方法
    1
    2
    3
    4
    5
    6
    // go实现
    func lua_RouterGroup_Handle(L *lua.State) int {
    receiver := (*gin.RouterGroup)(*(*unsafe.Pointer)(l.ToUserdata(1)))
    result0 := receiver.BasePath()
    //...
    }
    1
    2
    3
    -- lua调用
    local routerGroup = engine.RouterGroup
    local path = routerGroup:BasePath()

方式一更加直接简单;方式二则更贴合go的组合继承逻辑,且代码复用性更好。

考虑到绑定代码,基本都是生成工具生成的,方式二在生成代码时,也更加合理

接下来,就是思考如何实现了

1. 尝试通过元表嵌套

已知:如果元表的index操作符是一张表,那么对index表的索引是走常规的流程,可以触发引发另一次元方法

则可以尝试通过元表的嵌套,来模拟go的继承

go实现

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
41
42
43
// func (group *RouterGroup) BasePath() string
func lua_RouterGroup_BasePath(L *lua.State) int {
receiver := (*gin.RouterGroup)(*(*unsafe.Pointer)(l.ToUserdata(1)))
result0 := receiver.BasePath()
L.PushString(result0)
return 1
}

// 注册RouterGroup元表
func RegistGoMetaTable(l *lua.State) {
created := l.NewMetaTable("RouterGroup")
var funcs = map[string]lua.LuaGoFunction{
"BasePath": lua_RouterGroup_BasePath,
}
l.PushString("__index")
l.CreateTable(0, len(funcs))
for fname, f := range funcs {
l.PushGoFunction(f)
l.SetField(-2, fname)
}
l.SetTable(-3)
l.Pop(1)
}

// 注册Engine元表,然后将RouterGroup元表设置为Engine元表的元表
func RegistGoMetaTable(L *lua.State) {
created := l.NewMetaTable("Engine")
var funcs = map[string]lua.LuaGoFunction{
// ...
}
l.PushString("__index")
l.CreateTable(0, len(funcs))
for fname, f := range funcs {
l.PushGoFunction(f)
l.SetField(-2, fname)
}
l.SetTable(-3)
l.Pop(1)

// 将RouterGroup设置为Engine的元表
l.LGetMetaTable("RouterGroup")
l.SetMetaTable(-2)
}

将go结构体指针传入lua虚拟机

1
2
3
4
5
6
7
8
9
func PushGoPointer(l *lua.State, ptr *gin.Engine)  {
value := reflect.ValueOf(ptr)
ptrVal := unsafe.Pointer(value.Pointer())
rawptr := l.NewUserdata(unsafe.Sizeof(ptrVal))
*(*unsafe.Pointer)(rawptr) = ptrVal

l.LGetMetaTable("Engine")
l.SetMetaTable(-2)
}

在lua中调用

1
2
--此时的调用逻辑为: engine userdata => Engine元表 => RouterGroup元表 => lua_RouterGroup_BasePath方法
local path = engine:BasePath()

结论

通过嵌套元表的形式实现的继承,是可以在lua中通过父结构体指针,调用到子结构体的成员方法的。

然而在实际使用过程中,却导致了程序崩溃

问题出在对lua userdata的使用上

  1. 将结构体指针通过userdata传入lua后

    1
    2
    3
    4
    5
    6
    7
    func PushGoPointer(l *lua.State, ptr *gin.Engine)  {
    //...
    // 由于unsafe.Pointer是底层的、非类型安全的指针,Go 编译器不会保留其原始类型的元信息
    // 一旦脱离当前上下文环境,ptrVal就是一个纯指针了
    *(*unsafe.Pointer)(rawptr) = ptrVal
    //...
    }
  2. 通过RouterGroup触发lua_RouterGroup_BasePath方法后,某些情况下会正常工作,某些情况下则会导致程序崩溃

    1
    2
    3
    4
    5
    6
    7
    8
    func lua_RouterGroup_BasePath(L *lua.State) int {
    //......
    // (*(*unsafe.Pointer)(l.ToUserdata(1))),对go来说,取到了一个不包含元信息的纯指针
    // 对一个纯指针进行类型强转(*gin.RouterGroup),go会直接套用RouterGroup类型的底层布局到这个指针上,导致行为未定义
    // 这个指针实际指向的是*gin.Engine
    receiver := (*gin.RouterGroup)(*(*unsafe.Pointer)(l.ToUserdata(1)))
    //......
    }

2. 尝试先获取结构体的成员变量,再由成员变量调用子结构体的函数

go实现

上诉代码不变,在Engine原表中,新增一个获取成员变量的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func lua_Engine_GetMemVar(L *lua.State) int {
receiver := (*gin.Engine)(*(*unsafe.Pointer)(l.ToUserdata(1)))
if receiver == nil {
L.ArgError(1, "ginmodule.GinModule is nil.")
return -1
}
name := L.ToString(2)

// ...

// 通过反射获取成员变量
value := reflect.ValueOf(receiver)
value = value.Elem()
current = value.FieldByName(name)

// ...
// 将成员变量传入lua
PushGoPointer(l, current)
}

在lua中调用

1
2
3
4
--先获取成员变量
local routerGroup = engine:GetMemVar("RouterGroup")
--再通过成员变量调用其BasePath方法
local path = routerGroup:BasePath()

结论

通过获取成员变量的方式,可以正常调用到子结构体的方法

但是无法规避多层嵌套的复杂结构带来的影响,调用子结构方法,需要知晓结构体的完整构成,还需要再每个类型的原表中添加一个GetMemVar方法

总结

  1. 由于lua userdata的实现问题,直接传入lua的go结构体指针,最终会丢失元信息

  2. go是组合继承,lua原表只能实现单继承

  3. go结构体继承有普通类型嵌入,指针嵌入,接口嵌入

由于上诉几点问题,在lua中完整还原go的结构体及其继承关系,并没有比较简单通用的办法。

传入lua的go对象,始终需要手动在go层保留其元信息,lua元表也无法准确还原出go的多种继承方式

需要自行设计一套完整的go、lua映射逻辑才行

使用golua库改造Loolob服务器的MusicFree接口部分

实战使用golua,将原go实现的MusicFree接口改造为lua实现,并添加热更新功能

引入lua

  1. 封装lua对象池

    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
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    // 自定义Lua对象
    type Lua struct {
    *lua.State

    mutex sync.Mutex

    // 是否运行中
    isRunning bool

    // 回调引用
    listCallbackRefs []int

    // go对象表,通过lua直接创建go对象
    mapGoStruct map[string]interface{}

    // 通过go向lua发送数据,go端由Send函数发送,lua端通过Receive接收
    chanToLua chan interface{}

    // 通过lua向go发送数据,lua端由Send函数发送,go端通过Receive接收
    chanToGo chan interface{}

    // 回调函数,将lua数据转换为go数据类型
    callbackLuaToGo func(*Lua) interface{}

    // 回调函数,将go数据转换为lua数据
    callbackGoToLua func(*Lua, interface{}) int
    }

    // lua向go发送数据
    func lua_Send(state *lua.State) int {
    l := ToLua(state.Index)
    if l == nil {
    state.ArgError(1, "error runtime")
    return -1
    }
    if l.callbackLuaToGo != nil {
    v := l.callbackLuaToGo(l)
    l.chanToGo <- v
    } else {
    var data map[string]interface{}
    v := lua_luar.LuaToGo(l.State, 1, &data)
    l.chanToGo <- v
    }
    return 0
    }

    // lua接收来自go的数据
    func lua_Recv(state *lua.State) int {
    l := ToLua(state.Index)
    if l == nil {
    state.ArgError(1, "error runtime")
    return -1
    }
    data, ok := <-l.chanToLua
    if !ok {
    state.PushNil()
    return 1
    }
    if l.callbackGoToLua != nil {
    return l.callbackGoToLua(l, data)
    } else {
    lua_luar.GoToLua(l.State, data)
    return 1
    }
    }

    // 从对象池中取出虚拟机并执行
    func Run(
    file string,
    onInit func(*Lua),
    onRecv func(interface{}),
    onComplete func(error),
    ) *Lua {
    // 取出
    l := pool.Get().(*Lua)
    registerLua(l)

    l.mutex.Lock()
    l.isRunning = true
    l.init()
    l.mutex.Unlock()

    preloads := []func(*Lua){
    func(l *Lua) {
    lua_core.Preload(l.State)
    },
    onInit,
    }

    // 运行lua
    go func(l *Lua) {
    err := l.loadFile(file, preloads)
    l.clear()
    l.mutex.Lock()
    l.isRunning = false
    l.mutex.Unlock()
    if onComplete != nil {
    onComplete(err)
    }
    // 回收
    unregisterLua(l)
    pool.Put(l)
    }(l)

    // 接收来自lua的参数
    go func(l *Lua) {
    if onRecv != nil {
    l.receive(onRecv)
    }
    }(l)

    return l
    }
  2. 接入Loolob服务器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 初始化MusicFree接口部分时启动
    func (slf *MusicFreePlugin) startLua() {
    slf.mu.Lock()
    slf.lua = lblua.Run(
    "./lua/musicfreeplugin/main.lua",
    slf.onLuaInit,
    slf.onLuaRecv,
    slf.onLuaComplete,
    )
    slf.mu.Unlock()
    slf.lua.RegisterGoToLua(slf.onLuaTrans)
    fmt.Println("启动lua")
    }

改造原MusicFree接口

  1. 封装函数,将request请求丢给lua执行

原gin框架每个请求都是在独立的线程中执行,这里转换成lua有两种处理方式:一是在每个请求的handler中都启用一个lua虚拟机,来响应请求。二是将请求都交由同一个虚拟机处理

这里采用第二种方式,通过channel将请求都合并到lua线程中,交由同一个lua虚拟机执行。原来并行的请求响应,将变为串行执行,简化了逻辑

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
// 将请求转发至lua虚拟机,并阻塞等待
func (slf *MusicFreePlugin) requestToLua(route string, c *gin.Context) {
if c != nil {
done := make(chan any)
cb := func() {
close(done)
}
slf.mu.RLock()
slf.lua.Send(luaEvt{Route: route, Context: c, Complete: cb})
slf.mu.RUnlock()
// 设置5秒超时
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
select {
case <-done: // 耗时操作
c.Done()
case <-ctx.Done():
c.AbortWithStatus(http.StatusRequestTimeout)
}
} else {
slf.mu.RLock()
slf.lua.Send(luaEvt{Route: route, Context: nil, Complete: nil})
slf.mu.RUnlock()
}
}
  1. 修改原接口,将请求都转发至lua
    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
    func (slf *MusicFreePlugin) Init() {
    // ...
    group.POST("search", slf.search)
    group.GET("get", slf.get)
    group.GET("getrecommendsheettags", slf.getrecommendsheettags)
    group.POST("getrecommendsheetsbytag", slf.getrecommendsheetsbytag)
    group.POST("getmusicsheetinfo", slf.getmusicsheetinfo)
    // ...
    }

    func (slf *MusicFreePlugin) search(c *gin.Context) {
    slf.requestToLua("search", c)
    }

    func (slf *MusicFreePlugin) get(c *gin.Context) {
    slf.requestToLua("get", c)
    }

    func (slf *MusicFreePlugin) getrecommendsheettags(c *gin.Context) {
    slf.requestToLua("getrecommendsheettags", c)
    }

    func (slf *MusicFreePlugin) getrecommendsheetsbytag(c *gin.Context) {
    slf.requestToLua("getrecommendsheetsbytag", c)
    }

    func (slf *MusicFreePlugin) getmusicsheetinfo(c *gin.Context) {
    slf.requestToLua("getmusicsheetinfo", c)
    }

在lua中响应请求

  1. 将Context对象注册进lua

    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
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    var METAKEY_Context = "Context"
    var METAAPI_Context = map[string]lua.LuaGoFunction{
    // ...
    "Data": lua_Context_Data,
    "Query": lua_Context_Query,
    "JSON": lua_Context_JSON,
    // ...
    }

    // func (c *Context) Data(code int, contentType string, data []byte)
    func lua_Context_Data(state *lua.State) int {
    L := lblua.ToLua(state.Index)
    obj := (*gin.Context)(L.ToGoPointer(1))
    if obj == nil {
    L.ArgError(1, "error type.")
    return -1
    }
    param2 := int(L.ToInteger(2))
    param3 := L.ToString(3)
    param4 := []byte(L.ToBytes(4))

    obj.Data(param2, param3, param4)

    return 0
    }

    // func (c *Context) Query(key string) (value string)
    func lua_Context_Query(state *lua.State) int {
    L := lblua.ToLua(state.Index)
    obj := (*gin.Context)(L.ToGoPointer(1))
    if obj == nil {
    L.ArgError(1, "error type.")
    return -1
    }
    param2 := L.ToString(2)

    result1 := obj.Query(param2)

    L.PushString(result1)
    return 1
    }

    // func (c *Context) JSON(code int, obj any)
    func lua_Context_JSON(state *lua.State) int {
    L := lblua.ToLua(state.Index)
    obj := (*gin.Context)(L.ToGoPointer(1))
    if obj == nil {
    L.ArgError(1, "error type.")
    return -1
    }
    param2 := int(L.ToInteger(2))
    param3 := L.ToGoPointer(3)

    obj.JSON(param2, param3)

    return 0
    }
  2. 在lua中获取请求

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
41
42
43
44
-- 封装Context.Data
local DoneJson = function(evt, code, tb)
if evt == nil then
return
end
if evt.Context ~= nil then
evt.Context:Data(code, "application/json", json.encode(tb))
end
if evt.Complete ~= nil then
evt.Complete()
end
end

-- 封装Context.File
local DoneFile = function(evt, fp, size)
if evt == nil then
return
end
if evt.Context ~= nil then
evt.Context:FileStream(fp, size)
end
if evt.Complete ~= nil then
evt.Complete()
end
end

--从channel接收请求,并执行对应的逻辑
local function main()
-- ......
while true do
-- ......
local evt = core.Recv()
if evt == nil then
break
end
local Route = evt.Route
if Plugin[Route] ~= nil then
unsafe_xpcall(Plugin[Route], function(err)
debug.PrintWarn("处理事件[%s]失败: %s", Route, err)
DoneJson(evt, 500, { error = err })
end, Plugin, evt)
end
end
end
  1. 在lua中处理请求,以get接口为例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    function Plugin:get(evt)
    local c = evt.Context
    local md5 = c:Query("id") -- 获取歌曲id
    if #md5 <= 0 then
    DoneJson(evt, 500, { error = "error id" })
    return
    end
    local item = nil
    -- 从缓存数据中查找对应的歌曲
    for _, v in pairs(self.MusicList) do
    if v.MD5 == md5 then
    item = v
    break
    end
    end
    if item == nil then
    DoneJson(evt, 500, { error = "error id" })
    return
    end
    -- 将歌曲返回
    DoneFile(evt, item.FPath, self.ChunkSize)
    end

添加热更新功能

由于golua库,不支持预编译字节码,每次调用LoadFile函数,都是从本地加载lua文件执行。

所以实现热更新,只需要更新本地文件后,重新调用LoadFile函数即可

1. 新增reload接口

1
2
3
4
5
6
7
8
9
10
func (slf *MusicFreePlugin) Init() {
// ...
group.GET("reload", slf.reload)
// ...
}

func (slf *MusicFreePlugin) reload(c *gin.Context) {
slf.Reload()
c.JSON(200, gin.H{"success": "true"})
}

2. 通过nil变量控制lua虚拟机的退出

收到reload请求,向当前lua虚拟机发送一个nil,通知虚拟机退出

1
2
3
4
5
6
// 向lua虚拟机发送nil
func (slf *MusicFreePlugin) Reload() {
slf.mu.Lock()
slf.lua.Send(nil)
slf.mu.Unlock()
}

收到nil后跳出事件循环,结束执行

1
2
3
4
5
6
7
8
9
10
11
local function main()
-- ...
while true do
-- ...
local evt = core.Recv()
if evt == nil then
break
end
-- ...
end
end

3. 在当前虚拟机退出后,重启虚拟机重新加载lua文件

虚拟机退出后,直接重启。此时若本地lua文件已经更新过了,则新启动的虚拟机将执行更新后的逻辑

1
2
3
4
5
6
func (slf *MusicFreePlugin) onLuaComplete(err error) {
if err != nil {
fmt.Printf("lua 异常退出: %v\n", err)
}
slf.startLua()
}

golua

go和c混合编译的库,通过cgo调用c接口

简介

golua 是一个 Go 语言的库,用于在 Go 程序中嵌入 Lua 脚本引擎。它基于 Lua 的 C API,并通过 cgo 将其与 Go 进行绑定,允许开发者在 Go 应用中调用 Lua 脚本,或者让 Lua 脚本调用 Go 函数。

优缺点(来自deepseek的评价)

本来先让通义评价的,但纯TM乱说,还是deepseek靠谱点

优点

  1. 轻量级:Lua 本身是一个轻量级脚本语言,适合嵌入到 Go 应用中。
  2. 高性能:Lua 的执行速度较快,适合作为扩展脚本语言。
  3. 灵活性:允许动态加载和执行脚本,适合插件系统或配置逻辑。
  4. 社区支持:Lua 有丰富的库和文档,便于开发。

缺点

  1. 依赖 CGO:由于是基于 Lua 的 C API,需要 CGO 支持,可能增加构建复杂性。
  2. 内存管理:Lua 和 Go 的垃圾回收机制不同,可能导致内存管理问题。
  3. 性能开销:跨语言调用(Go ↔ Lua)会有一定的性能损失。

实际使用中的一些优缺点

优点

  1. 适合熟悉clua的人玩,不用再去看一遍lua底层代码了
  2. 直接链接的c库,lua51、52、53、54、jit都能玩
  3. 还原lua原始的堆栈操作

缺点

  1. 直接调用c其实很危险,特别是搞扩展c和go交互时,一不小心就崩
  2. 没法搞预编译,针对多虚拟机的优化应该只有使用对象池了

使用技巧

  1. 执行lua文件
    1
    2
    state := slf.LoadFile(file)
    err := slf.Call(slf.GetTop()-1, lua.LUA_MULTRET)
  2. 将go对象注册进lua,并通过lua元表实现go的继承关系
    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
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    // 向lua虚拟机注册metatable,并指定父类
    func (slf *Lua) RegistMetaTableWithParent(
    name string,
    funcs map[string]lua.LuaGoFunction,
    parent string,
    ) bool {
    created := slf.NewMetaTable(MetaKey + name) // 入 1
    if !created { // 校验
    slf.Pop(1) // 出 0
    return false
    }
    slf.PushString("__index") // 入 2
    slf.CreateTable(0, len(funcs)) // 入 3
    for fname, f := range funcs {
    slf.PushGoFunction(f) // 入 4
    slf.SetField(-2, fname) // 出 3
    }
    slf.LGetMetaTable(MetaKey + parent) // 入 4
    found := slf.IsTable(-1) // 校验
    if !found {
    slf.Pop(1) // 出 3
    } else {
    slf.SetMetaTable(-2) // 出 3
    }
    slf.SetTable(-3) // 出 1
    slf.Pop(1) // 出 0
    return true
    }

    // 将go对象指针以userdata的形式入栈,并设置其metatable
    func (slf *Lua) PushGoPointerWithMetaTable(ptr interface{}, metatable string) bool {
    value := reflect.ValueOf(ptr)
    if value.Kind() != reflect.Ptr {
    return false
    }
    ptrVal := unsafe.Pointer(value.Pointer())
    rawptr := slf.NewUserdata(unsafe.Sizeof(ptrVal)) // 入 1
    *(*unsafe.Pointer)(rawptr) = unsafe.Pointer(ptrVal)

    // 查找元表
    slf.LGetMetaTable(MetaKey + metatable) // 入 2
    found := slf.IsTable(-1) // 校验
    if !found {
    slf.Pop(1) // 出 1
    } else {
    slf.SetMetaTable(-2) // 出 1
    }
    return true
    }

    // 将go对象指针出栈
    func (slf *Lua) ToGoPointer(pos int) unsafe.Pointer {
    return *(*unsafe.Pointer)(slf.ToUserdata(pos))
    }

gopher-lua

一个用Go实现的Lua虚拟机,直接用原生Go代码运行Lua脚本

简介

Gopher-Lua 是一个用 Go 语言实现的 Lua 解释器,它允许开发者在 Go 程序中嵌入 Lua 脚本语言

优缺点(来自通义的评价)

优点

  1. 轻量级且高效

    • Lua 本身是一个非常轻量级的脚本语言,适合嵌入到其他应用程序中。
    • Gopher-Lua 继承了 Lua 的高效特性,同时利用 Go 的并发模型,能够很好地处理高并发任务。
  2. 与 Go 的无缝集成

    • Gopher-Lua 提供了丰富的 API,使 Lua 脚本可以方便地调用 Go 函数,反之亦然。
    • 开发者可以通过 Go 的类型系统将复杂的数据结构传递给 Lua 脚本,灵活性非常高。
  3. 易于扩展

    • 开发者可以通过 Go 编写自定义的 Lua 模块或函数,并将其注册到 Lua 环境中。
    • 这种扩展性使得 Gopher-Lua 非常适合需要动态配置或脚本化的应用场景。
  4. 跨平台支持

    • Go 和 Lua 都是跨平台的语言,因此 Gopher-Lua 可以轻松运行在多种操作系统上(如 Linux、Windows、macOS)。
  5. 社区活跃

    • 尽管 Gopher-Lua 不如标准 Lua 或其他 Lua 实现(如 LuaJIT)流行,但它的社区仍然相对活跃,文档和示例代码较多,便于学习和使用。
  6. 适合嵌入式场景

    • Lua 的设计初衷就是作为嵌入式脚本语言,Gopher-Lua 在这方面表现尤为突出,非常适合游戏开发、网络服务、自动化工具等需要脚本支持的场景。

缺点

  1. 性能不如原生 Lua

    • 虽然 Gopher-Lua 性能不错,但它毕竟是用 Go 实现的 Lua 解释器,性能上可能无法达到原生 Lua 或 LuaJIT 的水平。
    • 对于计算密集型任务,Gopher-Lua 可能会成为瓶颈。
  2. 缺乏 JIT 支持

    • Gopher-Lua 是一个纯解释器,没有像 LuaJIT 那样的即时编译(JIT)功能,这意味着对于需要高性能的场景,它可能不是最佳选择。
  3. API 学习曲线

    • 对于不熟悉 Lua 或 Go 的开发者来说,学习如何在 Go 中嵌入 Lua 并编写交互代码可能会有一定的门槛。
    • 需要理解 Lua 的栈机制以及 Go 的类型转换规则。
  4. 生态有限

    • 与 Lua 官方实现相比,Gopher-Lua 的生态系统较小,第三方库的数量和质量都有限。
    • 如果需要某些高级功能(例如特定的 Lua 模块),可能需要自行实现或寻找替代方案。
  5. 调试困难

    • Lua 脚本的调试本身就是一个挑战,而 Gopher-Lua 的调试工具和插件支持较少,可能会增加开发和维护的难度。
  6. 内存管理

    • Lua 的垃圾回收机制与 Go 的垃圾回收机制不同,可能会导致一些内存管理上的复杂性。
    • 在大规模应用中,这种差异可能会引发性能问题或内存泄漏。
  7. 不适合大型项目

    • Lua 本身是一种轻量级语言,虽然灵活,但在复杂性和模块化方面不如其他现代语言(如 Python 或 JavaScript)。
    • 如果项目规模较大,可能需要考虑更强大的脚本语言或框架。

适用场景

  • 游戏开发:Lua 是游戏开发中常用的脚本语言,Gopher-Lua 可以用于实现游戏逻辑或配置文件。
  • 网络服务:Go 的高性能网络能力与 Lua 的脚本灵活性相结合,适合构建动态配置的服务。
  • 自动化工具:Lua 的简单语法和 Gopher-Lua 的易用性使其成为编写自动化脚本的理想选择。

总结

Gopher-Lua 是一个强大且灵活的工具,特别适合需要在 Go 程序中嵌入脚本语言的场景。然而,它也有一些局限性,特别是在性能要求极高或需要复杂功能的情况下。如果你的应用场景符合其优势范围,那么 Gopher-Lua 将是一个非常好的选择;否则,可能需要考虑其他解决方案,例如直接使用原生 Lua 或 LuaJIT。

如果需要进一步了解,可以查看 Gopher-Lua 的官方文档和示例代码。

实际使用中的一些优缺点

优点

  1. 可以在lua端直接使用channel

  2. 与go线程有较好的相容性,且数据在线程间流转时要处理的问题更少,毕竟都是基于go的原生数据类型。由于go是直接运行的Lua字节码,可以方便的将go线程嫁接到lua上去。例如直接将lua文件中的某个函数放到新线程执行:lua调用 -> go获取lua函数/代码段的字节码对象 -> go在新线程中启动lua虚拟机,直接运行这个字节码对象。

  3. 单个虚拟机由go维护时,所需内存更少

  4. lua代码可以提前编译成字节码对象存储在内存中,方便复用,减少lua虚拟机的初始化时间

  5. go、lua数据交互方便,在go->lua,lua-go等场景。数据交互方便,减少了拷贝次数

  6. 还原了lua的堆栈操作,但又方便了许多,不再那么笨比了

缺点

  1. 只支持lua5.1

  2. 并未完全还原lua5.1的底层逻辑。例如table,原生是c的双指针,go版是map+array。部分情况下和原生运行效果有差异,例如内存占用等

  3. go版本用了go的yacc工具,整了ast抽象语法树来解析lua代码,然后生成的lua字节码来跑lua逻辑,并重新实现了lua底层。和clua相差较大,需要时间熟悉

使用技巧

  1. 直接执行lua文件

    1
    2
    l := lua.NewState()
    l.DoFile(filePath)
  2. 将go对象封装为元表,并通过元表__index字段实现继承关系

    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
    // 1. 将go对象的成员方法注册进lua元表中
    func RegMeta(L *lua.LState, key string, api map[string]lua.LGFunction, super string) *lua.LTable {
    meta := L.NewTypeMetatable(key) // 新建元表
    tb := L.SetFuncs(L.NewTable(), api) // 设置元表的方法
    if len(super) > 0 {
    L.SetMetatable(tb, L.GetTypeMetatable(super))
    }
    L.SetField(meta, "__index", tb) // 设置元方法
    return tb
    }
    // 2. 将go对象实例注册进lua虚拟机
    func UseMeta(L *lua.LState, key string, obj interface{}) *lua.LUserData {
    ud := L.NewUserData()
    ud.Value = obj
    L.SetMetatable(ud, L.GetTypeMetatable(key))
    return ud
    }
    // 3. 实现一个供lua调用的go方法。例如gin库中的Context类的String方法
    func lua_Context_String(L *lua.LState) int {
    ud := L.CheckUserData(1)
    if v, ok := ud.Value.(Context); ok {
    code := L.ToInt(2)
    str := L.ToString(3)
    v.String(code, str)
    } else {
    // error
    L.ArgError(1, "lua_Context_String expected")
    }
    return 0
    }
  3. 在lua中调用go对象实例的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    -- context对象可通过ch,从go发送至lua端
    channel.select(
    { "|<-", chIn, function(ok, context)
    if ok then
    -- ...处理请求...
    -- 调用go对象context的String方法
    context:String("success")
    end
    end },
    )
  4. 预编译lua脚本

    1
    2
    3
    4
    5
    6
    // 预编译lua文件
    proto, err := compileFile(filePath)
    // 使用预编译的lua脚本
    L.Push(slf.L.NewFunctionFromProto(proto))
    // 调用预编译的脚本
    L.PCall(0, lua.MultRet, nil)
  5. 利用其将lua代码编译为字节码特性,将lua扩展为多线程

    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
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    // 注册一个名为thread的talbe对象,含有一个run方法
    func initThread(l *lua.LState) {
    tb := l.NewTable()
    tb.RawSetH(lua.LString("run"), l.NewFunction(lua_run))
    l.SetGlobal("thread", tb)
    }

    // 通过thread.run方法,将lua代码段,放入go线程中执行
    func lua_run(L *lua.LState) int {

    //...

    // 参数封装为table从lua传入go中
    param := L.ToTable(1)

    // 要执行的lua代码段,实际是一个字节码对象
    fn := param.RawGetString("func").(*lua.LFunction)
    // 传递给代码段执行的参数
    args := param.RawGetString("args").(*lua.LTable)
    // 启用的线程数量
    count := int(math.Min(float64(args.Len()), float64(param.RawGetString("count").(lua.LNumber))))

    // 用于收集每个线程执行的结果
    // 这里采用table+lock的方式,也可以使用channel收集
    result := rt.l.NewTable()

    // 通过channel传递执行的参数
    ch := make(chan task, count)

    var mutex sync.Mutex
    wg := &sync.WaitGroup{}
    wg.Add(count)

    // 启用count个线程
    for i := 0; i < count; i++ {
    // ...
    // 获取一个新的lua虚拟机
    rt := rt.GetPool().GetRuntime()
    // 启动线程并执行lua代码段
    go do_task(i, rt, fn, ch, result, wg, &mutex)
    }

    // 通过channel发送参数
    go func() {
    for i := 0; i < args.Len(); i++ {
    ch <- task{Idx: i + 1, Param: args.RawGetInt(i + 1)}
    }
    close(ch)
    }()

    // 等待执行完
    wg.Wait()

    // 执行完成
    L.Push(result)

    return 1
    }

    func do_task(
    idx int,
    rt *Runtime,
    fn *lua.LFunction,
    ch chan task,
    out *lua.LTable,
    wg *sync.WaitGroup,
    mutex *sync.Mutex,
    ) {
    // 通过字节码对象,生成新虚拟机的lua function
    proto := rt.l.NewFunctionFromProto(fn.Proto)
    for {
    // 获取参数
    task, ok := <-ch
    if !ok {
    break
    }
    var err error
    rt.l.Push(proto)
    rt.l.Push(lua.LNumber(idx))
    // 执行lua代码段
    if task.Param != nil {
    rt.l.Push(task.Param)
    err = rt.l.PCall(2, 1, nil)
    } else {
    err = rt.l.PCall(1, 1, nil)
    }
    var result lua.LValue
    if err != nil {
    result = lua.LString(err.Error())
    } else {
    if rt.l.GetTop() == 1 {
    result = rt.l.Get(1)
    rt.l.Pop(1) // 使用完毕的参数,出栈
    } else {
    result = lua.LNil
    }
    }
    // 写入执行结果
    mutex.Lock()
    out.Append(result)
    mutex.Unlock()
    }
    if wg != nil {
    wg.Done()
    }
    // ...
    }
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
41
42
43
44
45
46
47
48
49
50
51
52
--由其他线程执行的函数
local function sub(idx, param)
local i = param
while i > 0 do
i = i - 1
end
return { idx, param }
end

local result = thread.run({
count = 4, --启用4个线程
func = sub,
args = { 9000000, 30000000, 70000000, 1000000, 5000, 320, 100000, 88880888 },
})
--[[
输出结果:
- "<var>" = {
- 1 = {
- 1 = 3
- 2 = 1000000
- }
- 2 = {
- 1 = 3
- 2 = 5000
- }
- 3 = {
- 1 = 3
- 2 = 320
- }
- 4 = {
- 1 = 3
- 2 = 100000
- }
- 5 = {
- 1 = 0
- 2 = 9000000
- }
- 6 = {
- 1 = 1
- 2 = 30000000
- }
- 7 = {
- 1 = 2
- 2 = 70000000
- }
- 8 = {
- 1 = 3
- 2 = 88880888
- }
- }
启用了4个线程,共处理了8个参数
]]

扩展歌单功能

查看MusicFree的源码,和其中的plugin.d.ts文件,提取几个关节接口

接口分析

getRecommendSheetTags

1
2
3
4
5
6
7
8
/** 获取热门歌单tag */
getRecommendSheetTags?: () => Promise<IGetRecommendSheetTagsResult>;

interface IGetRecommendSheetTagsResult {
// 固定的tag
pinned?: IMusic.IMusicSheetItemBase[];
data?: IMusic.IMusicSheetGroupItem[];
}

调用逻辑:
这个接口在app打开热门歌单时启用

返回值说明:

  • pinned字段返回固定的标签。这些标签名将直接显示在热门歌单这个界面上。
  • data字段返回分组的普通标签

getRecommendSheetsByTag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/** 歌单列表 */
getRecommendSheetsByTag?: (
tag: ICommon.IUnique,
page?: number,
) => Promise<ICommon.PaginationResponse<IMusic.IMusicSheetItemBase>>;
interface IMusicSheetItemBase {
/** 封面图 */
coverImg?: string;
artwork?: string;
/** 标题 */
title?: string;
/** 作者 */
artist?: string;
/** 歌单id */
id: string;
/** 描述 */
description?: string;
/** 作品总数 */
worksNum?: number;
platform?: string;
[k: string]: any;
}

调用逻辑:

  1. 这个接口在点击热门歌单中的标签时调用,返回包含标签的歌单
  2. 这个接口在点击热门歌单界面后,立即被调用一次。用于获取默认标签的歌单

返回值说明:

  • title 歌单名
  • artist 歌单作者
  • 歌单id

getMusicSheetInfo

1
2
3
4
5
6
7
8
9
/** 获取歌单信息,有分页 */
getMusicSheetInfo?: (
sheetItem: IMusic.IMusicSheetItem,
page: number,
) => Promise<ISheetInfoResult | null>;
/** 歌单项 */
export interface IMusicSheetItem extends IMusicSheetItemBase {
musicList: Array<IMusic.IMusicItem>;
}

调用逻辑:
点击歌单封面时调用,用于获取歌单内的歌曲

返回值说明:

  • musicList 歌单内的歌曲列表,将由getMediaSource接口来获取歌曲内容

插件实现

  1. getRecommendSheetTags,插件直接发起请求,然后返回数据即可。逻辑可交由服务器实现

    1
    await axios.get(`${URL}getrecommendsheettags`)
  2. getRecommendSheetsByTag,同上直接发起请求。逻辑交由服务器实现

    1
    2
    3
    4
    5
    const body = {
    tag,
    page,
    }
    const result = await axios.post(`${URL}getrecommendsheetsbytag`, body)
  3. getMusicSheetInfo,同上直接发起请求。逻辑交由服务器实现

    1
    2
    3
    4
    5
    const body = {
    sheetItem,
    page,
    }
    const result = await axios.post(`${URL}getmusicsheetinfo`, body)

服务器实现

1. getRecommendSheetTags

简单实现,直接返回三个固定即可

1
2
3
4
5
6
7
return {
pinned = {
{ title = "歌手", artist = "歌手", id = "歌手" },
{ title = "翻唱", artist = "翻唱", id = "翻唱" },
{ title = "我喜欢", artist = "我喜欢", id = "我喜欢" }
},
}

2. getRecommendSheetTags

根据标签获取歌单

提前将本地做分类处理

  1. 全部歌曲加入我喜欢歌单。收到我喜欢标签时,返回我喜欢歌单
  2. 按将歌曲按歌手名分组,加入对应歌手歌单。收到歌手标签时,返回所有歌手的歌单
  3. 将同名歌曲按名字分组,加入对应歌名的歌单。收到翻唱标签时,返回所有翻唱歌曲的歌单
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
41
42
43
-- 按歌手分组
function Sheet:InitArtist(MusicList)
self.Artist = {}
for _, item in pairs(MusicList) do
if self.Artist[item.Artist] == nil then
self.Artist[item.Artist] = {}
end
local list = self.Artist[item.Artist]
list[#list + 1] = item
end
local temp = {}
for k, v in pairs(self.Artist) do
if #v <= 1 then
temp[#temp + 1] = v[1]
self.Artist[k] = nil
end
end
for k, v in pairs(self.Artist) do
for i = #temp, 1, -1 do
local item = temp[i]
if (string.includes(item.Artist, k) or string.includes(k, item.Artist)) then
v[#v + 1] = item
table.remove(temp, i)
end
end
end
end
-- 按歌曲名分组
function Sheet:InitTitle(MusicList)
self.Title = {}
for _, item in pairs(MusicList) do
if self.Title[item.Title] == nil then
self.Title[item.Title] = {}
end
local list = self.Title[item.Title]
list[#list + 1] = item
end
for k, v in pairs(self.Title) do
if #v <= 1 then
self.Title[k] = nil
end
end
end

然后根据请求的标签,返回对应的歌单

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
function Sheet:getRecommendSheetsByTag(tag, page, PageCount)
local list = nil
if tag.id == "我喜欢" or tag.title == "默认" then
list = { {
title = "我喜欢",
artist = "我喜欢",
id = "我喜欢",
} }
elseif tag.title == "歌手" then
list = array.map(self.Artist, function(v, k)
return {
title = k,
artist = k,
id = k,
}
end)
elseif tag.title == "翻唱" then
list = array.map(self.Title, function(v, k)
return {
title = k,
artist = k,
id = k,
}
end)
end
if list == nil then
return { isEnd = true, data = {} }
end

local start_idx = 1 + math.clamp((page - 1) * PageCount, 0, #list)
local end_idx = 1 + math.clamp(page * PageCount, 0, #list)

return { isEnd = page * PageCount >= #list, data = array.slice(list, start_idx, end_idx) }
end

3. getMusicSheetInfo

根据歌单id,返回歌单内容

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
function Sheet:getMusicSheetInfo(sheetItem, page, PageCount)
local list = nil
if sheetItem.id == "我喜欢" then
list = self.MusicList
elseif self.Artist[sheetItem.id] ~= nil then
list = self.Artist[sheetItem.id]
elseif self.Title[sheetItem.id] ~= nil then
list = self.Title[sheetItem.id]
end
if list == nil then
return { isEnd = true, musicList = {} }
end

local start_idx = 1 + math.clamp((page - 1) * PageCount, 0, #list)
local end_idx = 1 + math.clamp(page * PageCount, 0, #list)

return {
isEnd = page * PageCount >= #list,
musicList = array.map(array.slice(list, start_idx, end_idx), function(v, k)
return {
title = v.Title,
artist = v.Artist,
duration = v.Duration,
id = v.MD5,
size = v.Size,
}
end)
}
end

开始使用

  1. 编译插件并拷贝进手机,然后通过APP安装

    1
    npm run build
  2. 然后点击热门歌单,选中并播放,大功告成

MusicFree简介

一个跨平台的开源音乐软件,基于React Native框架
通过其灵活的插件能力,以支持各种定制化玩法

通过MusicFree插件+云服务器的方式,即可打造个人音乐库

开始搭建

1. 分析插件接口

先参考插件示例

根据其plugin.d.ts文件,提取出两个个关键接口

  1. search 搜索音乐

    1
    2
    3
    4
    5
    6
    7
    8
    type ISearchFunc = <T extends ICommon.SupportMediaType>(
    query: string,
    page: number,
    type: T,
    ) => Promise<ISearchResult<T>>;
    interface IPluginDefine {
    search?: ISearchFunc;
    }
  2. getMediaSource 获取音乐

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    interface IMediaSourceResult {
    headers?: Record<string, string>;
    /** 兜底播放 */
    url?: string;
    /** UA */
    userAgent?: string;
    /** 音质 */
    quality?: IMusic.IQualityKey;
    }
    interface IPluginDefine {
    getMediaSource?: (
    musicItem: IMusic.IMusicItemBase,
    quality: IMusic.IQualityKey,
    ) => Promise<IMediaSourceResult | null>;
    }

search从服务器搜索音乐资源

getMediaSource从服务器获取音乐资源进行播放或下载

实现上述两个接口即可在APP内通过 搜索=>下载=>播放 的方式,来播放存在云服务上的音乐了

2. 准备资源

  1. 通过QQ音乐PC客户端下载资源(app下载的资源无法解码)

  2. 通过转换工具,将vip资源解码

  3. 通过ftp直接传到loolob的云服务上

3. 准备服务器

MusicFree的插件通过http协议进行交互,所以直接使用loolob的服务器,扩展几个接口即可

  1. 搜索接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // search接口支持分页,可以在服务端自定义搜索结果的分页逻辑
    func (slf *MusicFreePlugin) searchmusic(c *gin.Context) {
    // ...
    var body struct {
    Page int
    Query string
    }
    err := c.ShouldBindBodyWith(&body, binding.JSON)
    // ...
    c.JSON(200, gin.H{"list": list[start:end], "isEnd": page*PageCount >= len(list)})
    // ...
    }
  2. 获取资源接口

    1
    2
    3
    4
    5
    6
    // 返回音乐资源
    func (slf *MusicFreePlugin) getmusic(c *gin.Context) {
    // ...
    c.File(fp)
    // ...
    }

注意点

    1. 音乐资源通过唯一id识别,直接使用md5值即可
    1. 服务器与APP交互的核心值就是idurl,一个用于标识资源,一个用于下载/播放
    1. 最简策略,直接静态路由本地文件,search接口返回本地音乐的url即可

4. 准备插件

直接在示例工程上进行开发

将plugin.d.ts文件替换为MusicFree中最新的即可

  1. 搜索接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 通过http请求搜索音乐资源
    async function searchMusic(query, page) {
    // ...
    const result = await axios.post(`${URL}searchmusic`, body)
    // ...
    return {
    isEnd,
    data: list.map(v => {
    return {
    id: v.MD5,
    artist: v.Author,
    title: v.Name,
    }
    }),
    }
    }
  2. 获取资源接口

    1
    2
    3
    4
    5
    6
    7
    // 本接口只负责返回资源url,资源的下载由app接管
    async function getMediaSource(musicItem, quality) {
    return {
    url: `${URL}getmusic?id=${musicItem.id}`,
    quality: quality,
    }
    }

开始使用自己的音乐服务

  1. 编译插件并拷贝进手机,然后通过APP安装

    1
    npm run build
  2. 搜索,然后播放,大功告成

基于TypeScript的丐版分层状态机

主要用于小游戏开发,能应付简单的逻辑控制。本身支持多层嵌套,但考虑到小游戏体量,一般能用到两层足以

状态接口与状态机设计

设计思路参考开源框架Unity3d-Finite-State-Machine,简化其逻辑

1. 定义出状态实例的接口

Enter表示进入状态,Exit表示退出状态。Update和LateUpdate执行本状态的逻辑,对应Unity脚本的Update和LateUpdate函数。

1
2
3
4
5
6
7
8
9
10
export interface Status<T> {
/** 进入状态,多层情况下先执行自身Enter再执行子状态机的Enter */
Enter(owner: T, ...param: any): void;
/** 每帧调用,多层情况下先执行自身再执行子状态机的 */
Update(owner?: T): void;
/** 每帧调用,多层情况下先执行自身再执行子状态机的 */
LateUpdate(owner?: T): void;
/** 退出状态,多层情况下先执行子状态机的Exit再执行自身Exit */
Exit(owner: T): void;
}

2.1 定义一个简单的有限状态机

一个简单的有限状态机,仅需维护两个状态实例,上一状态和当前状态。然后实现状态切换的函数即可。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
export class Machine<T> {
/** owner 当前状态机的拥有者(使用者) */
protected readonly owner: T;
/** 当前状态 */
private _current: Status<T>;
/** 上一状态 */
private _previous: Status<T>;
/** 获取当前状态 */
get current(): Status<T> { return this._current }
/** 获取上一状态 */
get previous(): Status<T> { return this._previous }

/**
* 分层状态机
* @param owner 使用者
* @param state 初始状态
*/
constructor(owner?: T, state?: Status<T>) {
this.owner = owner
if (state) {
this._current = state
}
}

/**
* 切换为某一状态,不触发状态切换回调
* @param state 新状态
*/
protected SetState(state?: Status<T>) {
if (this._current) {
if (this._current == state) return
this._previous = this._current
}
this._current = state
}

/**
* 切换状态
* @param state 新状态
* @param param 传递的参数
*/
protected ChangeState(state?: Status<T>, ...param: any) {
if (this._current) {
if (this._current == state) return
this._previous = this._current
this._current.Exit(this.owner)
}
this._current = state
if (this._current) {
this._current.Enter(this.owner, ...param)
}
}

/**
* 重置为上一状态
*/
RevertState() {
this.ChangeState(this._previous)
}
}

2.2实现分层状态机

现在,将其改造为分层状态机,使其实现Status接口。则Machine类就既可以作为状态机管理子状态,也可以作为一个状态实例,被其它状态机管理。

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
// 实现Status接口
export class Machine<T> implements Status<T> {
// ---skip---
Enter(owner: T, ...param: any): void {
if (this._current) {
this._current.Enter(this.owner, ...param)
}
}

Update(owner?: T): void {
if (this._current) {
this._current.Update(this.owner)
}
}

LateUpdate(owner?: T): void {
if (this._current) {
this._current.LateUpdate(this.owner)
}
}

Exit(owner: T): void {
if (this._current) {
this._current.Exit(this.owner)
this._previous = this._current
this._current = null
}
}
// ---skip---
}

状态机的使用

通过以上代码,已经实现了一套基础的分层状态机。但是代码还很抽象,通过接下来的使用方式。才容易理解其逻辑。

使用情景例子:设计一个AI,其拥有Idle(静止)和Move(移动),两个状态。而其Move状态下,区分了两种移动方式,Walk和Run。

1.1 根据情景设计接口

AI需要四个状态,Idle、Move、Walk、Run。其中Idle和Move是一级状态,Walk和Run则是Move下的两个子状态

下面给出四个状态对应的接口

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
/** 状态机使用接口 */
export interface I {

//#region /////////////////////// Idle ////////////////////////
onEnterIdle(...param: any): void
onUpdateIdle(): void
onLateUpdateIdle(): void
onExitIdle(): void
//#endregion //////////////////// Idle-END ////////////////////

//#region /////////////////////// Move ////////////////////////
onEnterMove(move: StateMove, ...param: any): void
onUpdateMove(move: StateMove): void
onLateUpdateMove(move: StateMove): void
onExitMove(move: StateMove): void
//#endregion //////////////////// Move-END ////////////////////

//#region /////////////////////// MoveWalk ////////////////////////
onEnterMoveWalk(...param: any): void
onUpdateMoveWalk(): void
onLateUpdateMoveWalk(): void
onExitMoveWalk(): void
//#endregion //////////////////// MoveWalk-END ////////////////////

//#region /////////////////////// MoveRun ////////////////////////
onEnterMoveRun(...param: any): void
onUpdateMoveRun(): void
onLateUpdateMoveRun(): void
onExitMoveRun(): void
//#endregion //////////////////// MoveRun-END ////////////////////

}

1.2 创建对应的状态实例

首先是Idle状态,可创建为全局单例

1
2
3
4
5
6
7
export class StateIdle implements Status<I> {
static ins: StateIdle = new StateIdle
Enter(owner: I, ...param: any): void { owner.onEnterIdle(...param) }
Update(owner: I): void { owner.onUpdateIdle() }
LateUpdate(owner: I): void { owner.onLateUpdateIdle() }
Exit(owner: I): void { owner.onExitIdle() }
}

然后是Move和其两个子状态。Move即是状态,又是含有子状态的状态机,无法作为全局单例。而Walk和Run与Idle相同

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
41
export class StateMoveWalk implements Status<I> {
static ins: StateMoveWalk = new StateMoveWalk
Enter(owner: I, ...param: any): void { owner.onEnterMoveWalk(...param) }
Update(owner: I): void { owner.onUpdateMoveWalk() }
LateUpdate(owner: I): void { owner.onLateUpdateMoveWalk() }
Exit(owner: I): void { owner.onExitMoveWalk() }
}

export class StateMoveRun implements Status<I> {
static ins: StateMoveRun = new StateMoveRun
Enter(owner: I, ...param: any): void { owner.onEnterMoveRun(...param) }
Update(owner: I): void { owner.onUpdateMoveRun() }
LateUpdate(owner: I): void { owner.onLateUpdateMoveRun() }
Exit(owner: I): void { owner.onExitMoveRun() }
}

export class StateMove extends Machine<I> {

Enter(owner: I, ...param: any): void { owner.onEnterMove(this, ...param), super.Enter(owner, ...param) }
Update(owner: I): void { owner.onUpdateMove(this), super.Update(owner) }
LateUpdate(owner: I): void { owner.onLateUpdateMove(this), super.LateUpdate(owner) }
Exit(owner: I): void { super.Exit(owner), owner.onExitMove(this) }


//#region /////////////////////// Walk ////////////////////////
readonly state_walk: StateMoveWalk = StateMoveWalk.ins
get isWalk(): boolean { return this.current == this.state_walk }
get isPreWalk(): boolean { return this.previous == this.state_walk }
goWalk(...param: any) { this.ChangeState(this.state_walk, ...param) }
setWalk() { this.SetState(this.state_walk) }
//#endregion //////////////////// Walk-END ////////////////////

//#region /////////////////////// Run ////////////////////////
readonly state_run: StateMoveRun = StateMoveRun.ins
get isRun(): boolean { return this.current == this.state_run }
get isPreRun(): boolean { return this.previous == this.state_run }
goRun(...param: any) { this.ChangeState(this.state_run, ...param) }
setRun() { this.SetState(this.state_run) }
//#endregion //////////////////// Run-END ////////////////////

}

1.3 创建对应的状态机

最后是实现管理Idle和Move的状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export class Machine extends Machine<I> {

//#region /////////////////////// Idle ////////////////////////
readonly state_idle: StateIdle = StateIdle.ins
get isIdle(): boolean { return this.current == this.state_idle }
get isPreIdle(): boolean { return this.previous == this.state_idle }
goIdle(...param: any) { this.ChangeState(this.state_idle, ...param) }
setIdle() { this.SetState(this.state_idle) }
//#endregion //////////////////// Idle-END ////////////////////

//#region /////////////////////// Move ////////////////////////
readonly state_move: StateMove = new StateMove(this.owner)
get isMove(): boolean { return this.current == this.state_move }
get isPreMove(): boolean { return this.previous == this.state_move }
goMove(...param: any) { this.ChangeState(this.state_move, ...param) }
setMove() { this.SetState(this.state_move) }
//#endregion //////////////////// Move-END ////////////////////

}

1.4 创建脚本,使用状态机

最后,在对应的脚本上,使用状态机即可

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
export class AIScript extends Laya.Script3D implements I {

//#region /////////////////////// 创建使用状态的固定代码 ////////////////////////
hfsm: Machine = new Machine(this)

onUpdate(): void {
this.hfsm.Update()
}

onLateUpdate(): void {
this.hfsm.LateUpdate()
}
//#endregion //////////////////// 创建使用状态的固定代码-END ////////////////////


//#region /////////////////////// 使用示例 ////////////////////////
onEnable() {
if (/** 静止 */) {
this.hfsm.goIdle()
} else {
this.hfsm.goMove()
}
}
//#endregion //////////////////// 使用示例-END ////////////////////


//#region /////////////////////// 各个子状态的接口 ////////////////////////
onEnterIdle(...param: any): void {}
onUpdateIdle(): void {
if (/** 移动 */) {
this.hfsm.goMove()
}
}
onLateUpdateIdle(): void {}
onExitIdle(): void {}

onEnterMove(move: StateMove, ...param: any): void {
if (/** 走 */) {
move.setWalk()
} else {
move.setRun()
}
}
onUpdateMove(move: StateMove): void {
if (/** 静止 */) {
this.hfsm.goIdle()
}
}
onLateUpdateMove(move: StateMove): void {}
onExitMove(move: StateMove): void {}

onEnterMoveWalk(...param: any): void {}
onUpdateMoveWalk(): void {
if (/** 跑 */) {
this.hfsm.state_move.goRun()
}
}
onLateUpdateMoveWalk(): void {}
onExitMoveWalk(): void {}

onEnterMoveRun(...param: any): void {}
onUpdateMoveRun(): void {
if (/** 走 */) {
this.hfsm.state_move.goWalk()
}
}
onLateUpdateMoveRun(): void {}
onExitMoveRun(): void {}
//#endregion //////////////////// 各个子状态的接口-END ////////////////////

}

通过以上示例,可以看到Idle和move,通过逻辑判断互相切换。而Move状态内部,也通过逻辑判断自行切换Walk与Run。
对于外部,只需关心AI当前是Idle还是Move。而移动的方式,封装进了Move内部。不干扰Idle和Move的切换

总结

分层状态机本身,主要是Status接口和Machine类两部分代码。

状态机的使用一节中的1.1~1.3的代码,都是根据实际情景生成的代码,且代码格式固定,只要知道需要哪些状态,可以直接用脚本生成。

Go+Gin代理所有静态资源

  1. 远端不再使用nodojs工具,如hexo server等,直接使用go框架代理全部资源,减少部署流程。一个go srever即可启动所有站点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 使用Gin框架的Static和StaticFile接口,生成静态资源服务器
    func (u *StaticDir) Bind() {
    for _, param := range config_site.Site.ParamStaticService {
    println("路由路径:", param.Path, "=>", param.Index)
    dirs, err := os.ReadDir(param.Path)
    if err != nil {
    log.Println("读取静态文件夹失败:", param.Path)
    continue
    }

    for _, fi := range dirs {
    fi.Name()
    if fi.IsDir() {
    u.Ge.Static(param.Index+fi.Name(), param.Path+fi.Name())
    // fmt.Println("路由目录:", param.Index+fi.Name(), param.Path+fi.Name())
    } else {
    u.Ge.StaticFile(param.Index+fi.Name(), param.Path+fi.Name())
    // fmt.Println("路由文件:", param.Index+fi.Name(), param.Path+fi.Name())
    }
    }
    u.Ge.StaticFile(param.Index, param.Path+"index.html")
    }
    }
  2. Live2D的资源,也可采用同样方式

    1
    2
    # 通过配置表,可以使gin指向不同的静态资源
    { index: /live2d_api/model/, path: ../live2d_api/model/ }

Go重写php接口

原live2d-widget给出的示例接口,是通过php实现。需在站点部署php、php-fpm等工具,麻烦还经常抽风。遂放弃使用php自行重写

这里选择直接套用现成的开源代码live2d_api_go,并做如部分修改,达到原php接口的效果:

  1. 将net/http库,改为gin库

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // old
    mux := http.NewServeMux()
    mux.Handle("/live2Dmodel/", http.StripPrefix("/live2Dmodel/", fs))
    mux.HandleFunc("/get/", GetModel)
    mux.HandleFunc("/switch/", SwitchModel)
    mux.HandleFunc("/rand_textures/", RandomTextures)

    // new
    live2dRouter := u.Ge.Group("/live2d_api")
    {
    live2dRouter.GET("/get/", u.getModel)
    live2dRouter.GET("/switch/", u.switchModel)
    live2dRouter.GET("/rand_textures/", u.randomTextures)
    }
  2. 修改get方法参数获取方式

    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
    // old
    func GetModel(w http.ResponseWriter, req *http.Request) {
    ids, ok := req.URL.Query()["id"]
    var id string
    if !ok || len(ids) < 1 {
    //log.Printf("err")
    //error handling
    w.Write([]byte("error"))
    return
    } else {
    id = ids[0]
    }
    // --- skip ---
    }

    // new
    func (u *Live2D) getModel(c *gin.Context) {
    id := c.Query("id")
    if len(id) < 1 {
    // c.String(http.StatusNotFound, "error")
    // return
    id = "0-0"
    }
    // --- skip ---
    }
  3. 调整rand_textures接口。原接口仅根据model_list.json文件,来返回可以使用的皮肤。
    但部分模型未将皮肤信息写入model_list.json中,而是需要读取模型中的textures.cache文件,并替换index.json中的textures字段,来实现换装

  • 修改/rand_textures/接口,使其可以随机出textures.cache中的皮肤

    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
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    // old
    func RandomTextures(w http.ResponseWriter, req *http.Request) {
    // --- skip ---
    switch modelList.Models[modelId].(type) {
    case string:
    resp.Textures.ID = textureId
    case []interface{}:
    length := len(modelList.Models[modelId].([]interface{}))
    resp.Textures.ID = rand.Intn(length)
    for resp.Textures.ID == textureId {
    resp.Textures.ID = rand.Intn(length)
    }

    }
    // --- skip ---
    }

    // new
    func (u *Live2D) randomTextures(c *gin.Context) {
    // --- skip ---
    switch modelList.Models[modelId].(type) {
    case string:
    resp.Textures.ID = textureId
    // 这里判断一下textures中是否有换装资源
    resp.Textures.ID = getTexturesID(modelList.Models[modelId].(string), textureId)
    case []interface{}:
    length := len(modelList.Models[modelId].([]interface{}))
    resp.Textures.ID = rand.Intn(length)
    for resp.Textures.ID == textureId {
    resp.Textures.ID = rand.Intn(length)
    }

    }
    // --- skip ---
    }

    // 从textures.cache中获取id
    func getTexturesID(path string, id int) int {
    cacheFile, err := os.Open(live2d_resources + "model/" + path + "/textures.cache")
    if err != nil {
    return id
    }
    defer cacheFile.Close()
    byteValue, _ := io.ReadAll(cacheFile)
    var list []interface{}
    json.Unmarshal(byteValue, &list)

    length := len(list)
    newId := rand.Intn(length)
    for newId == id {
    newId = rand.Intn(length)
    }
    return newId
    }
  • 修改/get/接口,使其返回的indox.json中的textures字段可变

    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
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    // old
    func GetModel(w http.ResponseWriter, req *http.Request) {
    // --- skip ---
    var path string
    switch modelList.Models[modelId].(type) {
    case string:
    path = modelList.Models[modelId].(string)
    case []interface{}:
    path = modelList.Models[modelId].([]interface{})[textureId].(string)

    }

    model := GetModelData(path)
    // --- skip ---
    }

    // new
    func (u *Live2D) getModel(c *gin.Context) {
    // --- skip ---
    var path string
    switch modelList.Models[modelId].(type) {
    case string:
    path = modelList.Models[modelId].(string)
    case []interface{}:
    path = modelList.Models[modelId].([]interface{})[textureId].(string)
    textureId = -1
    }

    model := getModelData(path, textureId)
    // --- skip ---
    }

    // 获取index.json
    func getModelData(path string, texId int) ModelData {
    // Open our jsonFile
    jsonFile, err := os.Open(live2d_resources + "model/" + path + "/index.json")
    // if we os.Open returns an error then handle it
    if err != nil {
    fmt.Println(err)
    }
    // defer the closing of our jsonFile so that we can parse it later on
    defer jsonFile.Close()
    byteValue, _ := io.ReadAll(jsonFile)
    var model ModelData
    json.Unmarshal(byteValue, &model)

    if texId > -1 {
    getTextures(&model, path, texId)
    }

    changeDataPathInModel(&model, path)
    return model
    }

    // 获取textures.cache中的值
    func getTextures(data *ModelData, path string, id int) {
    cacheFile, err := os.Open(live2d_resources + "model/" + path + "/textures.cache")
    if err != nil {
    return
    }
    defer cacheFile.Close()
    byteValue, _ := io.ReadAll(cacheFile)
    var list []interface{}
    json.Unmarshal(byteValue, &list)

    switch list[id].(type) {
    case string:
    data.Textures = []string{list[id].(string)}
    case []interface{}:
    data.Textures = []string{}
    for _, s := range list[id].([]interface{}) {
    data.Textures = append(data.Textures, s.(string))
    }
    }
    }

接口简介

详见手册

React前端

基于Go和Python实现了AI的通讯接口后,可以添加一个web页面,来直观的展示与AI对话的功能

这里选择React+Ant+Redux搭建一个简单的对话界面

页面实现

施工中…

接口简介

详见手册

Nginx配置

主要是需要添加对ws协议的转发,同时注意手动设置下超时时间,以配合心跳包的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

location / {
root html;
index index.html index.htm;

proxy_pass http://127.0.0.1:47780;

# 添加对ws的转发
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

# 配置超时时间15分钟
proxy_read_timeout 900s;
proxy_send_timeout 900s;
}