脈絡:Content-Based Router 讓我們依訊息內容把訊息路由到正確的系統。這個過程對原始寄件端是透明的——寄件端只管把訊息送到通道,路由器接手一切。

但有些情況下,我們想自己指定訊息的一或多個接收者

常見的類比是電子郵件系統的收件人清單:寄件者可以為每封信指定一份收件人清單,郵件系統再確保內容送達每一位。

企業整合中的對應情境:

  • 某項功能可由一或多個提供者執行。例如我們與多家信用機構簽約評估客戶信用——小額訂單只送一家;大額訂單則送給多家、比較結果後再決定。此時接收者清單取決於訂單金額。
  • 我們想把訂單訊息送給一份精選的供應商名單取得報價——與其送給所有廠商,我們想控制哪些廠商收到請求,可能還依使用者偏好而定。

幾條走不通的路#

因為這是 Content-Based Router 所解問題的延伸,那裡的部分作用力與替代方案在此同樣適用。

靠 Publish-Subscribe Channel 的訂閱#

多數訊息系統提供 Publish-Subscribe Channel,把已發布訊息的副本送給每個訂閱者。

一個通道的活躍訂閱者清單相當靜態,無法逐則訊息改變

廣播 + 過濾#

因為訂閱是二元的(要嘛收全部、要嘛都不收),每個潛在接收者就得依訊息內容過濾——多半用 Message Filter 或 Selective Consumer。這把「誰該收到訊息」的邏輯分散到各訂閱者身上。

順著這條路,我們也可以把預定收件者清單附在訊息上來保有中央維護點:訊息廣播給所有可能接收者,各接收者查看清單,不在其中就丟棄訊息。

這兩種作法的問題都是沒效率——要求每個潛在接收者處理每一則訊息,只為了可能把它丟掉。

而且這種配置仰賴接收者的「榮譽制」——我們其實沒辦法阻止接收者去處理那則訊息。在「把報價請求轉給一小群精選供應商、並期待其他人忽略收到的訊息」這種情境下,這絕對不能接受。

由訊息發起者逐一發送#

這把「送達所有接收者」的重擔壓在訊息發起者身上。若發起者是套裝應用程式,這通常不是選項;而且這會把決策邏輯嵌進應用程式,讓應用程式與整合基礎設施耦合得更緊

許多情況下,被整合的應用程式根本不知道自己參與了整合方案,期待它含有訊息路由邏輯並不現實。

解法#

Recipient List 內含的邏輯可以想成兩個獨立部分(雖然實作上常耦合在一起):

  1. 計算出一份收件者清單
  2. 走訪清單,把收到的訊息複製一份送給每個接收者

和 Content-Based Router 一樣,Recipient List 通常不修改訊息內容。

清單從哪來#

  • 由外部提供——訊息發起者或另一個元件把清單附在進站訊息上,Recipient List 只需走訪這份現成清單。

這種情況下,Recipient List 通常會把清單從訊息中移除,以縮小出站訊息、並防止個別接收者看到清單上還有誰。當每則訊息的目的地取決於使用者選擇時,這種作法很合理。

  • 由 Recipient List 自行計算——多數情況下,它依訊息內容與內嵌的一組規則計算清單。規則可以寫死,也可以是可設定的。

耦合與存取控制#

Recipient List 同樣受 Message Router 所討論的耦合考量約束:把訊息預測式地路由給個別接收者,可能導致元件之間更緊的耦合——因為一個中央元件必須知道一連串其他元件。

為了讓 Recipient List 真能控制資訊流向,我們必須確保接收者無法直接訂閱 Recipient List 的輸入通道,繞過它所施加的控制

強健性#

Recipient List 必須把進站訊息送給清單上的每個接收者。一個強健的實作必須在所有出站訊息都成功送出之後,才「消費」掉進站訊息——也就是說整個操作必須是原子的,失敗後必須可重啟。有三種作法:

  • 單一交易——使用交易性通道,把訊息放上所有出站通道當作單一交易的一部分,全部放完才提交。這保證要嘛全送、要嘛全不送
  • 持久化的收件者清單——「記住」自己已經送過哪些訊息,故障重啟後只送給剩下的接收者。清單可存在磁碟或資料庫中,以撐過元件崩潰。
  • 冪等接收者——重啟時乾脆全部重送。這要求所有潛在接收者都是冪等的(見 Idempotent Receiver)。

