賴勇浩(http://laiyonghao.com)之前在部落格發過兩篇文章(http://blog.csdn.net/lanphaday/article/details/6065896,http://blog.csdn.net/lanphaday/article/details/6074095)談到過 python-message 這個自製的訂閱/發布模式的 Python 庫,但都沒有完全介紹它的全部特性。後來也在珠三角技術沙龍(http://techparty.org)上口頭講過多次,雖然有音視頻,但檢索性不佳。最近春節放假在家,重新思考了 python-message 的一些設計問題,並對訂閱的回呼函數的形式做了修改,因此新的版本將與 v0.1.x 版本不相容,新發布的版本使用 v0.2.x 版本號碼(有趣的是之前的兩篇文章使用的是 v0.0.x 版本的介面,正巧與 v0.2.x 相容,所以反而不需要更改了)。在今天,我將在這篇文章裡介紹所有 python-message v0.2 所支援的特性。安裝
python-message 已經提交到 pypi,所以支援使用 easy_install 或 pip 安裝,使用前者的話,執行以下命令即可(可能需要管理員權限):
easy_install -U message
初體驗
安裝完畢後,可以編寫一個簡單的例子體驗一下面向訊息的編程:
import messagedef hello(name): print 'hello, %s.'%namemessage.sub('greet', hello) # subscribemessage.pub('greet', 'lai') # publish
message 模組下的 sub 函數提供訂閱某個主題(topic)的介面,它接受兩個參數:第一個是主題,主題不僅可以使用字串,只要是 collections.Hashable 的子類都可以,比如 tuple;第二個參數是一個回呼函數,它的形參應該能夠接受通過 message.pub() 函數發布出來的實參,一般情況下,回呼函數是沒有傳回值的。pub 函數的第一個參數是主題,其後可以接若干個不定參數和關鍵字參數,所有的這些參數都會被傳入該主題的回呼函數。所以以上代碼的輸出是:
hello, lai.
取消訂閱
取消訂閱某個主題顯然是常規需求,把下面的代碼加到上節的程式碼片段後面,再執行,輸出是一樣的,因為後續發布的主題沒有任何回呼函數訂閱了:
message.unsub('greet', hello) # unsubscribemessage.pub('greet', 'lai') # publish
python-message 的 unsub() 支援在回呼函數中直接取消訂閱,這個特性可以方便地實現一次性訂閱,範例程式碼如下:
import messagedef hello(name): print 'hello, %s.'%name message.unsub('greet', hello)message.sub('greet', hello)message.pub('greet', 'lai')message.pub('greet', 'u cann\'t c me.')
中止訊息傳遞
python-message v0.1.x 與 v0.2.x 最大的變化就在此處,在 v0.1.x 中,為了支援中止訊息傳遞,對訊息的回呼函數增加了一個條件:第一個參數 context 接收來自 message 內部的一個控制變數,如上文中的 hello(name) 函數必須是 hello(context, name),在 hello 中對 context.discontinued 置真值,從而實現中止訊息傳遞。但在真實應用中,需要使用中止訊息傳遞的情況非常少見,所以 context 參數不僅讓人手指勞累,還常常引起 pylint/pyflaks 警示告。所以在 v0.2.x 中,我痛下決心,去掉了 context 參數,那麼又該如何中止訊息傳遞呢,請見以下代碼:
import messagedef hello(name): print 'hello %s' % name ctx = message.Context() ctx.discontinued = True return ctxdef hi(name): print 'u cann\'t c me.'message.sub('greet', hello)message.sub('greet', hi)message.pub('greet', 'lai')
如你所見,python-message 利用了回呼函數的傳回值來中止訊息傳遞,因為同一個訊息可能會被多個回呼函數處理,所以回呼函數的傳回值本身就沒有意義,正好可以用來中止訊息傳遞。不過上面代碼的寫法有點複雜了,回呼函數 hello() 可以通過如下寫法簡寫:
def hello(name): print 'hello %s' % name return message.Context(discontinued = True)
改變調用次序
python-message 是同步調用回呼函數的,也就是說誰先 sub 誰先被調用。大部分情況下這樣已經能夠滿足大分需求,但有時需要後 sub 的函數先被調用,所以 message.sub 函數通過一個預設參數來支援的,只需要簡單地在調用 sub 的時候加上 front = True,這個回呼函數將被插入到所有之前已經 sub 的回呼函數之前:sub('greet', hello, front = True)。
警告:不要改變訊息
因為 python 一直是傳引用的,所以當 pub() 一個訊息的時候,所有訊息處理回呼函數接受的實參都是同一份,如果某一個回呼函數改變了實參,將影響到後續的回呼函數調用,如此引起的 bug 非常難排查出錯的位置,所以在些鄭重警告:雖然回呼函數可以隨時改變實參,但最好不要這樣做。
訂閱過去的訊息
python-message 不是一般的訂閱/發布模式實現,它是面向訊息編程的程式庫,所以它能夠“訂閱過去的訊息”。這個需求聽起來好像不常見,讓我來舉個簡單的例子:你開發一個程式庫 foo,foo 的關鍵函數 bar() 要求調用其時某些資源已經“真正可用”,資源真正可用的意思是指資料庫連接已經連上了資料庫之類。因為初始化資料庫連接並不是 foo 的職責,所以 foo 需要有一個途徑來判斷資料庫是否已經可用,一般地,可以約定某個全域的標識量來實現,但這種方法比較骯髒。python-message 通過 declare/retract 函數對實現“公告欄”的功能,從而實現支援“訂閱過去的訊息”。
declare(topic, *a, **kw) 用來向“公告欄”發布一個訊息,可以把它看作 pub() 函數,所有訂閱了這個 topic 的回呼函數都會被調用到。如上例,當 declare() 資料庫連接就緒的時候,所有關注資料庫連接的對象都會收到通知;而資料庫連接就緒一段時間後才 sub() 這個主題的函數,也會在 sub() 的時候馬上被調用,實現了“訂閱過去的訊息”。假設資料庫連接在一段時間後失效了(如資料庫宕機),那麼可以把“公告欄”的訊息撤消,這就需要用到 retract(topic) 函數。
除了 declare/retract 函數對之外,還有兩個輔助函數 get_declarations()/has_declaration(topic) 分別用來擷取“公告欄”的所有主題和查詢某個主題是否已經上了“公告欄”,方便吧?
退化為觀察者模式
訂閱/發布模式是觀察者模式的超集,它不關注訊息是誰發布的,也不關注訊息由誰處理。但有時候我們也希望某個自己的 class 的也能夠更方便地訂閱/發布訊息,也就是想退化為觀察者模式,python-message 同樣提供了支援,見以下代碼:
from message import observabledef greet(people): print 'hello, %s.'%people.name@observableclass Foo(object): def __init__(self, name): print 'Foo' self.name = name self.sub('greet', greet) def pub_greet(self): self.pub('greet', self)foo = Foo('lai')foo.pub_greet()
python-message 提供了類裝飾函數 observable(),對任何 class 只需要通過它裝飾一下就擁有了 sub/unsub/pub/declare/retract 等方法,它們的使用方法跟全域函數是類似的,在此不述。
topic 起名技巧
observable 雖然看起來不夠 pythonic,我個人也不喜歡,但有一個不可否認的優勢就是它基本上簡單了 topic 名字衝突的問題:因為不同的 observable class instance 是擁有不同的訊息系統的。
因為我更喜歡直接使用 message.sub() 等函數,所以借鑒 java/actionscript3 的 package 起名策略,我覺得很不錯,比如在應用中定義訊息主題常量 FOO = 'com.googlecode.python-message.FOO',這樣多個庫同時定義 FOO 常量也不容易衝突。除此之外,還有一招就是使用 uuid,如下:
uuid = 'bd61825688d72b345ce07057b2555719'FOO = uuid + 'FOO'