1. 程式人生 > >python基於函式替換的熱更新原理介紹

python基於函式替換的熱更新原理介紹

熱更新即在不重啟程序或者不離開Python interpreter的情況下使得被編輯之後的python原始碼能夠直接生效並按照預期被執行新程式碼。平常開發中,熱更能極大提高程式開發和除錯的效率,在修復線上bug中更是扮演重要的角色。但是要想實現一個理想可靠的熱更模組又非常的困難。

1.基於reload

reload作為python官方提供的module更新方式,有一定作用,但是很大程度上並不能滿足熱更的需求。

先來看一下下面的問題:

>>> import sys, math
>>> reload(math)
<module 'math' (built-in
)> >>> sys.modules.pop('math') <module 'math' (built-in)> >>> __import__('math') <module 'math' (built-in)> >>> reload(math) Traceback (most recent call last): File "<pyshell#4>", line 1, in <module> reload(math) ImportError: reload(): module math
not in sys.modules >>> sys.modules.get('math') <module 'math' (built-in)> >>> id(math), id(sys.modules.get('math')) (45429424, 45540272)

函式 __import__ 會在import宣告中被呼叫。import匯入一個模組分兩步:

  1. find a module, and initialize it if necessary;
  2. define a name or names in the local namespace;

 其中第一步有以下的搜尋過程:a): sys.modules; b): sys.meta_path; c):sys.path_hooks, sys.path_importer_cache, and sys.path

上面例子中math從快取sys.modules移除後,__import__會重新load math並新增到sys.modules,導致當前環境中math繫結的math module和sys.modules中不一致,導致reload失敗。

熱更使用reload並動態的使用__import__匯入很容易犯該錯誤,另外reload要求模組之前已經被正確的引入。

# -*- coding:utf-8 -*-
import time, os, sys
import hotfix
# r = hotfix.gl_var

# @singleton
class ReloadMgr(object):
    to_be_reload = ('hotfix',)
    check_interval = 1.0
    def __init__(self):
        self._mod_mtime = dict(map(lambda mod_name: (mod_name, self.get_mtime(mod_name)), self.to_be_reload))

    def polling(self):
        while True:
            time.sleep(1)
            self._do_reload()

    def _do_reload(self):
        for re_mod in self.to_be_reload:
            last_mtime = self._mod_mtime.get(re_mod, None)
            cur_mtime = self.get_mtime(re_mod)
            if cur_mtime and last_mtime != cur_mtime:
                self._mod_mtime.update({re_mod:cur_mtime})
                ld_mod = sys.modules.get(re_mod)
                reload(ld_mod)

    @staticmethod
    def get_mtime( mod_name):
        ld_mod = sys.modules.get(mod_name)
        file = getattr(ld_mod, '__file__', None)
        if os.path.isfile(file):
            file = file[:-1] if file[-4:] in ('.pyc', '.pyo') else file
            if file.endswith('.py'):
                return os.stat(file).st_mtime 
        return None

if __name__ == '__main__':
    reload_mgr = ReloadMgr()
    reload_mgr.polling()
View Code

上面的這個例子輪詢檢測已經被匯入過的指定模組的原始碼是否被修改過,如果被修改過,使用reload更新模組。這種方式思路清晰,實現簡單,然而並沒有太大的實際用途。主要原因如下:

  • 通過 from mod_a import var_b 的方式在mod_c模組中引入的變數var_b並不會隨著reload(mod_a)而更新,var_b將依舊引用舊值。該問題同樣存在於引入的函式和類;可以重新執行from語句或者通過mod_a.var_b的方式使用var_b。顯然如果在mod_c中引入了mod_a的一個類mod_a_cls並且有一個物件a_cls_obj,要想是a_cls_obj執行新程式碼,必須要重新生成一個新的物件。
  • 用指令觸發主動的進行更新可能較為實際,避免修改錯誤或者只修改了若干檔案中的一個就觸發更新導致錯誤;
  • 指定檢測更新模組的方式不靈活,且要求先前匯入過被檢測模組;
  • 更新完成後主程序被阻塞,直到下一次更新檢測。

因此,本質上這個程式僅僅是用作檢測檔案修改並使用reload更新,根本的缺陷是舊的物件不能執行新的程式碼,需要重新生成新的物件。可以應用於特定少量檔案的更新。

