对一个游戏来说,无论是client或server都非常需要一套代码热更新的机制。它能大大提高开发效率,又能超乎玩家期望地在运营期在线修正bug和增添功能。可谓必备机制。
热更新机制的目标是:
(1)更新代码定义
(2)不更新数据对象
(3)不要依赖热更新机制解决所有问题。过于复杂的改动,重启进程
具体到Python这个语言而言,目标便是:
(1)更新类/函数及衍生对象:class/function/method/classmethod/staticmethod
(2)不更新除了(1)中的其他类型对象
(3)不要依赖热更新机制解决所有问题。过于复杂的改动,重启进程
第(3)点将我解救出来了:不要把所有责任压在热更新机制上。
本文所指模块只限于.py/.pyc/.pyo...(即非dll/so/bulitin)为载体的模块。
Python的__builtins__中有一个众所周知的reload,但它在大项目中的可用性几乎为零也是众所周知的。它辜负了Python Documentation中对它的评价:
"This is useful if you have edited the module source file using an external editor and want to try out the new version without leaving the Python interpreter"
这里简单翻译一下Python内建的reload的说明:
当reload(M)被执行后:
* M模块将被重新解释字节码。并再执行模块级定义的执行语句(译注:由此应认识到在模块级就编写函数调用和类对象生成是多么坏的习惯呀)。并在M模块内定义一个新的 命名->新对象 的命名空间映射。
* M模块reload前的所有旧对象,直到它们的引用数量降到0,才可能被gc回收。
* M模块的命名空间中的命名全部指向了新的对象。
* 其他模块中对M模块reload前的旧对象的引用,仍然维持旧对象的引用; 如果你希望其他模块对M模块的相关对象引用能同时更新为M中的新对象, 那需要你自己动手。
一些reload函数的注意事项:
* 如果旧的模块M命名空间中的某个命名x在修改后的模块M中不存在,那reload(M)后,M.x仍然有效,并继续引用着reload(M)前的那个对象。 (译注:由于reload存在这个设定,所以下面要实现的reloadx将实现不了一个功能:即使修改模块M来删除命名,reloadx也不能删除原模块命名空间内的命名!)
* 由于存在上面一个设定,一个防止数据对象被reload重置的编码方案是:
try:
users
except NameError:
users = {"AKara", "Sheldon Cooper"}
* 如果模块B使用 from M import ... 的方式从模块M中导入对象引用, 那么reload(M)不会令B中的已导入对象产生任何影响;如果你需要实现这种影响,那需要自己动手在执行一次from .. import;又或者修改代码,使用 M.name 的方式来引用A中的对象。
会发现我们其实更希望reload应该至少长成这样子:
[1] reload(M)后,所有reload前生成的M中的类的instance(无论它在哪里),自动引用新的类实现。
[2] reload(M)后,所有对M中的function对象的引用(无论以什么方式引用),自动更新到新版本函数定义。
[3] 不需要 try .. except NameError 的编码方式,便能令reload不重置数据对象。即所有cls inst,dict, list, set, frozenset, tuple, string, None, Boolean...对象复用旧对象。
有了功能需求定义,再联系上面的[热更新机制的目标],不妨实现一个reloadx。实现的核心思路有两种:
思路1(函数和方法的更新):
Python中,一切皆为对象。(有人欢喜有人愁呀;Python的慢是有理由的)
显然,function/method/staticmethod/classmethod/class 均为对象。而变量名和对象之间的关系其实只是一种命名空间和对象空间中的引用映射(或许这事实困扰不少初学者:"Python函数传参到底是传值还是传地址?"),而对象空间中的每个对象是唯一的,有唯一的address(即id(obj));
所以,要实现这点,只需要遵守一个原则:保持对象address不变,也即是保证reloadx前后的对象是同一个对象!
乍听起来很矛盾,但是大体上是可以的:
method /staticmethod / classmethod / function这四种对象类型其实都可以归结到function object的更新上(因为method/staticmethod/classmethod本质上都是对function的一个wrapper对象,都有途径获得被wrap的function)。
function object的功能其实本质上是一个函数块,它主要由func_code, func_defaults, func_doc三个成员组成,那我们用reload后的function对象相应内容替换到旧的function对象中即可。
class则稍微特殊一些,它是由method / staticmethod / classmethod, 以及BASES关系(+MRO),数据成员等共同组成的一个对象体。但由于Python中对BASES tuple在运行时的替换有deallocator相等的限制,使得从Python脚本层次对派生关系重新定义不可行(但是增加基类是可以的:ClassA.__bases__ += (ClassB, ) ,所谓的Mix-in)。
函数和方法的更新是没问题的,替换方法和函数已经满足大部分的需求了。
优点:
- 无论这些function/class以什么方式引用,只要不深入直接引用到func_code/func_default对象,均可动态更新到
- 只需要更新一个对象,速度非常快
缺点:
- 不能动态更新class的派生关系相关的信息
思路2(新对象替换旧对象):
模块M被热更新后,找出所有对M中的class/function...有引用的对象,逐个执行新对象替换旧对象的操作。比如obj.__class__ = class_after_reload。
优点:
- 实现相对简洁
- 支持class对象的全更新
缺点:
- 对于将function/classobj.method跨模块不可变容器(tuple, frozenset...)引用的更新不了
- 如果引用对象众多,比(思路1)处理起来慢许多。
实现之前搭建一个简单的可持续测试环境,再实现reloadx,然后针对一些复杂用例进行反复测试(这是个漫长的过程)。
最终我实现了一个(思路1)的机制。机制伴随着几个约定的模块级函数调用,方便完成一些reload前后和模块初始化的数据定制。实现了reloadx后,对编写Python的良好模块的理解又进了一步。最好项目一开始便要实行系列规范。
后续可能还有一些改进措施可以做:
(1) 是否可以通过一些命名约定来实现模块级的 dict / list / set 等数据更新?
(2) 如果(1)可以实现,考虑实现 tuple frozenset 之类的固态容器更新?
(3) 监测两次update之间是否存在对象泄漏,防止reloadx多次后内存增大。
(4) 如果想偷懒,还可以开一个Python thread定时检查所有py的修改时间,自动reloadx。
(5) 实现(思路2)的版本对class处理更彻底。
reload的封装使用:
import sys
import os
class Reloader:
SUFFIX = '.pyc'
def __init__(self):
self.mtimes = {}
def __call__(self):
import pdb
pdb.set_trace()
for mod in sys.modules.values():
self.check(mod)
def check(self, mod):
if not (mod and hasattr(mod, '__file__') and mod.__file__):
return
try:
mtime = os.stat(mod.__file__).st_mtime
except (OSError, IOError):
return
if mod.__file__.endswith(self.__class__.SUFFIX) and os.path.exist(mod.__file__[:-1]):
mtime = max(os.stat(mod.__file__[:-1].st_mtime), mtime)
if mod not in self.mtimes:
self.mtimes[mod] = mtime
elif self.mtimes[mod] < mtime:
try:
reload(mod)
self.mtimes[mod] = mtime
except ImportError:
pass
reloader = Reloader()
reloader()