訊息可能天生就冪等(「所有 widget 特價到 5 月 30 日」或「幫我報 XYZ widget 的價」重複收到都不太會造成傷害),也可以透過插入一個剔除重複訊息的特殊 Message Filter,把接收元件變成冪等的。

冪等非常好用——它讓我們在不確定接收者是否收到時,可以乾脆重送。TCP/IP 協定用的正是類似機制,在不增加不必要開銷的前提下確保可靠遞送。

動態收件者清單#

雖然 Recipient List 的用意是保持控制,但有時讓接收者自己去設定清單中的規則集是有價值的——例如接收者想依「難以用發布訂閱通道主題表達」的規則來訂閱特定訊息(如「價格低於 $48.31 才接受」)。

為了把網路流量降到最低,我們仍希望只送給有興趣的一方,而不是廣播後讓各接收者自行判斷。作法是:

  1. 接收者透過一個特殊的控制通道把訂閱偏好送給 Recipient List
  2. Recipient List 把偏好存進規則庫
  3. 用規則庫為每則訊息編出收件者清單

這種作法把訊息過濾的控制權交給訂閱者,同時保留 Recipient List 在散發訊息上的效率。它結合了 Dynamic Router 與 Recipient List 的性質,構成 Dynamic Recipient List

這對前面提到的「價格更新」例子很適用;但因為它把控制權指派給個別接收者,並不適合本模式開頭那個「精選供應商投標」的例子

圖 7-6:由接收者透過控制通道設定的 Dynamic Recipient List

網路效率的考量#

「送一則訊息給所有可能接收者再由他們過濾」與「逐一送個別訊息」哪個有效率,非常取決於訊息基礎設施的實作

一般而言接收者愈多、網路流量愈大,但有例外:

有些發布訂閱系統建立在 IP Multicast 之上,能用單次網路傳輸把訊息送給多個接收者(只有遺失的訊息才需重傳)。

IP Multicast 利用乙太網路的匯流排架構:IP 封包送上網路時,同一網段上的所有網卡都會收到;平常網卡會檢查目標位址、不符就忽略,而 multicast 路由允許屬於指定 multicast 群組的所有接收端都把封包從匯流排上讀走——單一封包因此能被多張網卡接收。

這在區域網路上非常有效率,但在需要點對點 TCP/IP 連線的網際網路上行不通

一般結論:接收者彼此距離愈遠,使用 Recipient List 相對於 Publish-Subscribe Channel 就愈有效率。

廣播是否更有效率,還取決於「該處理這則訊息的接收者」佔「所有接收者」的比例:

  • 若平均而言多數接收者都在清單上,那乾脆廣播、讓少數不參與者自行過濾,可能更有效率
  • 若平均而言只有一小部分接收者對某則訊息有興趣,Recipient List 幾乎必然更有效率

Recipient List vs. Pub-Sub + 過濾器陣列#

我們已多次對比「用 Recipient List 做預測式路由」與「用 Publish-Subscribe Channel 加一組 Message Filter 做反應式過濾」。部分判準與 Content-Based Router 對 Message Filter 陣列的比較相同;但在 Recipient List 的情況下,訊息可以送給多個接收者,這讓「過濾」選項更具吸引力

Content-Based Router / Recipient ListPublish-Subscribe Channel + Message Filter
中央控制與維護——預測式路由分散控制與維護——反應式過濾
路由器必須知道參與者;參與者增減時可能得更新(除非用 Dynamic Router,但那會失去控制權)不需要知道參與者;增減參與者很容易
常用於業務交易(例如報價請求)常用於事件通知/資訊性訊息
限於佇列式通道時通常更有效率搭配發布訂閱通道可能更有效率(視基礎設施而定)

接下來#

若把訊息送給多個接收者,之後可能需要調解結果。例如向多家信用機構索取信用評分時,應該等所有結果回來再比較挑最好的;其他較不關鍵的功能,則可能取第一個可用的回應來優化吞吐量。這類策略通常實作在 Aggregator 中,而 Scatter-Gather 描述的正是「從單一訊息出發、送給多個接收者、再把回應重組成單一訊息」的情境。

若訊息系統只提供 Point-to-Point Channel 而沒有 Publish-Subscribe Channel,動態 Recipient List 可以用來實作發布訂閱通道:它保存一份訂閱了該「主題」(由這個 Recipient List 實例代表)的所有點對點通道清單。

當我們需要用特殊準則控管「誰能訂閱某個資料來源」時,這也很有用——只要訊息系統能確保接收者無法直接存取 Recipient List 的輸入通道,它就能輕鬆實作存取控制邏輯。

