在写这篇文章之前, 我特意在标题前加了个"终"字。因为我相信,这就是生产环境中热更新的最终出路。
大约在4年前,我实现过一版热更新。
但是这个版本并不理想。在一些简单场景下工作良好,场景稍一复杂,就显得捉襟见肘。
比如下面代码, 我的热更新实现就无法很好的进行更新。
--M1.lua
local M1 = {}
local case = {
[1] = function() --[[xx]] end
[2] = function() --[[xx]] end
}
function M1.foo(x)
case[x]()
end
return M1
--M2.lua
local foo = require "M1.foo"
local M2 = {}
function M2.foo(x)
foo(x)
end
return M
它即不能很好的更新M1.foo
函数,因为M2直接持有了M1.foo
函数的引用。也不能很好的更新M1
中的case
引用的函数, 因为case
中的函数不是任何一个函数的直接上值。
当然,我们可以加入一些强制规范来解决问题。比如:不允许一个模块持有其他模块的函数引用,不允许一个函数只被表
引用而不被函数引用,所有上值全部入表,等等。
不允许一个模块持有其他模块的函数
这个问题不大,毕竟可以增加代码可读性。但是,限制总是有或多或少增加实现者的负担,也会限制实现者的创造性。
而热更新
作为一种非常规修复Bug的方案,我不希望他对实现者做过多的干扰。
在这之后我一度认为进程级滚动更新才是未来,不仅可以修复Bug, 还可以进行不停机功能更新。
直到前两天,和一位同学再次聊起热更新。
我突然想到,进程级滚动更新有一个致命的弱点,他是有损的(当然在WEB领域是无损了,因为WEB服务器无状态)。某个进程在升级期间,会有短暂的服务不可用。
进程级滚动升级和停服升级相比,服务不可用的时间会大大降低。但是就临时修Bug而言,热更新可能还是更为合适。
那么,还是想办法改造一下热更新的实现吧。
我又重新思考了一下整个程序的构成,程序是由模块组成的,而模块与模块的交互是通过模块的导出函数进行的。
这样如果我们以模块为粒度进行热更新,就不太会造成模块撕裂的现象。比如下面代码:
--M1.lua
local M1 = {}
local function foobar()
print("1")
end
function M1.foo()
foobar()
print("foo1")
end
function M1.bar()
foobar()
print("bar1")
end
return M1
--M2.lua
local M2 = {}
local functin foobar()
print("2")
end
local function M2.foo()
foobar()
print("foo2")
end
local function M2.bar()
foobar()
print("bar1")
end
return M2
如果仅仅只使用M2.foo
去更新M1.foo
, 就会造成新的M1.foo
调用的foobar和旧的M1.bar
调用的foobar不是同一个函数。
但是以模块为粒度进行更新就不会有这种问题,我很快便实现了第二版。
随后发现,第二版除了解决了模块撕裂问题外,和第一版没有本质区别。第一版存在的问题在第二版依然存在。
问题并不是解决不了,但是这些方案都代表着超高的代价,我认为在生产环境这是不可接受的。
case表中引用函数问题
,可以递归遍历上值中的Table来解决。但这里有一个潜在的风险,不小心引用了一个超大Table, 可能会有遍历整个LuaVM的风险。
一个模块持有其他模块引用的函数问题
, 可以通过遍历整个LuaVM中函数的上值来修正。
种种迹象表明,不可能存在一种方案,即智能又高效又正确。
即然如此,只好采用一种更灵活,但是复杂的方案了。虽然很复杂,但是能解决问题。
这个复杂的方案就是:不管是以函数为单位还是模块为单位,都不再实现一键热更功能。取而代之的是,每次Bug的修复,都需要重新编写修复脚本。
在修复脚本中,我们可以使用Lua原生的debug.upvaluejoin
来正确的将修复函数引用到被修复的函数的上值,然后使用修复函数替换被修复函数。
针对一个模块持有另一个模块的导出函数引用的情况,我们也可以使用debug.setupvalue
来进行修正。
与此同时。我观察到,模块撕裂在某种程度上是不会有副作用的。
比如下面代码:
M.foo和M.bar虽然调用的不是同一个foobar函数,但是由于foobar1和foobar2逻辑相同,并且共享上值。这种情况下,模块撕裂现象虽然会增加内存,但却并不会有副作用。
local M = {}
local a, b = 0,0
local function foobar1()
a = a + 1
b = b + 1
end
local function foobar2()
a = a + 1
b = b + 1
end
local function M.foo()
foobar1()
print("foo2")
end
local function M.bar()
foobar2()
print("bar1")
end
return M
在修复脚本的方案下,修复逻辑可以是千变万化。但是一个共通点,我们在编写修复脚本时总是需要先定位到一个函数,然后对两个或两组函数进行上值的join。
在这种思路下,我实现了第三版热更新。共提供三个接口:
sys.patch.create
用于创建一个上下文, 用于在随后的修复操作中做一些必要的缓存。sys.patch.collectupval(f_or_t)
用于收集一个或一组函数的上值信息, 该函数会递归遍历所有上值为函数的上值。sys.patch.join(f1, f2)/sys.patch.join(f1, up1, f2, up2)
用于将f1函数的上值以上值名字为关联信息引用f2的上值, 如果f1的某个上值名字不存在于f2的上值列表中,则这个上值的路径将会被存储到路径列表中,sys.patch.join
在执行完之后, 总是会返回一个路径列表, 即使这个列表为空。
我们应该如何利用上面这组接口定位一个函数呢。
通过观察,我们可以得知一个事实。函数与函数的引用关系其实是一张有向有环图。这也就意味着不管我们想定位哪一个函数,它一定就在某一个函数的上值或者上值的Table中。
因此,sys.patch.collectupval
收集来的上值,不但可以辅助我们做新旧函数的上值引用,还可以辅助定位我们需要更新的函数。
最后以一个简单的热更新例子来结束:
--run.lua
local M = {}
local a, b = 1, 1
function M.foo()
a = a + 1
end
function M.bar()
b = b + 1
end
function M.dump()
return a, b
end
return M
--fix.lua
local M = {}
step = 3
local a, b = 1, 1
function M.foo()
step = 4
a = a + step
b = b + step
end
function M.bar()
b = b + 1
end
function M.dump()
return a, b
end
return M
--修复脚本 将使用fix.lua来修复run.lua中的逻辑
local patch = require "sys.patch"
local run = require "run"
local ENV = setmetatable({}, {__index = _ENV})
local fix = loadfile("fix.lua", "bt", ENV)()
local P = patch:create()
local up1 = P:collectupval(run)
local up2 = P:collectupval(fix)
local absent = P:join(up2, up1)
print(absent[1], absent[2])
debug.upvaluejoin(up2.foo.val, up2.foo.upvals.b.idx, up2.dump.val, up2.dump.upvals.b.idx)
print("hotfix ok")
for k, v in pairs(fix) do
run[k] = v
end
for k, v in pairs(ENV) do
if type(v) == "function" or not _ENV[k] then
_ENV[k] = v
end
end
在这个例子中,我们可以看到fix.lua
中的M.foo
函数引用的上值b
, 是不能被正确引用的。因为run.lua
中的M.foo函数没有上值b, 这时我们就需要手动调用debug.upvaluejoin
来修正fix.lua中的M.foo函数的上值b的引用。