本節用 Microsoft .NET、C# 與 MSMQ 實作貸款仲介範例。.NET 的 System.Messaging 命名空間讓程式能存取 Windows 內建的 Message Queuing 服務。

本節盡量把焦點放在方案的設計面,因此即使你不是 C# 開發者,這個範例仍然有價值——除了 System.Messaging 介面本身之外,若用 Java 與 JMS 實作,大部分程式碼會非常相似。

有些功能若用 Microsoft BizTalk Server 這類整合與編排工具,很可能用更少的力氣就能完成。作者刻意不用,理由有二:不想為了跑這個簡單範例就得取得授權,以及想展示所有必要功能的明確實作

貸款仲介的生態系#

理解設計最好從外向內看,先檢視貸款仲介必須支援的所有外部介面。

因為訊息佇列是單向的,要與另一個元件建立請求-回應通訊就需要一對佇列

  • 仲介在 loanRequestQueue 上接收貸款報價請求,在 loanReplyQueue 上回覆測試客戶端
  • 與信用機構的互動走另一對類似的佇列
  • 不為每家銀行各建一對佇列,而是讓所有銀行回覆到同一個 bankReplyQueue

於是:

  • Recipient List 把請求訊息送到各銀行的佇列
  • Aggregator 從抵達回覆佇列的訊息中挑出最佳報價
  • 兩者合起來構成一個分派式的 Scatter-Gather

為求簡單,本例所有銀行使用相同的訊息格式,因此不需要 Normalizer;但因為共同的銀行訊息格式與消費者期待的格式不同,仍需要一個 Message Translator 把銀行回覆轉成貸款仲介的回覆。

作者把貸款仲介設計成 Process Manager不是把仲介內部的功能拆成以訊息佇列分隔的個別元件,而是做成一個在內部執行所有功能的單一元件

省去了在這些功能之間跨佇列送訊息的開銷,但要求仲介維護多個並行的流程實例

圖 9-12:帶訊息佇列介面的 Loan Broker

打地基:一個 Messaging Gateway#

為了不讓應用程式碼被 MSMQ 專屬指令淹沒,作者把 MSMQ 相關功能分離到獨立類別中——Gateway 是非常適合這件事的模式

使用 Gateway 有兩個關鍵優勢:

  • 把通訊的技術細節從應用程式抽象掉
  • 若把 gateway 介面與實作分開,就能在測試時用 Service Stub 取代真正的外部服務

作者定義了兩個介面,刻意保持到近乎瑣碎的簡單——IMessageSender 只能送訊息,IMessageReceiver 只能收訊息(外加一個 Begin 方法告訴它可以開始接收了)。

namespace MessageGateway
{
    using System.Messaging;

    public interface IMessageSender
    {
        void Send(Message mess);
    }
}

圖 9-13:Loan Broker 的內部結構

圖 9-14:Credit Bureau Gateway 的結構

圖 9-15:從另一個組件建立類別 stub

重構:從 ACT 到流程物件#

最初的實作用單一個 LoanBroker 實例,靠一組 **ACT(Asynchronous Completion Token)**集合來模擬多個實例。

ACT 很有用,但它把資料與功能分開,某種程度上違背了物件導向的精神——而那兩者正是構成一個物件的東西。

重構的關鍵洞見是:delegate 本質上是型別安全的函式指標,指向特定的物件實例

因此與其把「單一 LoanBroker 實例中某個方法」的參照交給信用機構與銀行 gateway,我們可以讓 delegate 指向一個「流程物件」的特定實例——它既像 ACT 那樣保存當前狀態,又含有貸款仲介流程的邏輯

於是 ACT 變成新的 LoanBrokerProcess 類別,訊息處理函式也搬了進去:

internal class LoanBrokerProcess
{
    protected LoanBrokerPM broker;
    protected String processID;
    protected LoanQuoteRequest loanRequest;
    protected Message message;

    protected CreditBureauGateway creditBureauGateway;
    protected BankGateway bankInterface;

    public LoanBrokerProcess(LoanBrokerPM broker, String processID,
                             CreditBureauGateway creditBureauGateway,
                             BankGateway bankGateway,
                             LoanQuoteRequest loanRequest, Message msg)
    {
         this.broker = broker;
         this.creditBureauGateway = creditBureauGateway;
         this.bankInterface = bankGateway;
         this.processID = processID;
         this.loanRequest = loanRequest;
         this.message = msg;

         CreditBureauRequest creditRequest =
             LoanBrokerTranslator.GetCreditBureaurequest(loanRequest);
         creditBureauGateway.GetCreditScore(creditRequest,
             new OnCreditReplyEvent(OnCreditReply), null);
    }

