Lua 的代码加载和热更新方式

  由于在游戏服务器的架构中,大部分的进程都是有状态的,所以就非常依赖热更新。Lua 方便的热更新是其得以在手游后端开发中大量使用的重要原因,本篇来讲一下我了解过的 Lua 的一些代码加载和热更新方式。

加载模块

dofile

  使用 dofile 进行代码加载是最简单粗暴的,在进程启动的时候,直接将本进程所有要用到的脚本文件使用 dofile 加载进来。
  如果需要重新加载,那么就对修改过的文件再次执行 dofile 重新加载一次。但是这样加载有一个不好的地方,就是每个文件都要对应的使用一个全局变量 Table 来进行保存,而且如果这个 Table 被别的文件里使用 local 引用了,那么即使重新 dofile 加载一次,原来的引用还是保存了原先那个 Table 的地址。

loadfile

  使用 loadfile 加载文件,会得到一个中间函数,这个中间函数执行完就跟直接调用 dofile 加载效果是一样的了。但是因为有了一个中间函数,就有一些操作的机会。
  可以使用 setfenv 将加载结果的函数放在一个新创建的 Table 中执行,这样既解决了需要全局变量的问题,也解决了别的地方引用的问题。给出一个简单的实现方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local modules = {}
function import(filename, reload)
    local filefunc = loadfile(filename)
    if not modules[filename] then
        local m = {}
        setmetatable(m, {__index = _G})
        local wrapfunc = setfenv(filefunc, m)
        wrapfunc()
        modules[filename] = m
        return m
    else
        if reload then
            local m = modules[filename]
            local wrapfunc = setfenv(filefunc, m)
            wrapfunc()
            return m
        else
            return modules[filename]
        end
    end
end

local xxx = import("xxx.lua")

  需要注意的是,因为 Lua 底层机制的改变,setfenv 在 Lua5.2 及以后就废除了,不过也可以通过 _ENV 模拟出来,Implementing setfenv in Lua 5.2, 5.3, and above

require

  require 是 Lua 官方提供的加载模块的接口,不仅可以加载 Lua 模块,也可以加载 C 模块。它加载过的模块会被放在 package.loaded 中,对同一个模块第二次加载,不会重复加载,会直接返回旧的模块给调用者。
  需要重新加载的时候需要首先将 package.loaded 中对应模块设为 nil,然后再次执行 require。但是这样也有一个问题,那就是如果在某个地方使用 local 变量引用了 require 的结果,那么更新以后它引用的还是旧的模块。

热更新

  重新加载一个模块并不能完整对它进行热更新,热更新需要在重新加载模块的时候做很多处理,对各个类型的变量进行有选择性的保留或是覆盖。下面来说一下各个种类的变量要怎么处理。

外部变量

  外部变量包括了模块中的全局变量和 return 出来的局部变量。它们比较容易处理,因为外部可以直接读取到,区分它们的处理方式要看需不需要处理它们的外部变量引用。
  如果不需要处理它们的外部引用,那么一般将整个文件重新加载即可。重新加载以后文件中的变量都会被覆盖掉,如果需要保留部分旧值的话,可以在重新加载之前在外部获取并且保存这些旧值,然后在重新加载之后,再次恢复这些旧值即可。
  如果需要处理它们的外部引用,那么就需要使用后面要讲到的热更新脚本了,会在原地址上进行修改,来保证外部的引用不会出问题。

局部变量

  局部变量如果不需要保留的话,那就不用额外处理了,局部变量在外部都是以函数的 upvalue 形式存在的,重新加载以后函数会使用新的 upvalue,跟以前的值没关系。如果要保留以前值的话,那就要将旧的 upvalue 赋值给新的 upvalue。
  upvalue 的处理大概是热更新中最麻烦的部分,这部分因为要处理的问题跟实际情况的关联很强,所以除非在开发过程中就有一些强制规范约束,否则基本上不太可能实现类似一键更新这种操作。
  对于要处理局部变量的情况,一般采用的方法是为每次热更新编写一个脚本,在脚本中将需要修改的内容全部收集起来,然后使用 loadfile 加载热更脚本,并且将之前收集到的内容作为 loadfileenv 参数传入。
  处理 upvalue 的时候需要使用到一些 debug 库的方法。

  • debug.getinfo 用来获得函数信息,可以通过使用 debug.getinfo(func, “u”) 获得函数信息中与 upvalue 相关的部分。
  • debug.getupvalue 可以获得指定函数指定序号的 upvalue 的 name 和 value。
  • debug.setupvalue 可以增加函数的 upvalue。
  • debug.upvalueid 可以得到指定函数指定序号的 upvalue 的一个唯一标识符,可以用来判断两个函数引用的 upvalue 是否是同一个值。
  • debug.upvaluejoin 让一个闭包中的某个 upvalue 引用另一个闭包的某个 upvalue。

  要实现 upvalue 的保留,本质就是通过上述的 api 拿到想要保留的旧函数 upvalue 的值,然后再赋值给新函数的 upvalue。
  收集 upvalue 的方法可以参考 skynet 中 inject 的实现稍作修改,将 number 和 string 类型的 upvalue 也加入进来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