2.基於程序/執行緒檢測

 針對上面介紹的一個例子存在的問題,可以使用程序或者執行緒將模組修改檢測的工作和程式的執行分離開來。

大致思路就是,不直接啟動主程式,而是啟動一個檢測程式,在檢測程式中建立一個程序或者執行緒來執行主程式。

./MainProgram.py

 1 # -*- coding:utf-8 -*-
 2 import time
 3 # import cnblogs.alpha_panda
 4 
 5 cnt = 0
 6 
 7 def tick():
 8     global cnt
 9     print __name__, cnt
10     cnt += 1
11 
12 def start_main_loop():
13     frame_time = 1
14     while True:
15         time.sleep(frame_time)
16         tick()
17 
18 def start_program():
19     print 'program running...'
20     start_main_loop()
21 
22 if __name__ == '__main__':
23     start_program()
View Code

 ./Entry.py

 1 # -*- coding:utf-8 -*-
 2 
 3 import os, sys
 4 import threading, time, subprocess
 5 import MainProgram
 6 
 7 class Checker():
 8     def __init__(self):
 9         self._main_process = None
10         self._check_interval = 1.0
11         self._exclude_mod = (__name__, )
12         self._entry_program = r'./MainProgram.py'
13         self._mod_mtime = dict(map(lambda mod_name: (mod_name, self.get_mtime(mod_name)), sys.modules.iterkeys()))
14         self._start_time = 0
15 
16     def start(self):
17         self._initiate_main_program()
18         self._initiate_checker()
19 
20     def _initiate_main_program(self):
21         # self._main_process = subprocess.Popen([sys.executable, self._entry_program])
22         main_thread = threading.Thread(target = MainProgram.start_program)
23         main_thread.setDaemon(True)
24         main_thread.start()
25         self._start_time = time.time()
26 
27     def _initiate_checker(self):
28         while True:
29             try:
30                 self._do_check()
31             except KeyboardInterrupt:
32                 sys.exit(1)
33 
34     def _do_check(self):
35         sys.stdout.flush()
36         time.sleep(self._check_interval)
37         if self._is_change_running_code():
38             print 'The elapsed time: %.3f' % (time.time() - self._start_time)
39             # self._main_process.kill()
40             # self._main_process.wait()
41             sys.exit(5666)
42 
43     def _is_change_running_code(self):
44         for mod_name in sys.modules.iterkeys():
45             if mod_name in self._exclude_mod:
46                 continue
47             cur_mtime = self.get_mtime(mod_name)
48             last_mtime = self._mod_mtime.get(mod_name)
49             if cur_mtime != self._mod_mtime:
50                 # 更新程式執行過程中可能匯入的新模組
51                 self._mod_mtime.update({mod_name : cur_mtime})
52                 if last_mtime and cur_mtime > last_mtime:
53                     return True 
54         return False
55 
56     @staticmethod
57     def get_mtime( mod_name):
58         ld_mod = sys.modules.get(mod_name)
59         file = getattr(ld_mod, '__file__', None)
60         if file and os.path.isfile(file):
61             file = file[:-1] if file[-4:] in ('.pyc', '.pyo') else file
62             if file.endswith('.py'):
63                 return os.stat(file).st_mtime 
64         return None
65 
66 if __name__ == '__main__':
67     print 'Enter entry point...'
68     check = Checker()
69     check.start()
70     print 'Entry Exit!'
View Code

 ./Reloader.py

 1 def set_sentry():
 2     while True:
 3         print '====== restart main program... ====='
 4         sub_process = subprocess.Popen([sys.executable, r'./Entry.py'],
 5             stdout = None,    #subprocess.PIPE
 6             stderr = subprocess.STDOUT,)
 7         exit_code = sub_process.wait()
 8         print 'sub_process exit code:', exit_code
 9         if exit_code != 5666:
10             # 非檔案修改導致的程式異常退出,沒必要進行重啟操作
11             print 'main program exit code: %d' % exit_code
12             break
13 
14 if __name__ == '__main__':
15     try:
16         set_sentry()
17     except KeyboardInterrupt:
18         sys.exit(1)
View Code

執行Reloader.py,然後在編輯器中修改mainProgram.py,結果如下:

====== restart main program... =====
Enter entry point...
program is running...
MainProgram 0
MainProgram 1
MainProgram 2
MainProgram 3
MainProgram 4
MainProgram 5
The elapsed time: 6.000
sub_process exit code: 5666
====== restart main program... =====
Enter entry point...
program is running...
MainProgram 0
MainProgram 100
MainProgram 200
MainProgram 300
[Cancelled]
View Code

