A股上市公司传智教育(股票代码 003032)旗下技术交流社区北京昌平校区

 找回密码
 加入黑马

QQ登录

只需一步,快速开始

Python代码模块热更新机制实现reload-(jiayichendddd)

  对一个游戏来说,无论是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中的对象。
  * 如果一个模块已经产生了它的某个class的instance,那重定义这个class并reload这个模块,并不能影响已经存在的instance的class————这个instance还在用着reload前的class。这个限制对派生类一样存在。


  会发现我们其实更希望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()  


第一个知识点:
self.__class__.SUFFIX,首先python一切皆对象,类实际上在python的世界里面也是一个对象,__class__至少在下面这种情况中是有用的:当一个类中的某个成员变量是所有该类的对象的公共变量时.
很像c++中的static变量。
第二个知识点:
__import__与reload的区别:
(1):多次重复使用import语句时,不会重新加载被指定的模块,只是把对该模块的内存地址给引用到本地变量环境。
(2)对已经加载的模块进行重新加载,一般用于原模块有变化等特殊情况,reload前该模块必须已经import过。reload会重新加载已加载的模块,但原来已经使用的实例还是会使用旧的模块,而新生产的实例会使用新的模块;reload后还是用原来的内存地址;不能支持from。。import。。格式的模块进行重新加载。
(3)通常在动态加载时可以使用到这个函数,比如你希望加载某个文件夹下的所用模块,但是其下的模块名称又会经常变化时,就可以使用这个函数动态加载所有模块了,最常见的场景就是插件功能的支持。比如__import__可以这样__import__('os',globals(),locals(),['path','pip']) ,reload就不行了。

第三个知识点:
__call__的用法,可调用对象,这个确实是第一次接触到,大概的用法就是我们只需要去重载这个方法,那么我们就可以像调用方法的形式去调用对象,那么它就会去回调这个__call__方法


0 个回复

您需要登录后才可以回帖 登录 | 加入黑马