範例:Loan Broker 與 C#/MSMQ 的動態收件者清單

Loan Broker#

本書後面的組合式訊息間奏用 Recipient List 把貸款報價請求只路由給符合資格的銀行,並提供 Java、C# 與 TIBCO 三種實作。

用 C# 與 MSMQ 實作動態收件者清單#

這個範例把 Dynamic Router 範例改造成動態 Recipient List,結構非常相似:DynamicRecipientList 監聽 inQueuecontrolQueue

控制訊息格式是以冒號分隔的兩段字串:

  • 第一段是一串字元,表示接收者的訂閱偏好(「我想收到以這些字母之一開頭的所有訊息」)
  • 第二段是接收者監聽的佇列名稱

例如 "W:WidgetQueue" 表示把所有 W 開頭的訊息路由到 WidgetQueue"WG:WidgetGadgetQueue" 則表示把 WG 開頭的訊息都路由到 WidgetGadgetQueue

class DynamicRecipientList
{
    protected MessageQueue inQueue;
    protected MessageQueue controlQueue;

    protected IDictionary routingTable = (IDictionary)(new Hashtable());

    public DynamicRecipientList(MessageQueue inQueue, MessageQueue controlQueue)
    {
        this.inQueue = inQueue;
        this.controlQueue = controlQueue;

        inQueue.ReceiveCompleted += new ReceiveCompletedEventHandler(OnMessage);
        inQueue.BeginReceive();

        controlQueue.ReceiveCompleted += new ReceiveCompletedEventHandler(OnControlMessage);
        controlQueue.BeginReceive();
    }

    protected void OnMessage(Object source, ReceiveCompletedEventArgs asyncResult)
    {
        MessageQueue mq = (MessageQueue)source;
        mq.Formatter = new System.Messaging.XmlMessageFormatter(
            new String[] {"System.String,mscorlib"});
        Message message = mq.EndReceive(asyncResult.AsyncResult);

        if (((String)message.Body).Length > 0)
        {
            char key = ((String)message.Body)[0];

            ArrayList destinations = (ArrayList)routingTable[key];
            foreach (MessageQueue destination in destinations)
            {
                destination.Send(message);
                Console.WriteLine("sending message " + message.Body + " to " + destination.Path);
            }
        }
        mq.BeginReceive();
    }

    // control message format is XYZ:QueueName as a single string
    protected void OnControlMessage(Object source, ReceiveCompletedEventArgs asyncResult)
    {
        MessageQueue mq = (MessageQueue)source;
        mq.Formatter = new System.Messaging.XmlMessageFormatter(
            new String[] {"System.String,mscorlib"});
        Message message = mq.EndReceive(asyncResult.AsyncResult);

        String text = ((String)message.Body);
        String [] split = (text.Split(new char[] {':'}, 2));
        if (split.Length == 2)
        {
            char[] keys = split[0].ToCharArray();
            String queueName = split[1];
            MessageQueue queue = FindQueue(queueName);
            foreach (char c in keys)
            {
                if (!routingTable.Contains(c))
                {
                    routingTable.Add(c, new ArrayList());
                }
                ((ArrayList)(routingTable[c])).Add(queue);
                Console.WriteLine("Subscribed queue " + queueName + " for message " + c);
            }
        }
        mq.BeginReceive();
    }

    protected MessageQueue FindQueue(string queueName)
    {
        if (!MessageQueue.Exists(queueName))
        {
            return MessageQueue.Create(queueName);
        }
        else
            return new MessageQueue(queueName);
    }
}

存偏好的方式比 Dynamic Router 聰明(也複雜)一些:為了優化進站訊息的處理,它維護一個以「進站訊息第一個字母」為鍵的 Hashtable;但值不是單一目的地,而是所有已訂閱目的地的 ArrayList。收到訊息時先找出正確的目的地清單,再走訪清單逐一送出。

這個範例沒有為「不符合任何條件的進站訊息」設 dunno 通道——一般而言,Recipient List 不把「某則訊息有零個接收者」視為錯誤

這個實作不允許接收者取消訂閱,也不偵測重複訂閱。若某個接收者對同一訊息型別訂閱兩次,它就會收到重複訊息——這與典型的發布訂閱語意不同(後者一個接收者對同一通道只能訂閱一次)。若需要,DynamicRecipientList 很容易改成禁止重複訂閱。