這其中的主要涉及的問題如下:

  • 檢測程式和主程式要分別位於不同程序/執行緒,並且要能共享程序資源;
  • 檢測程式主動退出時,執行主程式的執行緒要關閉掉(注意:python threading沒有提供直接kill執行緒的介面);

以上問題決定了檢測程式和主程式要分別以子程序及其建立的執行緒的方式執行。

上面的程式中並沒有通過遍歷工程目錄的所有檔案的改動狀況來重啟程式,而是隻檢測已經被載入到記憶體中的模組,避免修改暫時沒有被使用的檔案導致錯誤的重啟。

這個例子僅僅是為了展示一種思路,將執行緒設定為守護執行緒以強迫其隨著建立程序的結束而退出的做法可能導致資源沒有正確釋放。

但這種方式本質上並不是熱更,也沒有保留程式的執行狀態,可以看做是一個自動化重啟的工具。

3.基於函式替換

下面我們從簡單到深入一步步的說明函式替換的熱更原理。

3.1 __dict__ vs attrs

先來看一個簡例:

class Foo(object):
    STA_MEM = 'sta_member variable'
@staticmethod def sta_func(): print 'static_func'
@classmethod def cls_func(cls): print 'cls_func'

def func(self): print "member func"

 下面比較一下上面類中定義的三個函式:

comp = [(Foo.sta_func, Foo.__dict__['sta_func']),(Foo.cls_func, Foo.__dict__['cls_func']),(Foo.func, Foo.__dict__['func'])]
for attr_func, dic_func in comp:
    for func in (attr_func, dic_func):
        print func, type(func), id(func), inspect.ismethod(func), inspect.isfunction(func), isinstance(func, classmethod), isinstance(func, staticmethod)

 看一下比較結果:

<function sta_func at 0x027072B0>                 <type 'function'>      40923824  False  True  False  False
<staticmethod object at 0x026FAC90>               <type 'staticmethod'>    40873104  False   False  False   True

<bound method type.cls_func of <class '__main__.Foo'>>    <type 'instancemethod'>   40885944 True False False False
<classmethod object at 0x026FAD50>                <type 'classmethod'>     40873296 False False True False

<unbound method Foo.func>                      <type 'instancemethod'>  40886024 True False False False
<function func at 0x02707B70>                   <type 'function'>      40926064 False True False False

可以看到Foo.func和Foo.__dict__['func']獲取的並不是同一個物件,型別也不同。

簡單可以理解為對於類型別,__dict__中包含的是類的namespace。裡面是原生的函式定義,而通過點運算子得到的是類的屬性。

關於這個詳細解釋可以參考instancemethod or function 和 from function to method . 這裡不做過多說明。

3.2 執行時替換物件成員函式

為了便於說明如何在程式執行時替換函式,下面刻意設計的一個簡單的例子:

 ./hotfix.py

# -*- coding:utf-8 -*-
gl_var = 0

class Foo(object):
    def __init__(self):
        self.cur_mod = __name__
    def bar(self):
        print 'This is Foo member func bar, self.cur_mod = %s' % self.cur_mod

f = Foo()
f.bar()
print 'hotfix gl_var = %d\n' % gl_var

 ./reloader.py (只使用reload)

import hotfix

if __name__ == '__main__':
    foo = hotfix.Foo()
    foo.cur_mod = __name__
    cmd = 1
    while 1 == cmd:
        reload(hotfix)
        foo.bar()
        cmd = input()

執行測試結果:

G:\Cnblogs\Alpha Panda>python Reloader.py
This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = __main__
####### 修改hotfix.Foo.bar函式的定義 #######
1
After Modified! This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = __main__
View Code

 上面的結果說明修改hotfix.Foo.bar的定義並reload之後,新定義的函式對於新建的物件是生效的,但是對於已經存在的物件reloader.foo並不生效。下面新增函式替換:

 1 import hotfix
 2 
 3 def reload_with_func_replace():
 4     old_cls = hotfix.Foo
 5     reload(hotfix)
 6     for name, value in hotfix.Foo.__dict__.iteritems():
 7         if inspect.isfunction(value) and name not in ('__init__'):
 8             # setattr(foo.bar, 'func_code', hotfix.Foo.bar.func_code)
 9             old_func = old_cls.__dict__[name]