    private void OnCreditReply(CreditBureauReply creditReply, Object act)
    {
         BankQuoteRequest bankRequest =
             LoanBrokerTranslator.GetBankQuoteRequest(loanRequest, creditReply);
         bankInterface.GetBestQuote(bankRequest, new OnBestQuoteEvent(OnBestQuote), null);
    }

    private void OnBestQuote(BankQuoteReply bestQuote, Object act)
    {
        LoanQuoteReply quoteReply =
            LoanBrokerTranslator.GetLoanQuoteReply(loanRequest, bestQuote);
        broker.SendReply(quoteReply, message);
        broker.OnProcessComplete(processID);
    }
}

這些方法不再參照 gateway 傳來的 ACT 參數,因為所有必要資訊都存在 LoanBrokerProcess 實例中。流程完成後,它用 SendReply 送出回覆訊息,再通知 LoanBrokerPM 流程已完成。

圖 9-16:管線化處理能顯著提高吞吐量

圖 9-17:中介者會妨礙以系統產生之 Message ID 做關聯

圖 9-18:Bank Gateway 的結構

圖 9-19:Loan Broker 類別圖(MSMQ 實作)

改善效能:找出瓶頸#

方案跑起來後,作者用測試資料產生器送出 50 個隨機請求衡量吞吐量:收到 50 則回覆訊息總共花了 27 秒

很容易誤以為每個請求花 27 / 50 = 0.5 秒。錯! 吞吐量是「27 秒 50 個請求」,但其中有些請求花了 26 秒才完成

第一個瓶頸:信用機構#

檢視測試期間的佇列快照,發現信用機構的請求佇列裡積了 39 則訊息——所有報價請求都得先過信用機構,它顯然是瓶頸。

現在可以享受鬆散耦合的紅利:多啟動兩個信用機構實例,變成三個平行實例。

結果:處理 50 則訊息的總時間降到 21 秒,最久的請求等不到 15 秒,平均等待時間從原本的一半降到 8 秒

訊息吞吐量沒有像期望的那樣戲劇性提升,但別忘了這個簡單範例的所有行程都跑在同一顆 CPU 上、彼此競爭資源

圖 9-22:信用請求佇列中積壓了 39 則訊息

圖 9-23:送出 50 筆報價請求(使用 3 個信用機構實例)

第二個瓶頸:Bank 5#

消除了一個瓶頸,卻冒出新的一個——Bank 5

為什麼是它?Bank 5 是那家「借錢給所有人」的當鋪,因此它幾乎參與了每一筆報價請求。

我們當然可以再多開幾個 Bank 5 實例,但期待當鋪為了改善我們的吞吐量而多跑幾個實例並不現實

另一個選項是改變銀行請求的路由邏輯:由於當鋪的收費明顯高於其他銀行,它的報價通常只有在「沒有其他銀行提供報價」時才是最低的。據此,我們可以修改 BankConnectionManager只在其他銀行都無法服務時才把請求路由給 Bank 5——這在不影響系統整體行為的前提下提升了效率。

我們也清楚看到非同步訊息與事件驅動消費者的優勢——我們能在 12 秒內處理 50 個報價請求,同步方案要花上 8 到 10 倍的時間!

圖 9-20:執行 MSMQ 範例

圖 9-21:送出 50 筆報價請求

圖 9-24:現在 Bank 5 成了瓶頸

關於測試的三條建議#

貸款仲介範例顯示:一個簡單應用程式一旦變成分散式、非同步、事件驅動,就會相當複雜。

複雜度上升意味著缺陷風險上升;而非同步的本質意味著缺陷可能難以重現或排查,因為它們取決於特定的時序條件。因此訊息方案需要非常縝密的測試方法。

一、用介面與實作類別把應用程式與訊息實作隔離#

測試單一應用程式比測試「多個以訊息通道連接的分散式應用程式」容易得多:能追完整的執行路徑、不需要複雜的啟動程序、也不必在測試之間清空通道(見 Channel Purger)。

作法是把 messaging gateway 的實作與介面定義分開,就能提供多種實作。因為所有訊息相關邏輯都被封裝在 gateway 裡,介面可以非常簡單:

public interface ICreditBureauGateway
{
    void GetCreditScore(CreditBureauRequest quoteRequest,
                        OnCreditReplyEvent OnCreditResponse, Object ACT);
    void Listen();
}

於是我們能做一個 mock 實作:它不連任何訊息佇列,而是在 GetCreditScore 方法內直接喚起指定的 delegate。

