用途
在生产环境上,总有可能出现不可预知的Bug,而通常修改好Bug仅仅又修改几句,停机维护的成本又太高,对于游戏来说,通常每个服就是单独的进程,也做不到像分布式环境下,关掉一部分机器,先升级一部分,再升级另一部分的无缝升级。这时候如果有热更就可以迅速的把Bug修复方案通过热更新进行修复,不会对用户任何的影响。例如:
- 业务逻辑有Bug
- 配置的数据有误
- 需求发生变更
热更新的原则
1、热更新不破坏原有数据
热更新更新的基本内容就是更新服务的逻辑,通常只是逻辑发生变化,但原有的值并不能被改变,例如:
local a = 1 function get_a() return a end
此时,我们调用get_a()返回是的1,我们将热更成
local a = 2 function get_a() print("get_a function") return a end
此时我们改变了a的初始值,但我们并不知道之前服务a的值是不是被重新赋过值,假设热更前a的值仍然为1,那么我们热更后调用get_a()返回的应该是1,而不应受新的初始值影响,而且同能打印出了"get_a function",这时候则认为热更正常。
2、不为热更新写更多的代码
热更新可以通过很多种方法实现,比如说模块为了支持数据不变的特性,需要在模块里额外写一些代码来记录旧值,热更新之后再把旧值copy过来,或者用一些特殊的语法来支撑。这种方法将会对项目增加很多的负担,而且一旦发生意料之外的Bug,热更系统几乎处于半瘫痪状态。应该来说,代码原本该怎么实现就怎么实现,对于99%的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
热更新的实现,代码适用于5.2以上
原理
利用_ENV环境,在加载的时候把数据加载到_ENV下,然后再通过对比的方式修改_G底下的值,从而实现热更新,函数
function hotfix(chunk, check_name)
定义env的table,并为env设置_G访问权限,然后调用load实现把数据重新加载进来
local env = {} setmetatable(env, { __index = _G }) local _ENV = env local f, err = load(chunk, check_name, 't', env) assert(f,err) local ok, err = pcall(f) assert(ok,err)
此时env我们可以得到新函数有变更的部分,我们替换的为可见变量,也就是可直接访问的变量
for name,value in pairs(env) do local g_value = _G[name] if type(g_value) ~= type(value) then _G[name] = value elseif type(value) == 'function' then update_func(value, g_value, name, 'G'..' ') _G[name] = value elseif type(value) == 'table' then update_table(value, g_value, name, 'G'..' ') end end
通过env当前的值和_G当前的值进行对比
- 如果类型不同我们直接覆盖原值,此时value不为nil,不会出现原则被覆盖成nil的情况
- 如果当前值为函数,我们进行函数的upvalue值比对
function update_func(env_f, g_f, name, deep) --取得原值所有的upvalue,保存起来 local old_upvalue_map = {} for i = 1, math.huge do local name, value = debug.getupvalue(g_f, i) if not name then break end old_upvalue_map[name] = value end --遍历所有新的upvalue,根据名字和原值对比,如果原值不存在则进行跳过,如果为其它值则进行遍历env类似的步骤 for i = 1, math.huge do local name, value = debug.getupvalue(env_f, i) if not name then break end local old_value = old_upvalue_map[name] if old_value then if type(old_value) ~= type(value) then debug.setupvalue(env_f, i, old_value) elseif type(old_value) == 'function' then update_func(value, old_value, name, deep..' '..name..' ') elseif type(old_value) == 'table' then update_table(value, old_value, name, deep..' '..name..' ') debug.setupvalue(env_f, i, old_value) else debug.setupvalue(env_f, i, old_value) end end end end
- 如果当前值为table,我们遍历table值进行对比
local protection = { setmetatable = true, pairs = true, ipairs = true, next = true, require = true, _ENV = true, } --防止重复的table替换,造成死循环 local visited_sig = {} function update_table(env_t, g_t, name, deep) --对某些关键函数不进行比对 if protection[env_t] or protection[g_t] then return end --如果原值与当前值内存一致,值一样不进行对比 if env_t == g_t then return end local signature = tostring(g_t)..tostring(env_t) if visited_sig[signature] then return end visited_sig[signature] = true --遍历对比值,如进行遍历env类似的步骤 for name, value in pairs(env_t) do local old_value = g_t[name] if type(value) == type(old_value) then if type(value) == 'function' then update_func(value, old_value, name, deep..' '..name..' ') g_t[name] = value elseif type(value) == 'table' then update_table(value, old_value, name, deep..' '..name..' ') end else g_t[name] = value end end --遍历table的元表,进行对比 local old_meta = debug.getmetatable(g_t) local new_meta = debug.getmetatable(env_t) if type(old_meta) == 'table' and type(new_meta) == 'table' then update_table(new_meta, old_meta, name..'s Meta', deep..' '..name..'s Meta'..' ' ) end end
更新
1、可以调用hotfix_file对整个文件进行热更
function hotfix_file(name) local file_str local fp = io.open(name) if fp then io.input(name) file_str = io.read('*all') io.close(fp) end if not file_str then return -1 end return hotfix(file_str, name) end
2、可以通过hotfix进行代码的更新
function hotfix(chunk, check_name)
关于坑
这里有一个注意事项,lua的module模块,如:
module("AA", package.seeall)
当我们加载lua模块的时候,这时候这个模块信息并不像初始化全局代码一样,就算提前设置了package.loaded["AA"] = nil, 也不会出现在env中同时也不会调用_G的__newindex函数,也就是说env["AA"]为空,故这种写法无法进行热更新,所以通常模块的写法改成如下
--定义模块AA AA = {} --相当于package.seeall setmetatable(AA, {__index = _G}) --环境隔离 local _ENV = AA