Lua脚本热更新

本文继续来写一下脚本代码的热更新在游戏客户端或服务端的实现,之前写了一篇【 客户端python热更新】里面提到热更新要注意的要点,这篇作为续篇就不再复述了,这次主要讲一下在python里无法热更新的 闭包函数upvalue怎么保留这两个遗留缺陷,转过头来看看另外一个解释型动态类型语言"Lua"。

相信游戏行业的同行都不会对Lua语言陌生,Lua的特点:基于寄存器的虚拟机,简洁的语法,高效的编译执行,容易嵌入的特性。Lua在国内互联网技术上的应用也占领不少市场,redis,openresty, skynet等等都能看到Lua忙碌的身影。Lua的语言特性相对于python来说更加简单,接下来看一下怎么逐步实现Lua脚本的热更新。

Lua的require函数

与phthon的import类似地,Lua的 require(modelname)把一个lua文件加载存放到 package.loaded[modelname]中,重复require同一个模块实际还是沿用第一次加载的chunk。因此,很容易想到,第一个版本的热更新模块可以写成这样:
--强制重新载入module
function require_ex( _mname )
    log( string.format("require_ex = %s", _mname) )
    if package.loaded[_mname] then
        log( string.format("require_ex module[%s] reload", _mname))
    end
    package.loaded[_mname] = nil
    require( _mname )
end

可以看到,强制地require新的模块来更新新的代码,非常简单暴力。但是,显然问题很多,旧的引用住的模块无法得到更新,全局变量需要用"a = a or 0"这种约定来保留等等。这种程度的热更新显然不能满足现在的游戏开发需求。


Lua的setenv函数

setenv是Lua 5.1中可以改变作用域的函数,或者可以给函数的执行设置一个环境表,如果不调用setenv的话,一段lua chunk的环境表就是 _G,即Lua State的全局表,print,pair,require这些函数实际上都存储在全局表里面。那么这个setenv有什么用呢?我们知道 loadstring一段lua代码以后,会经过语法解析返回一个 Proto,Lua加载任何代码chunk或function都会返回一个Proto,执行这个Proto就可以初始化我们的lua chunk。为了让更新的时候不污染_G的数据,我们可以给这个Proto设置一个空的环境表。同时,我们可以保留旧的环境表来保证之前的引用有效。
local Old = package.loaded[PathFile]
local func, err = loadfile(PathFile)
--先缓存原来的旧内容
local OldCache = {}
for k,v in pairs(Old) do
     OldCache[k] = v
     Old[k] = nil
end
--使用原来的module作为fenv,可以保证之前的引用可以更新到
setfenv(func, Old)()

做完这一步,相信有些人已经懂得如何去做更新了,就是对旧环境表里的数据和代码做处理,这里的细节就不一一贴代码了,主要是注意处理function和模拟的class的更新细节,根据具体情况进行取舍。


Lua的debug库函数

Lua的函数是带有词法定界的first-class value,即Lua的函数与其他值(数值、字符串)一样,可以作为变量、存放在表中、作为传参或返回。通过这样实现闭包的功能,内嵌的函数可以访问外部的局部变量。这一特性给Lua带来强大的编程能力同时,其函数也不再是单一无状态的函数,而是连同外部局部变量形成包含各种状态的闭包。如果热更新缺少了对这种闭包的更新,那么可用性就大打折扣。
下面讲一下热更新如何处理旧的数据,还有闭包的upvalue的有效性问题怎么解决。这时候强大的Lua debug api上场了,调用debug库的 getlocal函数可以访问任何活动状态的局部变量, getupvalue函数可以访问Lua函数的upvalues,还有相对应的修改函数。
例如,这是查询和修改函数局部变量写的debug函数:
-- 查找函数的local变量
function get_local( func, name )
    local i=1
    local v_name, value
    while true do
        v_name, value = debug.getlocal(func,i)
        if not v_name or v_name == name then
            break
        end
        i = i+1
    end
    if v_name and v_name == name then
        return value
    end
    return nil
end
-- 修改函数的local变量
function set_local( func, name, value )
    local i=1
    local v_name
    while true do
        v_name, _ = debug.getlocal(func,i)
        if not v_name or v_name == name then
            break
        end
        i = i+1
    end
    if not v_name then
        return false
    end
    debug.setlocal(func,i,value)
    return true
end

一个函数的局部变量的位置实际上在语法解析阶段就已经能确定下来了,这时候生成的opcode就是通过寄存器的索引来找到局部变量的,了解这一点应该很容易理解上面的代码。修改upvalue的我就不列举了,同样的道理,这时你一定已经看出来了,这种方式可以实现某种程度的数据更新。

明白了debug api操作后,还是对问题的解决毫无头绪,先看看skynet怎么对代码进行热更新的吧,上面的代码是我对skynet进行修改调试时候写的。skynet的热更新并不是对文件原地修改更新,而是先把将要修改的函数打成patch,再把patch inject进正在运行的服务完成更新,skynet里面有一个机制对patch文件中的upvalue与服务中的upvalue做了重新映射,实现原来的upvalue继续有效。可惜它并不打算对所有闭包upvalue做继承的支持,skynet只是把热更新用作不停机的bug修复机制,而不是系统的热升级。通过inject patch的方式热更新可以看出来,云风并不认为热更新所有的闭包是完全可靠的。对热更新的定位我比较赞同,但是我想通过另外方式完成热更新,毕竟管理各种patch的方式显得不够干净。

深度递归替换所有的upvalue

接下来要做的事情很清晰了,递归所有的upvalue,根据一定的替换规则替换就可以,注意新的upvalue需要设置回原来的环境表。
function UpdateUpvalue(OldFunction, NewFunction, Name, Deepth)
     local OldUpvalueMap = {}
     local OldExistName = {}
     -- 记录旧的upvalue表
     for i = 1, math.huge do
          local name, value = debug.getupvalue(OldFunction, i)
          if not name then break end
          OldUpvalueMap[name] = value
          OldExistName[name] = true
     end
     -- 新的upvalue表进行替换
     for i = 1, math.huge do
          local name, value = debug.getupvalue(NewFunction, i)
          if not name then break end
          if OldExistName[name] then
               local OldValue = OldUpvalueMap[name]
               if type(OldValue) ~= type(value) then          -- 新的upvalue类型不一致时,用旧的upvalue
                    debug.setupvalue(NewFunction, i, OldValue)
               elseif type(OldValue) == "function" then     -- 替换单个函数
                    UpdateOneFunction(OldValue, value, name, nil, Deepth.."    ")
               elseif type(OldValue) == "table" then          -- 对table里面的函数继续递归替换
                    UpdateAllFunction(OldValue, value, name, Deepth.."    ")
                    debug.setupvalue(NewFunction, i, OldValue)
               else
                    debug.setupvalue(NewFunction, i, OldValue)     -- 其他类型数据有改变,也要用旧的
               end
          else
               ResetENV(value, name, "UpdateUpvalue", Deepth.."    ")     -- 对新添加的upvalue设置正确的环境表
          end
     end
end

这是替换upvalue的函数,替换fucntion的函数相信很多项目都有写过,这里不再粘贴,而且不同的项目相信还有一些自己定制的替换规则。还有一点要注意的是,如果重新设置了metatable,在遍历table的时候也替换一遍就可以了。最后,如果大家对这个热更新的特性有兴趣,我会写测试用例的方式把特性罗列出来,不过得抽时间写,估计代码量是这个热更新代码的两三倍。



你可能感兴趣的:(lua)