public class MockCreditBureauGatewayImp : ICreditBureauGateway
{
    private Random random = new Random();

    public void GetCreditScore(CreditBureauRequest quoteRequest,
                               OnCreditReplyEvent OnCreditResponse, Object ACT)
    {
          CreditBureauReply reply = new CreditBureauReply();
          reply.CreditScore = (int)(random.Next(600) + 300);
          reply.HistoryLength = (int)(random.Next(19) + 1);
          reply.SSN = quoteRequest.SSN;
          OnCreditResponse(reply, ACT);
    }

    public void Listen() { }
}

這個 mock 含有與真實信用機構相同的邏輯,因此貸款仲介的其餘部分完全察覺不到這次偷天換日

圖 9-25:以 Gateway 隔離應用程式與訊息實作以利測試

圖 9-26:把 Credit Bureau 的介面與實作分離

二、把業務邏輯接進訊息環境之前,先用單元測試測它#

CreditBureau 類別的實作展示了「訊息相關功能(封裝在基底類別)」與「業務邏輯」的乾淨分離。

真實情境中業務邏輯會複雜得多,此時值得getCreditScoregetCreditHistoryLength 搬到一個完全不相依於訊息層的獨立類別(即使沒那麼明顯,繼承仍會把相依性從子類別帶到基底類別與相關類別)。之後就能用 nUnit 這類單元測試工具寫測試,完全不必操心訊息。

三、提供訊息層的 mock 實作,以便同步測試#

ICreditBureauGateway 的 mock 簡單有效,但它把整個 gateway 相關程式碼都換掉了,因此 CreditBureauGatewayImp 得另外測。

若想去掉對訊息佇列的相依(及其效能成本)、但仍執行 CreditBureauGatewayImp 內的程式碼,就改為 mock IMessageReceiverIMessageSender 介面:

public class MockQueue: IMessageSender, IMessageReceiver
{
    private OnMsgEvent onMsg = new OnMsgEvent(DoNothing);

    public void Send(Message msg){
        onMsg(msg);
    }

    private static void DoNothing(Message msg){ }

    public OnMsgEvent OnMessage
    {
        get { return onMsg; }
        set { onMsg = value; }
    }

    public void Begin() { }

    public MessageQueue GetQueue()
    {
        return null;
    }
}

可以看到 Send 不經過任何訊息佇列就立刻觸發 onMsg delegate。要把這個 mock 佇列用在信用機構 gateway 上,得確保回覆的是正確型別的訊息——不能單純把請求訊息當成回覆訊息傳回去;作法可以很簡單,例如用一則預先備好的罐頭回覆訊息。

本範例的限制#

這一節提醒我們:即使是簡單的訊息系統(貸款仲介其實只要做兩件事——取得信用分數、取得最佳銀行報價),也會因為非同步本質與元件間的鬆散耦合而變得相當複雜。

為了把範例塞進書裡,作者仍走了不少捷徑,以下主題未被處理

  • 錯誤處理——目前元件只是把訊息吐到各自的主控台視窗,這對正式系統完全不夠。真實實作應把錯誤訊息路由到中央主控台,以統一方式通知維運人員(見後續章節的系統管理模式,例如 Control Bus)。
  • 交易——本例未使用交易性佇列。若 MessageRouter 在把 4 則報價請求送出 2 則之後崩潰,有些銀行會處理請求、有些不會;若貸款仲介在收齊所有銀行回覆之後、送出回覆給客戶端之前崩潰,客戶端永遠等不到回覆。真實系統中這類動作必須包在交易裡,確保進站訊息在對應的出站訊息送出之前不會被消費掉
  • 執行緒安全——本實作只在單一執行緒中執行。進站佇列上的 BeginReceive(藏在 MessageReceiverGateway 裡)在前一則訊息處理完之前不會被呼叫。這對範例應用綽綽有餘(而且已經比同步實作快很多),但在高吞吐環境中,我們會想用管理多個執行者執行緒的 Message Dispatcher

小結#

本章走過了用非同步訊息佇列與 MSMQ 實作貸款仲介的過程。作者刻意不迴避實作細節,好把建構非同步訊息應用時真正會遇到的問題攤開來,並把焦點放在設計取捨而非廠商專屬 API 上。

這個範例提醒我們實作即使是簡單的訊息應用也有其複雜度:許多在單體應用中理所當然的事(例如喚起一個方法),在非同步訊息中可能需要可觀的編碼工夫

所幸,設計模式為我們提供了一套語言來描述這些設計取捨。