10             setattr(old_func, "func_code", value.func_code)
11     setattr(hotfix, 'Foo', old_cls)
12 
13 if __name__ == '__main__':
14     foo = hotfix.Foo()
15     foo.cur_mod = __name__
16     cmd = 1
17     while 1 == cmd:
18         reload_with_func_replace()
19         foo.bar()
20         cmd = input()

 看一下測試結果:

G:\Cnblogs\Alpha Panda>python Reloader.py
This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = __main__
1
After Modified! This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

After Modified! This is Foo member func bar, self.cur_mod = __main__
View Code

在沒有重新建立reloader模組中的物件foo的情況下,被修改後的函式程式碼被執行了,而且物件的狀態(self.cur_mod)被保留下來了。

3.3 函式替換一般化

顯然上面的程式碼只是為了演示,使用reload要事先知道並確定模組,而且只能運用於繫結到模組的變數上,程式執行過程中通過sys.modules拿到的模組都是是str型別的,因此使用runtime使用reload顯然不合適。

 1 RELOAD_MOD_LIST = ('hotfix',)
 2 
 3 def do_replace_func(new_func, old_func):
 4     # 暫時不支援closure的處理
 5     re_attrs = ('func_doc', 'func_code', 'func_dict', 'func_defaults')
 6     for attr_name in re_attrs:
 7         setattr(old_func, attr_name, getattr(new_func, attr_name, None))
 8 
 9 def update_type(cls_name, old_mod, new_mod, new_cls):
10     old_cls = getattr(old_mod, cls_name, None)
11     if old_cls:
12         for name, new_attr in new_cls.__dict__.iteritems():
13             old_attr = old_cls.__dict__.get(name, None)
14             if new_attr and not old_attr:
15                 setattr(old_cls, name, new_attr)
16                 continue
17             if inspect.isfunction(new_attr) and inspect.isfunction(old_attr):
18                 do_replace_func(new_attr, old_attr)
19                 # setattr(old_cls, name, new_attr)
20         setattr(new_mod, cls_name, old_cls)
21 
22 def reload_with_func_replace():
23     for mod_name in RELOAD_MOD_LIST:
24         old_mod = sys.modules.pop(mod_name)        # Not reload(hotfix)
25         __import__(mod_name)                    # Not hotfix = __import__('hotfix')
26         new_mod = sys.modules.get(mod_name)
27         for name, new_attr in inspect.getmembers(new_mod):
28             if new_attr is not type and isinstance(new_attr, type):
29                 update_type(name, old_mod, new_mod, new_attr)

上面重寫了3.2中的reload_with_func_replace,這樣只要在RELOAD_MOD_LIST中指定需要熱更的模組或者定義一個忽略熱更的列表模組,然後需要的時候觸發一個指令呼叫上面的熱更流程,便可實現執行時對sys.modules中部分模組實施熱更新。

加上對閉包的處理:

def do_replace_func(new_func, old_func, is_closure = False):
    # 簡單的closure的處理
    re_attrs = ('func_doc', 'func_code', 'func_dict', 'func_defaults')
    for attr_name in re_attrs:
        setattr(old_func, attr_name, getattr(new_func, attr_name, None))
    if not is_closure:
        old_cell_nums = len(old_func.func_closure) if old_func.func_closure else 0
        new_cell_nums = len(new_func.func_closure) if new_func.func_closure else 0
        if new_cell_nums and new_cell_nums == old_cell_nums:
            for idx, cell in enumerate(old_func.func_closure):
                if inspect.isfunction(cell.cell_contents):
                    do_replace_func(new_func.func_closure[idx].cell_contents, cell.cell_contents, True)

上面僅僅對含有閉包的情況進行了簡單處理,關於閉包以及cell object相關的介紹可以參考一下我的另一篇博文:理解Python閉包概念.

4.小節

上面完整介紹了基於函式熱更的原理以及其核心的地方。考慮到python程式碼的語法很靈活,要想實際應用於專案中,還有很多要完善的地方。而且熱更對執行時程式碼的更新能力有限,重大的修改還是需要重啟程式的。就好比一艘出海的輪船,熱更僅僅可以處理一些零件的替換和修復工作,如果有重大的問題,比如船的引擎無法提供動力,那還是要返廠重修才能重新起航的:-)。

限於篇幅先介紹到這裡,有問題歡迎一起討論學習。