山姆鍋在過去的文章中,數次提到「參與者模式 (Actor Model)」這個名詞,也使用 比喻的方式 解釋它的概念。在多種分散式編程模式中,對山姆鍋來說,這是最不會讓頭腦打結的方法。
本文山姆鍋試圖整理寫過的一個參與者框架,雖然不完整,希望還是多少對參與者模式如何運作的了解有所幫助。
設計目標 (Design Goals)
- 參與者無須知道溝通的對象所在的主機。
- 參與者無須知道訊息如何傳遞。
- 訊息結構可以擴展,容許應用程式彈性利用。
- 支援 「訊息通知 (notification)」的通訊模式。
- 支援 「請求 - 回應 (request-response)」通訊模式。
- 訊息傳輸協定 (transport protocol) 可以更換。
物件模型 (Object Model)
框架定義了一些應用程式該繼承或使用的類別,底下說明框架中主要的類別。
Actor
代表框架中的「參與者」物件。每個參與者都有一個全域 (global) 唯一的名稱(name),在同一個引擎(engine), 這個名稱必須是唯一。
每個參與者會有一個信箱 (mailbox),存放該參與者收到但尚未處理的訊息。在此框架,參與者不會明確呼叫來從信箱取得訊息,而是由框架推送(push) 給參與者物件。
ActorRef
代表參與者的「參考」物件。這個物件封裝與一個參與者溝通的必要資訊,如:參與者名稱、所在引擎的網路位址等。 同時提供幾個關鍵方法:
- tell: 用來傳送通知 / 一般訊息給此參與者。
- ask: 用來送出請求訊息給此參與者。
- get_proxy: 用來取得一個特別的代理 (proxy) 物件,此代理物件可以把方法呼叫轉化成請求訊息。
ActorProxy
參與者的代理物件,用來把傳送請求訊息轉化成對此代理物件的呼叫。運用到 Python 的 __getattribute__
方法, 攔截方法呼叫並轉成訊息送給對應的參與者。 ActorRef
的 get_proxy
方法會回傳一個 ActorProxy 物件。
ActorContext
針對每個參與者物件,框架會配置一個 ActorContext 物件。此物件擔任參與者與框架之間的主要介面, 同時封裝關於此參與者的重要資訊。底下是此類別主要方法:
-
`get_name(self)`:
取的此參與者的名稱。
-
`get_self_ref(self)`:
取得此參與者的「參考」物件。 當需要以非同步的方式接受通知時,需要像事件源註冊,此時便會需要取得自己 的參考物件。因為所有溝通都只能透過參考物件,直接回傳參與者物件並沒有用處。
-
`get_sender_ref(self)`:
取的目前傳送目前訊息的參與者參考物件。這個方法只在處理訊息時可以用,底層使用 Greenlet local 物件 來記錄與傳遞當前的訊息傳送者,是一種類似 Thread Local Storage 的機制。
-
`spawn(self, actorcls, _args,*kwargs)`:
派生出新的參與者。
-
`make_ref(self, actor_name, address)`:
根據參與者名稱與網路位址建構一個 ActorRef(參考) 物件。
-
`serialize_ref(self, actor_ref)`:
把一個 ActorRef 物件序列化成適合網路傳遞或儲存的格式。
-
`deserialize_ref(self, ref_str)`:
把經過序列化的 ActorRef 物件反序列化成物件。
Message
總共有四個具體 (concrete) 的訊息類別,分別為:
-
Message:
代表一般或者通知訊息,用在不需要接收者回應的情境。
-
Request:
代表請求的訊息,預期接受者回傳成功或者失敗的回應。
-
Result:
代表請求執行成功的回應訊息,通常也會內涵執行結果。
-
Error:
代表請求執行錯誤的回應訊息,包含基本錯誤訊息。
訊息相關的類別定義在: https://github.com/sampot/simpleactors/blob/master/ava/actor/message.py ⎘
ActorEngine
此類別定義實作參與者框架的主要邏輯,如派生參與者物件、遞送訊息與處理訊息逾時等等工作。 每個引擎物件會有一個網路位址用來跟其它引擎通訊。如果參與通訊的參與者都屬於同一個引擎, 則訊息會採取捷徑方式處理並不會到網路層。
通訊模式
參與者之間只能透過訊息傳遞來互相溝通,而且參與者只能跟它已經有「參考」物件的對象通訊。 參與者之間可以分成兩種溝通模式:
-
通知模式 (notification):
這種模式很容易理解,就是訊息送出去後就不管了,也可以說是 fire-and-forget 模式。 在框架中,
tell
操作就是用來實現這種通訊模式。 -
請求 - 回應模式 (request-response):
這種模式下,送出訊息的參與者會預期接收的參與者會給予回應,不管是表示成功或失敗結果。 在框架中,底下三種訊息類別分別代表請求、成功的結果、失敗的例外訊息:
-
Request:
: 封裝一個代表請求的訊息,請求訊息包含要接收者執行的動作 (action) 以及參數。
-
Result:
: 封裝代表請求的執行結果。
-
Error:
: 封裝網路或者請求錯誤的資訊。逾時 (timeout) 沒有收到回應,框架也會以一個 Error 訊息通知參與者。
參與者藉由呼叫
ask
方法來送出請求,並根據回應來判斷是成功、失敗或者逾時。 -
參與者實作概觀
參與者需要以一個類別實現,這個類別不一定需要繼承自框架提供的 Actor
類別,但參與者類別至少需要符合下列要求:
- 如果有建構子,除了 `actor_context` 這個具名參數 (named parameter) 外,其餘參數都必須有預設值。
有兩個特別的方法,用來處理訊息跟要求:
- `handle_message`: 只有在此參與者收到一般訊息 (通知訊息) 時,框架才會呼叫此方法。
- `handle_action`: 當參與者收到請求訊息,但沒有對應的處理方法時,框架會呼叫這個方法。
參與者類別如果有定義 on_
開頭的方法,如果收到對應動作 (action) 的請求訊息,框架會自動呼叫該方法, 這時就不會再呼叫 handle_action
。 例如:參與者收到一個叫做 ‘hello’ 的動作要求, 則框架會呼叫 on_hello
這個方法。
具體的範例請參考使用情境中的 PongActor
類別。
使用情境 (Use Scenarios)
底下使用情境使用一個叫做 `PongActor` 的參與者實作,程式碼如下:
class PongActor(object):
instance = None
def __init__(self, actor_context):
self.context = actor_context
print("PongActor created.")
self.called = False
self.last_msg = None
PongActor.instance = self
def on_ping(self, s):
self.called = True
self.last_msg = s
return s
def on_error(self):
self.called = True
raise RuntimeError('Pong error')
def on_get_sender(self):
self.called = True
return self.context.get_sender_ref().actor_name
def handle_message(self, msg):
self.called = True
self.last_msg = msg
def handle_action(self, *args, **kwargs):
self.called = True
self.last_msg = kwargs
return kwargs
這個參與者也實際被用在框架的測試案例中,那些額外的屬性都是為了測試需要。
取得 ActorContext 物件
參與者只有在被建構的當下才有機會取的它對應的 ActorContext 物件。參與者類別必須定義一個接受名為 actor_context
的具名參數,框架在產生此參與者物件時會帶入 ActorContext 物件。
例如下面的 __init__
宣告:
def __init__(self, actor_context):
self._context = actor_context
這個方法可以有其它參數,只要它們都有預設值。
為了判斷方法是否接受一個名為 actor_context
的參數,使用了 Python 內建的 inspect
套件。
該技巧應該有參考價值,特別收錄下列建立參與者物件的片段:
argspec = inspect.getargspec(actor_cls.__init__)
if 'actor_context' in argspec.args:
# 判斷是否有'actor_context' 這個參數
# 有的話,插入 ActorContext 物件的具名參數。
kwargs['actor_context'] = context
_actor = actor_cls(*args, **kwargs)
其中,`actor_cls` 是參與者類別。
ActorContext 是參與者物件與框架互動的介面,除非是不需要其它參與者通訊的參與者, 不然大概都會採用這個方式取的 ActorContext 物件。
派生一個參與者 (Spawn Actors)
假設 actor_engine
是參與者引擎物件,則可以使用下面片段來派生新的參與者:
pong_ref = actor_engine.spawn(PongActor)
其中,`PongActor` 是參與者的實作類別,`spawn` 方法會傳回該參與者物件的「參考」。 透過這個「參考」物件,其它參與者便可以跟它溝通。
上述的方法是針對框架本身要派生新的參與者物件時使用,參與者物件則透過關聯的 ActorContext 物件:
pong_ref = actor_context.spawn(PongActor)
傳送通知訊息給參與者
假設 pong_ref
是一個參與者的「參考」物件,下面片段示範如何送一個通知訊息給它:
msg = message.Message()
msg.receiver = pong_ref.actor_name
msg.msg_id = b'msgid1234'
pong_ref.tell(msg)
過程中沒有錯誤的話,`PongActor` 的 handle_message
方法會被呼叫來處理此訊息。
要求參與者處理一個動作
req = message.Request()
req.receiver = pong_ref.actor_name
req.action = 'ping' # 1
req.arguments = {'s': 'hello'}
result = pong_ref.ask(req)
text = result.get(1)
上述程式片段會呼叫到 PongActor
的 on_ping
這個方法。
使用代理物件簡化請求 - 回應
同樣假設 pong_ref
是 PongActor
的一個參考物件,下面程式片段示範如何使用參與者代理物件來簡化請求:
pong = pong_ref.get_proxy()
text = pong.ping(s='world')
PongActor
的 on_ping
方法同樣會被呼叫,只是在程式碼這個層級看起來比較容易理解,
但背後還是採用標準的訊息傳遞機制在運作。
實作構想
- 一開始設計時,實作就決定採用 Gevent 跟 Greenlet。每個 Actor 都由一個 greenlet 來負責。
- 訊息的實際傳送由原始碼中的
net
套件所負責,它本身也自成一個通訊框架,由於主要目的是說明參與者框架,就不在此詳述。
已知限制
- 框架不維護參與者之間的階層關係 (hierarchical relationships),所以當參與者意外結束時,並不會通知上層負責監管的參與者。
- 訊息傳遞是基於 UDP 協定,跨主機通訊時,訊息有可能會遺失、順序顛倒,這些都是參與者實作要處理。
結語
山姆鍋越寫越覺得這個框架還真的蠻陽春的,不過還好山姆鍋夠阿 Q,會正面思考,總覺得會有些值得參考的地方吧!
參考資料
`simepleactors`: https://github.com/sampot/simpleactors ⎘
本文所討論的參與者框架原始碼。