使用Python來寫伺服器端程式,很大的一個優勢就是可以進行熱更新,即在不停機的情況下,使改動後的程式生效。在開發階段,這個功能可以大大提高開發效率(寫程式碼–啟動伺服器–看效果–改程式碼–hotfix–看效果–提交~);而在生產環境中,可以以最小的代價(不停機)修復線上的bug。
我在專案中使用hotfix功能很長世間了,大概瞭解它是利用了Python的import/reload功能,但是並沒有去自己研究過。最近看了雲風大大寫的一篇文章:如何讓 lua 做盡量正確的熱更新,收穫很多。也覺得應該研究一下Python的hotfix機制,畢竟是跟了自己這麼久的小夥伴嘛。
import
說到hotfix就要從import語句說起。
首先建立這樣一個簡單的檔案用作測試。
1 2 3 4 5 6 7 8 9 10 11 12 |
from __future__ import print_function class RefreshClass(object): def __init__(self): self.value = 1 def print_info(self): print('RefreshClass value: {} ver1.0'.format(self.value)) version = 1.0 print(version) |
下面啟動一個python直譯器。
1 2 3 4 5 6 7 |
>>> import test_refresh as tr 1.0 >>> import test_refresh as tr >>>> # edit version=2.0 >>> import test_refresh as tr >>> tr.version 1.0 |
重新import一個已經import過的模組,並不會重新執行檔案(第二個import之後沒有輸出)。後面修改原始檔並重新import後,對記憶體中tr.version的檢查也驗證了這一點。
為了能夠重新載入修改後的原始檔,我們需要明確的告訴Python直譯器這一點。在Python中,sys.modules儲存了已經載入過的模組。所以
1 2 3 4 5 |
>>> del sys.modules['test_refresh'] >>> import test_refresh as tr 2.0 >>> tr.version 2.0 |
在將test_refresh從sys.modules中刪除之後再進行import操作,就會重新載入原始檔了。
另外,如果我們只能拿到模組的字串名字,可以使用__import__函式。
1 2 3 4 5 6 |
# edit version=3.0 >>> del sys.modules['test_refresh'] >>> tr = __import__('test_refresh') 3.0 >>> tr.version 3.0 |
reload
當我們面對的是一個之前已經import過的模組時,可以直接使用reload進行重新載入。
1 2 3 4 5 6 |
# edit version = 4.0 >>> reload(tr) 4.0 <module 'test_refresh' from 'test_refresh.py'> >>> tr.version 4.0 |
初步嘗試hotfix
知道了模組重新載入的方法後,我們在Python的互動式命令列中,嘗試動態改變一個類的行為邏輯。
1 2 3 4 5 6 7 8 |
from __future__ import print_function class RefreshClass(object): def __init__(self): self.value = 1 def print_info(self): print('RefreshClass value: {} ver1.0'.format(self.value)) |
這是測試類的當前狀態。
我們建立一個該類的物件,驗證下它的行為。
1 2 3 4 5 |
>>> a = tr.RefreshClass() >>> a.value 1 >>> a.print_info() RefreshClass value: 1 ver1.0 |
符合預期。
接下來,修改類的print_info函式為ver2.0,並reload模組。
1 2 3 4 5 6 7 8 |
# edit print_info ver2.0 >>> reload(tr) 4.0 <module 'test_refresh' from 'test_refresh.py'> >>> a.value 1 >>> a.print_info() RefreshClass value: 1 ver1.0 |
輸出並沒有如預期一樣輸出ver2.0……
那我們重新建立一個物件試試。
1 2 3 4 5 |
>>> b = tr.RefreshClass() >>> b.value 2 >>> b.print_info() RefreshClass value: 2 ver2.0 |
新物件b的行為是符合重新載入後的邏輯的。這說明,reload確實更新了RefreshClass類的行為,但是對於已經例項化的RefreshClass類的物件,卻沒有進行更新。物件a中的行為還是指向了舊的RefreshClass類。
在Python中,一切皆是物件。不僅例項a是物件,a的類RefreshClass也是物件。
這時,要修改a的行為,就需要用到a的__class__屬性,來強制使a的類行為指向重新載入後的RefreshClass物件。
1 2 3 4 5 |
>>> a.__class__ = tr.RefreshClass >>> a.value 1 >>> a.print_info() RefreshClass value: 1 ver2.0 |
由於value是繫結在例項a上的,所以它的值並不會隨RefreshClass的改變而改變。這也符合hotfix的預期邏輯:更新記憶體中例項的行為邏輯,但是不更新它們的資料。
接下來,我們還可以通過print_info函式的imfunc屬性,驗證在更改了_class__屬性後,函式確實更新成了新版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# edit print_info ver3.0 >>> reload(tr) 4.0 <module 'test_refresh' from 'test_refresh.py'> >>> a.print_info.im_func <function print_info at 0x7f50beeb2c08> >>> c = tr.RefreshClass() >>> c.print_info() RefreshClass value: 3 ver3.0 >>> c.print_info.im_func <function print_info at 0x7f50beeb2cf8> >>> a.__class__ = tr.RefreshClass >>> a.print_info.im_func <function print_info at 0x7f50beeb2cf8> >>> a.print_info() RefreshClass value: 1 ver3.0 |
觸發hotfix
上面的操作都是在Python的互動式直譯器中執行的。下面我們將嘗試使一個執行中的Python程式進行熱更新。
這裡遇到一個問題:作為Python程式入口的那個檔案,不是以module的形式存在的,因此不能用上面的方式進行hotfix。所以我們需要保持入口檔案的儘量簡潔,而將絕大多數的邏輯功能交給其他的模組執行。
要觸發一個正在執行中的Python程式進行熱更新,我們需要有一種方式和Python程式通訊。直接使用OS的標識檔案是一個簡單易行的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
from __future__ import print_function import os import time import refresh_class rc = refresh_class.RefreshClass() while True: if os.path.exists('refresh.signal'): reload(refresh_class) rc.__class__ = refresh_class.RefreshClass time.sleep(5) rc.print_info() |
1 2 3 4 5 6 |
class RefreshClass(object): def __init__(self): self.value = 1 def print_info(self): print('RefreshClass value: {} ver1.0'.format(self.value)) |
每次我們修改完refresh_class.py檔案,就建立一個refresh.signal檔案。當refresh執行完畢,刪除此檔案即可。
這種做法一般來講,會導致多次重新載入(因為一般不能及時的刪除refresh.signal檔案)。
所以,我們考慮使用Linux下的訊號量,來同Python程式通訊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from __future__ import print_function import time import signal import refresh_class rc = refresh_class.RefreshClass() def handl_refresh(signum, frame): reload(refresh_class) rc.__class__ = refresh_class.RefreshClass signal.signal(signal.SIGUSR1, handl_refresh) while True: time.sleep(5) rc.print_info() |
我們在Python中註冊了訊號量SIGUSR1的handler,在其中熱更新RefreshClass。
那麼只需在另一個terminal中,輸入:
kill -SIGUSR1 pid
即可向pid程式傳送訊號量SIGUSR1。
當然,還有其他方法可以觸發hotfix,比如使用PIPE,或者直接開一個socket監聽,自己設計訊息格式來觸發hotfix。
總結
以上進行Python熱更新的方式,原理簡單明瞭,就是利用了Python提供的import/reload機制。但是這種方式,需要去替換每一個類的例項的__class__成員。這就往往需要在某處儲存目前記憶體中存在的所有物件(或者能夠索引到所有活動物件的根物件),並且在類的設計上,需要所有類的基類提供一個通用的refresh方法,在其中進行__class__的替換工作。對於複雜的類組合方式,這種方法比較容易在熱更新的時候漏掉某些例項。
其實還有一種途徑可以代替__class__的替換工作。我們知道,如果不替換__class__的話,即使我們重新載入進來了新的module,但是所有的__class__還將指向舊的module的class。那麼,我們不妨將新的module的內容插入到舊的module中。這樣我們就可以不用費勁去更新每一個__class__了。一般的,我們會利用import hook(sys.meta_path,詳見PEP302)來實現這個替換。當然,這種方法的實現細節較多(因為module中可能存在module,class,function等互相巢狀的情況),不過只要實現完整後,就是一勞永逸的事情了。
相關程式碼可以在GitHub上找到py-refresh。