Lua 服务端热更新
游戏服务端之所以用 Lua,大多数时候是因为 Lua 方便做热更新,一般来说对 Lua 做热更新基本上都会使用以下两句语句。
1 | package.loaded[name] = nil |
这种方式的热更好处就是简单,不过有的代码写起来就要特别小心,当你在代码中看到以下类似的片段,很有可能是为了热更新做的一种妥协。
1 | Activity.c2sFun = Activity.c2sFun or {}; |
同时,如果 Lua 代码中存有大量的 upvalue 时,还要记得保存原有的状态信息,否则会丢失原值,对于开发人员来说,这种热更方式费心费力。
因此, Lua HotFix 就是为了摆脱以上的限制,或者说减少需要关心的事情,让开发人员能够更为简单的做热更新。之所以要自己写这么一套东西,主要是因为网络上开源的热更方案不适合项目,要么支持的Lua版本过旧,要么就约束的过多,项目已经进行到了中后期,这个时候再来规范已经来不及了,其次有很多的错误,这点我会在本文中的第二部分进行讨论。
本文主要分为两个部分,第一部分为 HotFix 实现,第二部分为热更新的错误案例。
HotFix 实现
首先放出 HotFix 源码。
通过 loadfile 将文件读入 Lua ,此时为一个 function 也就是 chunk,设置这个 function 的执行环境为我的 假环境表 我管它叫 fakeEnv ,在里面替换掉一些函数,然后执行 chunk ,就能从 fakeEnv 得到一系列的函数,全局变量信息。
接下来是确定什么能更新,什么不能更新。首先函数必更新,因为你热更不更逻辑,要你有何用?其次数据默认不更新,为什么是默认不更新,主要考虑到 upvalue ,优先保证服务器正常运作(哪怕我热更失败),但是 table 这个类型我们要更新,只更新函数即可,table 中的数据也采用默认不更新的思路(因为项目中会在 table 中保存状态数据)。
这个时候就能成功的更新上新的逻辑了,此时就要考虑数据的更新,因为我们不确定什么数据是需要更新的(比如说配置信息),因此默认是不更新数据的,如果需要更新数据,则通过 在模块中加入 __RELOAD 函数,因为什么数据要更新,使用者最为清楚,其次使用这个 __RELOAD 函数,代码入库也极为方便,基本上把热更修改后的文件直接入库就行了。
代码片段示例
1 | yuerer = {} |
因此,使用这套热更新有以下约束
- 除了函数会更新,其他默认不更新(table 里面的数据也不会默认更新,因为有的开发人员喜欢在 table 里保存状态数据)
- 如果要更新或新增除了函数以外的信息,自行定义 __RELOAD 函数,并实现
- 不支持 userdata,thread 类型
- 不要存任何 function,table 的引用(或者是显式在 __RELOAD 函数中重置引用)
- 不要热更 _ENV 的 metatable
How to use
- 假设我们 require 了一个模块 fix1,此时我们要更新 fix1 中的代码。
1 | require("fix1") |
这样就能实现最基础的 除 userdata thread 类型的热更新
- 如果想要更新数据,请在 fix1 模块下 写一个
__RELOAD函数
这主要是基于两个原因- 数据可能有状态信息
- 方便入库
1 | function __RELOAD() |
热更新的错误案例
热更新的方案在网络上多种多样,我将会挑选出几个常见的错误,在这里进行讨论。
错误①
更新前的函数没有 _ENV 这个 upvalue ,依赖对 table 进行热更的方式无法生效。
假设我有一个函数 error1 写错了,现在要进行热更。以下代码片段分别表示热更前与热更后,如果我采用函数替代的形式,我能更新的上吗?
1 | --------------- 热更前 |
显然是不能的,我们先来看看 error1 的热更前的版本的指令,可以看出,函数体只有一个 upvalue。
1 | function <test.lua:3,5> (4 instructions at 0x1687da0) |
这个时候因为函数体没有调用任何全局函数 或是 全局变量,自然没有 _ENV 这个环境表作为 upvalue,也就没有办法通过改写 _ENV[error1] = error1 的方式修改全局表的 error1 的函数地址(除非你显式的加上 rawset(_ENV, error1, xxxx),然而大多数开源的方案都没注意到这个问题)。因此当你热更后调用 error1 的时候还是会调用的热更前的版本。
而下面的版本就可以更新成功。
1 | --------------- 热更前 |
我们再来看一下 这个版本的热更前的指令。可以看到 这次函数里面有了 _ENV,我们此时可以通过改写这个 upvalue 的内容来达到替换 error1 的目的。
1 | function <test.lua:3,6> (6 instructions at 0x733da0) |
解决方案我认为分为两种。
- 鸵鸟,毕竟一个模块从头至尾不调用 全局函数 或是 操作全局变量 的概率实在是太低了(热更模块中任意函数有用到就行,估计这也是很多开源热更方案没有检查出这种错误的原因)
- 检查对函数热更的时候,有没有操作过
_ENV如果没有,则通过rawset(_ENV, k, v)这种补丁的形式覆写环境表。
错误②
使用 debug.setupvalue 进行 upvalue 修复。以下代码猜一下执行结果。
1 | local count = 0 |
答案揭晓,都为 1000。大部分的热更都没有考虑到一个 upvalue 会同时被一个以上的函数所使用的情况。
正确的热更方式是采用 debug.upvaluejoin 进行关联。
出现这样的错误主要还是因为分不清 debug 中 setupvalue 与 upvaluejoin 的区别。
错误③
还记得上个错误案例的 debug.setupvalue 吗?这里要讨论的是它和它的兄弟 debug.getupvalue。
1 | debug.getupvalue (f, up) |
可以看到,它的第二个参数为索引,那么考虑一下下面的代码片段,能否热更成功呢?
1 | --------------- 热更前 |
聪明的人可能已经发现了,如果我们按照索引来取 upvalue 然后更新到下面的那个函数中,是有问题的,我们来分别看看指令。
热更前的函数,第一个 upvalue 为 count。
1 | function <test.lua:3,5> (4 instructions at 0x19acda0) |
热更后的函数,第一个 upvalue 则为 _ENV 这是因为我们在这里先调用了 print 这个全局函数。
1 | function <test.lua:8,10> (4 instructions at 0x19aceb0) |
因此,热更 upvalue 的时候,一定不能默认更新前后的函数 upvalue 的顺序是不变的。
错误④
小心重复更新。下面的代码展示了一个错误案例。
1 | --------------- 热更前 |
正如我们前面所说,热更模块的实现无非是只替换函数与表中的函数,而数据则是默认用旧值,需要更新的数据在我所写的框架中需要定义一个 __RELOAD() 函数,在里面填写需要更新的数据(这块如果不记得的话,可以回到前面,了解一下我为什么要这么设计)
首先我们来讨论更新前,更新前没有 c3 函数,
现在来讨论更新后,更新后增加了一个 c3 函数,这种时候更新到 c1 时,会跟着 c1 的 _ENV 去更新 c2 (因为 ENV 是一个 table 需要更新), c2也有 _ENV 然后又会更新 c1 ,还会更新 c3 此时因为 c3 原本是一个不存在函数,直接设进 _ENV 就行了。
这个时候更新了 c2 ,你可能有疑惑 不是 c2 已经更新过了吗?之前更新的 c2 是因为 c1 的 _ENV 更新到的,这次是由 假环境表中找出来更新的, c2 顺着自己的 _ENV 又会更新到 c3 ,第二次更新 c3 的时候,因为之前我们已经设置到真正的 _ENV 去了,此时就要重新更新 c3 的 upvalue ,可这个“旧函数” 是同一次热更中产生的,因此在有的时候会导致 c3 的 upvalue 关联到错误的地址。
因此,要小心重复更新,无论是 table 也好, 还是 function 也好。
总结
Lua 热更新是一个值得研究的东西,它非常有趣,能够让你更理解 Lua 的运行机制,同时能够减轻项目开发人员的负担,由于时间关系,目前足够支持各个项目组各种奇怪的写法,在开源中的实现里应该是较为全面的,由于现有项目中不使用协程,而且是一个全Lua的框架,因此也没有 userdata ,目前来看是足够了。
唯一我觉得不足的地方,当其他地方存储一个 function 作为 callback 的时候,没法直接更新到,通常是采用调用字符串的形式来调用函数(其实就是从 _ENV 找这个函数的地址),如果可以将这一块做到 Lua 虚拟机中,就能实现更完美的热更新了。