探究如何給Python程式做hotfix

發表於2016-12-20

使用Python來寫伺服器端程式,很大的一個優勢就是可以進行熱更新,即在不停機的情況下,使改動後的程式生效。在開發階段,這個功能可以大大提高開發效率(寫程式碼–啟動伺服器–看效果–改程式碼–hotfix–看效果–提交~);而在生產環境中,可以以最小的代價(不停機)修復線上的bug。

我在專案中使用hotfix功能很長世間了,大概瞭解它是利用了Python的import/reload功能,但是並沒有去自己研究過。最近看了雲風大大寫的一篇文章:如何讓 lua 做盡量正確的熱更新,收穫很多。也覺得應該研究一下Python的hotfix機制,畢竟是跟了自己這麼久的小夥伴嘛。


import

說到hotfix就要從import語句說起。

首先建立這樣一個簡單的檔案用作測試。

下面啟動一個python直譯器。

重新import一個已經import過的模組,並不會重新執行檔案(第二個import之後沒有輸出)。後面修改原始檔並重新import後,對記憶體中tr.version的檢查也驗證了這一點。

為了能夠重新載入修改後的原始檔,我們需要明確的告訴Python直譯器這一點。在Python中,sys.modules儲存了已經載入過的模組。所以

在將test_refresh從sys.modules中刪除之後再進行import操作,就會重新載入原始檔了。

另外,如果我們只能拿到模組的字串名字,可以使用__import__函式。

reload

當我們面對的是一個之前已經import過的模組時,可以直接使用reload進行重新載入。

初步嘗試hotfix

知道了模組重新載入的方法後,我們在Python的互動式命令列中,嘗試動態改變一個類的行為邏輯。

這是測試類的當前狀態。

我們建立一個該類的物件,驗證下它的行為。

符合預期。

接下來,修改類的print_info函式為ver2.0,並reload模組。

輸出並沒有如預期一樣輸出ver2.0……

那我們重新建立一個物件試試。

新物件b的行為是符合重新載入後的邏輯的。這說明,reload確實更新了RefreshClass類的行為,但是對於已經例項化的RefreshClass類的物件,卻沒有進行更新。物件a中的行為還是指向了舊的RefreshClass類。

在Python中,一切皆是物件。不僅例項a是物件,a的類RefreshClass也是物件。
這時,要修改a的行為,就需要用到a的__class__屬性,來強制使a的類行為指向重新載入後的RefreshClass物件。

由於value是繫結在例項a上的,所以它的值並不會隨RefreshClass的改變而改變。這也符合hotfix的預期邏輯:更新記憶體中例項的行為邏輯,但是不更新它們的資料。

接下來,我們還可以通過print_info函式的imfunc屬性,驗證在更改了_class__屬性後,函式確實更新成了新版本。

觸發hotfix

上面的操作都是在Python的互動式直譯器中執行的。下面我們將嘗試使一個執行中的Python程式進行熱更新。

這裡遇到一個問題:作為Python程式入口的那個檔案,不是以module的形式存在的,因此不能用上面的方式進行hotfix。所以我們需要保持入口檔案的儘量簡潔,而將絕大多數的邏輯功能交給其他的模組執行。

要觸發一個正在執行中的Python程式進行熱更新,我們需要有一種方式和Python程式通訊。直接使用OS的標識檔案是一個簡單易行的方法。

每次我們修改完refresh_class.py檔案,就建立一個refresh.signal檔案。當refresh執行完畢,刪除此檔案即可。

這種做法一般來講,會導致多次重新載入(因為一般不能及時的刪除refresh.signal檔案)。

所以,我們考慮使用Linux下的訊號量,來同Python程式通訊。

我們在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

相關文章