local function getupvaluetable(u, func, unique)
    local i = 1
    while true do
        local name, value = debug.getupvalue(func, i)
        if name == nil then
            return
        end
        local t = type(value)
        if t == "table" or t == "number" or t == "string" then
            u[name] = value
        elseif t == "function" then
            if not unique[value] then
                unique[value] = true
                getupvaluetable(u, value, unique)
            end
        end
        i = i + 1
    end
end

  收集完成以后,修改的方式就非常自由了,既可以把模块整个重新加载一遍,然后将保存的 upvalue 还原,也可以在原来模块的基础上直接修改函数。一般为了避免其它地方引用的问题,所以还是在原地址上直接修改函数的比较多。
  来看一个简单的例子,在 m.add 被执行几次以后,需要进行热更修改,将 a 每次增加的值改为 2,并且需要保留之前 a 的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- m.lua
local m = {}

local a = 1

function m.add()
    a = a + 1
    print("a", a)
end

return m

  在热更脚本 hotfix.lua 中,从环境中拿到传进来的 upvalue 直接赋值即可,旧的 m.add 函数和其 upvalue a 都被换成了新的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- hotfix.lua
local a = u.a
m.add = function()
    a = a + 2
    print("a", a)
end

-- main.lua
local m = require "m"
m.add() -- a = 2
m.add() -- a = 3
m.add() -- a = 4

local u = {}
local unique = {}
getupvaluetable(u, m.add, unique)
local env = setmetatable({u = u, m = m}, {__index = _ENV})
loadfile("./hotfix.lua", "bt", env)()

m.add() -- a = 6
m.add() -- a = 8

  再来看一个稍微复杂一点的情况,这次再增加一个函数 m.set,目标是将 add 改成 a = a + step,并且 step 变成两个函数的公共 upvalue,也就是说再次调用 m.set 修改 step 以后,m.add 可以直接使用新的值,而不需要再次热更。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
local m = {}

local a = 1
local step = 10

function m.add()
    a = a + 1
end

function m.set(s)
    step = s
end

return m

  要实现 upvalue 的关联需要使用 debug.upvaluejoin 将 m.add 的第二个 upvalue step 与 m.set 的第一个 upvalue step 进行关联。

 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
-- hotfix.lua
local a = u.a
local step = u.step
m.add = function()
    a = a + step
end

debug.upvaluejoin(m.add, 2, m.set, 1)

-- main.lua
local m = require "m"
m.add() -- a = 2
m.add() -- a = 3
m.add() -- a = 4

local u = {}
local unique = {}
getupvaluetable(u, m.add, unique)
getupvaluetable(u, m.set, unique)
local env = setmetatable({u = u, m = m}, {__index = _ENV})
loadfile("./hotfix.lua", "bt", env)()

m.add() -- a = 14,增加了 10
m.set(100)
m.add() -- a = 114,增加了 100

  可以看到热更过以后,再次调用 m.add 此时累加的步长已经变成了 step 的当前值 10,使用 m.setstep 设为 100 以后,再次调用 m.add,累加的步长变成了 100。

旧的对象

  Lua 中的面向对象是使用元表的 __index 方法模拟出来的,创建出来的对象只有修改过的数据和函数是自己的,未修改过的数据和函数都是使用的类定义的提供的。
  得益于这种实现方法,Lua 中热更新一个类的成员函数基本上不需要做什么额外处理,直接在原函数的位置上修改即可。
  在 c.lua 中实现一个简单的类,每次为它调用 add 方法会累加自己的 count 值。

 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
-- c.lua
local c = {
    count = 1
}

function c:new()
    return setmetatable({}, {__index = self})
end

function c:add()
    self.count = self.count + 1
end

return c

-- hotfix.lua
c.add = function(self)
    self.count = self.count + 10
end

-- main.lua
local c = require "c"
local o1 = c:new()
o1:add() -- o1.count = 2

loadfile("./hotfix.lua", "bt", setmetatable({c = c}, {__index = _ENV}))()

o1:add() -- o1.count = 12

local o2 = c:new()
o2:add() -- o1.count == 11

  可以看到在热更过以后,不管是热更之前创建的旧对象还是热更以后创建的新对象,执行的都是更新过的逻辑。

Licensed under CC BY-NC-SA 4.0
Built with Hugo
主题 StackJimmy 设计