本節用 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 類別的實作展示了「訊息相關功能(封裝在基底類別)」與「業務邏輯」的乾淨分離。
真實情境中業務邏輯會複雜得多,此時值得把
getCreditScore與getCreditHistoryLength搬到一個完全不相依於訊息層的獨立類別(即使沒那麼明顯,繼承仍會把相依性從子類別帶到基底類別與相關類別)。之後就能用 nUnit 這類單元測試工具寫測試,完全不必操心訊息。
三、提供訊息層的 mock 實作,以便同步測試#
ICreditBureauGateway 的 mock 簡單有效,但它把整個 gateway 相關程式碼都換掉了,因此 CreditBureauGatewayImp 得另外測。
若想去掉對訊息佇列的相依(及其效能成本)、但仍執行 CreditBureauGatewayImp 內的程式碼,就改為 mock IMessageReceiver 與 IMessageSender 介面:
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不經過任何訊息佇列就立刻觸發onMsgdelegate。要把這個 mock 佇列用在信用機構 gateway 上,得確保回覆的是正確型別的訊息——不能單純把請求訊息當成回覆訊息傳回去;作法可以很簡單,例如用一則預先備好的罐頭回覆訊息。
本範例的限制#
這一節提醒我們:即使是簡單的訊息系統(貸款仲介其實只要做兩件事——取得信用分數、取得最佳銀行報價),也會因為非同步本質與元件間的鬆散耦合而變得相當複雜。
為了把範例塞進書裡,作者仍走了不少捷徑,以下主題未被處理:
- 錯誤處理——目前元件只是把訊息吐到各自的主控台視窗,這對正式系統完全不夠。真實實作應把錯誤訊息路由到中央主控台,以統一方式通知維運人員(見後續章節的系統管理模式,例如 Control Bus)。
- 交易——本例未使用交易性佇列。若 MessageRouter 在把 4 則報價請求送出 2 則之後崩潰,有些銀行會處理請求、有些不會;若貸款仲介在收齊所有銀行回覆之後、送出回覆給客戶端之前崩潰,客戶端永遠等不到回覆。真實系統中這類動作必須包在交易裡,確保進站訊息在對應的出站訊息送出之前不會被消費掉。
- 執行緒安全——本實作只在單一執行緒中執行。進站佇列上的
BeginReceive(藏在MessageReceiverGateway裡)在前一則訊息處理完之前不會被呼叫。這對範例應用綽綽有餘(而且已經比同步實作快很多),但在高吞吐環境中,我們會想用管理多個執行者執行緒的 Message Dispatcher。
小結#
本章走過了用非同步訊息佇列與 MSMQ 實作貸款仲介的過程。作者刻意不迴避實作細節,好把建構非同步訊息應用時真正會遇到的問題攤開來,並把焦點放在設計取捨而非廠商專屬 API 上。
這個範例提醒我們實作即使是簡單的訊息應用也有其複雜度:許多在單體應用中理所當然的事(例如喚起一個方法),在非同步訊息中可能需要可觀的編碼工夫。
所幸,設計模式為我們提供了一套語言來描述這些設計取捨。