服務站
建置 WCF 路由器,第二篇
Michele Leroux Bustamante

目錄
在 2008 年 4 月號的<服務站>中,我介紹了如何建立簡單的路由器,讓訊息能夠在呼叫用戶端與目標服務之間透明地傳遞。在過程當中,我複習了重要的 Windows® Communication Foundation (WCF) 定址與訊息篩選語意,您也學習到如何設計路由器合約來處理不具型別的訊息,以及如何設定繫結和行為,讓路由器不處理訊息而直接傳遞。本期內容將繼續上述主題的討論,我會進一步探索實用的路由器案例以及實作的詳細資料。
傳遞式路由器的案例
如我在第一篇的文章中所述,在用戶端和服務之間插入傳遞式路由器時,用戶端會與目標服務產生關係,而非路由器。雖然訊息必須透過路由器可以理解的傳輸通訊協定和訊息編碼器來傳送,但是整個訊息的內容 (包括像是安全性標頭和可靠工作階段) 並不會由路由器處理。適用於傳遞式路由器的範例包括負載平衡、以內容為基礎的路由,或訊息的轉換。
負載平衡和跨伺服器資源的工作散佈,最適合網路負載平衡 (Network Load Balancing,NLB),若有硬體負載平衡裝置更好。然而,如果服務是裝載於沒有上述高階功能和裝置的環境中、或服務是安裝在您無法直接控制的實體基礎結構中,或是您需要以網域特定邏輯為基礎的路由,或是應用程式需要容易設定的輕量型路由解決方案,則 WCF 路由器就能夠有效擔任負載平衡的角色。如此的 WCF 路由器可以散佈訊息給裝載於多重處理序中的服務,無論是在同一部電腦上或散佈於多部電腦上。
不管採用的是哪一種散佈模型,負載平衡路由器都需要有一些核心功能。服務必須先向路由器登錄,才能進行負載的散佈。路由器必須能夠判斷服務型別以及關聯的端點,才能夠正確地轉送訊息。路由器必須有散佈負載的演算法,例如傳統的循環配置資源方法或某種類型的具優先順序路由。
有時候服務之間的訊息散佈會根據訊息內容進行處理,而非根據負載平衡。以內容為基礎的路由器通常會檢查訊息的標頭或訊息的主體,以取得路由資訊。例如,來自具備有效授權金鑰之用戶端的訊息,可能會以高優先順序轉送給處理效能較高之大型集區的伺服器,而僅具備試用授權的訊息則會轉送給處理效能較低之小型集區的伺服器。在此案例中,路由器不僅必須知道要將訊息轉送至何處,還要能夠檢查每一則訊息、其標頭或主體內容,以決定要轉送至何處。下列區段將討論支援這些案例的相關路由功能。
使用 Action 標頭轉送
路由器所接收到的訊息會有兩個定址標頭,這有助於將訊息轉送給正確的服務:
To 此標頭會指出端點的名稱。如果標頭與目標服務相符但與路由器不符,就會有一個 URL 指出訊息預定是要給哪一個服務端點。
Action 此標頭會指出訊息預定要給哪一個服務作業,但是有可能並非由有效的 URL 表示。
然而,在大部分的情況下,To 標頭會符合路由器位址而非服務的位址,讓 Action 標頭擔任提供訊息目的地資訊的責任。您應該還記得 Action 標頭衍生自服務合約命名空間、服務合約名稱以及作業名稱。這些資訊就足以讓路由器識別目標服務,假設合約並未跨不同服務型別共用。請參考下列服務合約,每一個都實作於不同的服務型別:
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IServiceA {
[OperationContract]
string SendMessage(string msg);
}
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IServiceB {
[OperationContract]
string SendMessage(string msg);
}
public class ServiceA : IServiceA {...}
public class ServiceB : IServiceB{...}
如 [圖 1] 所示,路由器可以依賴合約命名空間 (適用於各個服務合約) 以及服務端點 (用於轉送訊息) 之間的對應。
圖 1 合約命名空間與服務端點的對應 (按一下影像以放大圖片)
下列顯示初始化的字典,其中的每一個合約命名空間項目都會對應到組態項目,並會指出要使用的正確通道組態設定:
static public IDictionary<string, string> RegistrationList =
new Dictionary<string, string>();
RegistrationList.Add(
"http://www.thatindigogirl.com/samples/2008/01/IServiceA",
"ServiceA");
RegistrationList.Add(
"http://www.thatindigogirl.com/samples/2008/01/IServiceB",
"ServiceB");
初始化通道的程式碼如下所示:
string contractNamespace =
requestMessage.Headers.Action.Substring(0,
requestMessage.Headers.Action.LastIndexOf("/"));
string configurationName =
RouterService.RegistrationList[contractNamespace];
using (ChannelFactory<IRouterService> factory =
new ChannelFactory<IRouterService>(configurationName))
{...}
在此案例中,請注意下列幾項重要的設計相依性:
- 合約至服務的對應通常會存在於資料庫中,以簡化組態並支援多重路由器執行個體。
- 服務合約不能針對多重服務型別實作,除非訊息可以由任何實作合約的服務處理。
- 如果伺服器陣列中有多重服務執行個體,每一個端點的組態就應該要對應至虛擬位址,再由實體負載平衡器進行散佈。
- 除了適用於應用程式服務的訊息以外,並不支援包含 Action 標頭的訊息。
最後一點很重要,因為如果已經為應用程式服務啟用安全工作階段或可靠工作階段,就會在開始傳送實際的應用程式服務訊息之前,先傳送額外的訊息來建立該工作階段。這些訊息會根據相對的通訊協定使用 Action 標頭,完全獨立於任何應用程式服務。這代表訊息的轉送必須使用 Action 標頭以外來替代。
使用自訂標頭轉送
為了確保每一個訊息都包含路由標頭,且能夠正確指出用戶端欲進行通訊的應用程式服務,可以在應用程式服務端點組態區段指定自訂標頭,如下所示:
<service behaviorConfiguration="serviceBehavior"
name="MessageManager.ServiceA">
<endpoint address="http://localhost:8010/RouterService"
binding="wsHttpBinding" bindingConfiguration="wsHttp"
contract="IServiceA" listenUri="ServiceA">
<headers>
<Route
xmlns="http://www.thatindigogirl.com/samples/2008/01">
http://www.thatindigogirl.com/samples/2008/01/IServiceA
</Route>
</headers>
</endpoint>
</service>
自訂標頭具有名稱、命名空間以及值。在某些情況下,標頭較為動態,但在此案例中,標頭是固定的,以代表服務合約命名空間。Route 項目會指出標頭的名稱,而命名空間則是由 xmlns 屬性指出。由於此標頭是端點組態的一部分,所以會包含在服務的中繼資料中。因此,當用戶端產生 Proxy 時,也會產生包含標頭的用戶端組態,如下所示:
<client>
<endpoint address="http://localhost:8010/RouterService"
binding="wsHttpBinding" bindingConfiguration="wsHttp"
contract="localhost.IServiceA" >
<headers>
<Route xmlns="http://www.thatindigogirl.com/samples/2008/01">
http://www.thatindigogirl.com/samples/2008/01/IServiceA
</Route>
</headers>
</endpoint>
</client>
這會使標頭的存在對於用戶端的程式碼變得透明化,並確保所有訊息 (包括用於建立安全工作階段或可靠工作階段的訊息) 都包含該標頭。路由器可以藉由名稱和命名空間,從任何訊息擷取標頭值,如下所示:
string contractNamespace =
requestMessage.Headers.GetHeader<string>(
"Route",
"http://www.thatindigogirl.com/samples/2008/01");
此實作與前一個範例的差異,僅在於路由器得知合約命名空間的方法,亦即改用自訂 Route 標頭而非 Action 標頭。這樣可以讓路由器將安全工作階段或可靠工作階段相關的訊息,轉送給適當的服務端點。
登錄服務
與其為應用程式服務硬式編碼端點,路由器可以為服務公開服務端點,隨著上線與離線進行登錄與解除登錄。如果沒有軟體或硬體的負載平衡器,這可以在應用程式服務必須擴充時,或當通訊埠或電腦名稱的相對端點位址有變更時,有效降低路由器的組態負荷。若要支援這樣的模型,可以採取下列步驟:
- 為路由器實作服務登錄合約,並對防火牆後面的應用程式服務公開端點。
- 為路由器維護一份登錄清單。
- 每當初始化 ServiceHost 時,就向路由器登錄服務端點。
- 隨著各個 ServiceHost 發生錯誤 (Faulted) 或關閉,就向路由器解除登錄服務端點。
[圖 2] 的圖表顯示登錄流程,這會加入項目,亦即存有合約命名空間對應到實體端點位址的項目。
圖 2 向路由器登錄服務 (按一下影像以放大圖片)
採用此方法,登錄時僅需要各個服務端點的合約命名空間和實體位址。[圖 3] 顯示 IRegistrationService 服務合約以及關聯的 RegistrationInfo 詳細資料,這些都會傳遞給路由器以進行登錄與解除登錄。

圖 3 具有資料合約的 IRegistrationService 合約
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IRegistrationService {
[OperationContract]
void Register(RegistrationInfo regInfo);
[OperationContract]
void Unregister(RegistrationInfo regInfo);
}
[DataContract(Namespace =
"http://schemas.thatindigogirl.com/samples/2008/01")]
public class RegistrationInfo {
[DataMember(IsRequired = true, Order = 1)]
public string Address { get; set; }
[DataMember(IsRequired = true, Order = 2)]
public string ContractName { get; set; }
[DataMember(IsRequired = true, Order = 3)]
public string ContractNamespace { get; set; }
public override int GetHashCode() {
return this.Address.GetHashCode() +
this.ContractName.GetHashCode() +
this.ContractNamespace.GetHashCode();
}
}
路由器可以針對每一個合約儲存一個單一項目,但這麼一來每一個合約就無法有一個以上的服務。為了要支援跨多重項目的散佈,路由器應該為每一個登錄使用唯一的機碼。此程式碼會使用字典,針對 RegistrationInfo 執行個體為每一個項目建立雜湊碼的唯一關聯:
// registration list
static public IDictionary<int, RegistrationInfo>
RegistrationList =
new Dictionary<int, RegistrationInfo>();
// to register
if (!RouterService.RegistrationList.ContainsKey(
regInfo.GetHashCode())) {
RouterService.RegistrationList.Add(regInfo.GetHashCode(),
regInfo);
}
// to unregister
if (RouterService.RegistrationList.ContainsKey(
regInfo.GetHashCode())) {
RouterService.RegistrationList.Remove(
regInfo.GetHashCode());
}
當路由器接收到訊息時,應該要蒐集合約命名空間並在字典中尋找符合的項目,如果有超過一個存在,就使用選取準則將訊息轉送給適當的服務端點 (請參閱 [圖 4])。

圖 4 將訊息對應到適當的端點
string contractNamespace =
requestMessage.Headers.Action.Substring(0,
requestMessage.Headers.Action.LastIndexOf("/"));
// get a list of all registered service entries for
// the specified contract
var results = from item in RouterService.RegistrationList
where item.Value.ContractNamespace.Contains(contractNamespace)
select item;
int index = 0;
// find the next address used ...
// create the channel
RegistrationInfo regInfo = results.ElementAt<KeyValuePair<int,
RegistrationInfo>>(index).Value;
Uri addressUri = new Uri(regInfo.Address);
Binding binding = ConfigurationUtility.GetRouterBinding (addressUri.Scheme);
EndpointAddress endpointAddress = new EndpointAddress(regInfo.Address);
ChannelFactory<IRouterService> factory = new
ChannelFactory<IRouterService>(binding, endpointAddress)
// forward message to the service ...
除了可以因應跨電腦服務的負載平衡需求,動態登錄功能還適用於同一部電腦上裝載有多重服務執行個體的情況,如果是裝載於 Windows 服務中,就需要多重通訊埠指派。
若要支援此功能,服務應該要針對該電腦選取動態通訊埠指派。針對 TCP 服務,這可以藉由在端點組態中將接聽 URI 模式設定為 Unique 來完成:
<endpoint address="net.tcp://localhost:9000/ServiceA"
contract=" IServiceA" binding="netTcpBinding"
listenUriMode="Unique"/>
但是,針對具名管道和 HTTP,此設定並不會選取唯一通訊埠,而是會在位址後面附加 GUID:
net.tcp://localhost:64544/ServiceA
http://localhost:8000/ServiceA/66e9c367-b681-4e4f-8d12-80a631b7bc9b
net.pipe://localhost/ServiceA/6660c07e-c9f5-450b-8d40-693ad1a71c6e
若要確保 TCP 和 HTTP 服務端點會有唯一的通訊埠,您可以在程式碼中初始化基底位址或明確端點位址:
Uri httpBase = new Uri(string.Format(
"http://localhost:{0}",
FindFreePort()));
Uri tcpBase = new Uri(string.Format(
"net.tcp://localhost:{0}",
FindFreePort()));
Uri netPipeBase = new Uri(string.Format(
"net.pipe://localhost/{0}",
Guid.NewGuid().ToString()));
ServiceHost host = new ServiceHost(typeof(ServiceA),
httpBase, tcpBase, netPipeBase);
[圖 5] 顯示在同一部電腦上裝載的多重服務,向路由器登錄的情況。此圖表亦顯示為了要移除路由器的單點失敗問題,可能還是需要軟體或實體的負載平衡器,以便在執行個體之間散佈登錄呼叫。當然,這也隱含登錄清單會儲存在共用資料庫中。
圖 5 透過負載平衡路由器登錄具有動態通訊埠的服務 (按一下影像以放大圖片)
檢查訊息
雖然路由器通常會將原始訊息轉送給應用程式服務,它們也可能根據訊息的內容執行活動,例如針對以內容為基礎的路由檢查標頭或主體項目,或是根據標頭或主體項目的有效性拒絕訊息。
檢查標頭是一件平凡的事,因為 Message 型別會公開 Headers 屬性,以直接擷取定址標頭並透過名稱和命名空間擷取自訂標頭。請參考下列服務作業,其中會針對傳入作業使用訊息合約來加入自訂 LicenseKey 標頭:
// operation
[OperationContract]
SendMessageResponse SendMessage(SendMessageRequest message);
// message contract
[MessageContract]
public class SendMessageRequest {
[MessageHeader]
public string LicenseKey { get; set; }
[MessageBodyMember]
public string Message { get; set; }
}
用戶端會傳送包含 LicenseKey 標頭的訊息,如果沒有授權金鑰則標頭會是空的。路由器可以擷取此標頭,方法如下:
string licenseKey =
requestMessage.Headers.GetHeader<string>(
"LicenseKey",
"http://www.thatindigogirl.com/samples/2008/01");
如果相同的 LicenseKey 值置於訊息主體內進行傳遞,路由器就必須讀取訊息主體才能存取該值 (因為此資訊無法直接透過 Message 型別取得)。GetReaderAtBodyContents 方法會傳回 XmlDictionaryReader,這可以用來讀取訊息主體,如下所示:
XmlDictionaryReader bodyReader =
requestMessage.GetReaderAtBodyContents();
Message 的 State 屬性可以是下列 MessageType 列舉值之一:Created、Copied、Read、Written 或 Closed。訊息會以 Created 狀態開始,針對作業接收 Message 參數的路由器並不會處理訊息,因此狀態會維持為 Created。
讀取訊息主體會造成要求訊息從 Created 變成 Read 的狀態。讀取之後,這無法轉送給應用程式服務,因為訊息只能讀取一次、寫入一次,或複製一次。
讀取訊息之前,以內容為基礎的路由器實作應該將訊息複製到緩衝區。使用緩衝的訊息複本,就可以建立並使用原始訊息的新複本進行處理,如下所示:
MessageBuffer messageBuffer =
requestMessage.CreateBufferedCopy(int.MaxValue);
Message messageCopy = messageBuffer.CreateMessage();
XmlDictionaryReader bodyReader =
messageCopy.GetReaderAtBodyContents();
XmlDocument doc = new XmlDocument();
doc.Load(bodyReader);
XmlNodeList elements = doc.GetElementsByTagName("LicenseKey");
string licenseKey = elements[0].InnerText;
同一個緩衝區可以再次使用,以建立訊息來轉送給應用程式服務。呼叫 CreateMessage 會根據原始訊息傳回新的 Message 執行個體。
路由器和傳輸工作階段
在傳遞式路由器的情況下,用戶端必須使用路由器預期的傳輸通訊協定和編碼格式來傳送訊息,而路由器則必須使用應用程式服務預期的傳輸通訊協定和編碼格式來轉送訊息給應用程式服務。到目前為止所討論的路由功能,只要兩個端點都是 HTTP (無論有沒有工作階段) 就很好用。然而,當您引入傳輸工作階段 (例如 TCP) 時,就會出現一些有趣的難題。在極簡單的情況下,亦即已停用安全性且沒有可靠工作階段時,一切都還沒有問題,但是一旦加入這些功能,就會產生新的難題。
為應用程式服務啟用安全性之後,路由器就必須提供簽署的 To 標頭。通常這表示 To 標頭不需要變更 (可保留由用戶端傳送的原貌),但是根據預設,路由器在傳送訊息時,會修改 To 標頭以符合服務的位址,除非已啟用手動定址功能。舉例而言,如果路由器使用 TCP 通訊協定來轉送訊息給服務,且傳出通道是以要求-回覆合約為基礎,就不允許手動定址。
如果已啟用可靠工作階段且路由器使用 TCP 通訊協定來呼叫服務,就會產生另一個問題。在此情況下,非同步通知就會透過路由器傳回。因此,路由器就需要維持與服務的工作階段,來接收非同步通知。如此一來,用戶端就必須維持與路由器的雙工工作階段,才能接收到上述非同步通知。
藉由實作可支援工作階段且依賴雙工傳入與傳出通道的路由器,就可以解決這兩個問題的部分難題。無論是呼叫的用戶端或應用程式服務,都不需要直接知道流程的細節,這只是路由器本身的實作細節。然而,這還是會依賴工作階段感知的繫結,而且引入非同步可靠工作階段通知時還會依賴雙工通訊。
雙工路由器
[圖 6] 中的程式碼顯示雙工路由器合約的範例,這是為了支援在用戶端、路由器和應用程式服務之間透過 TCP 傳送訊息的情況。這與傳統的要求-回覆路由器合約的差異如下:
- ProcessMessage 現在已變成單向作業。
- 服務合約需要工作階段,且具有關聯的回呼合約。您必須注意的是,這並不會要求用戶端實作回呼;這只會影響路由器內部。
- 回呼合約有一個單向方法,可以接收來自路由器呼叫應用程式服務的回應。另請注意,服務並不會察覺回應是傳送到回呼通道;它們可以是要求-回覆訊息。

圖 6 雙工路由器合約
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01",
SessionMode = SessionMode.Required,
CallbackContract = typeof(IDuplexRouterCallback))]
public interface IDuplexRouterService {
[OperationContract(IsOneWay=true, Action = "*")]
void ProcessMessage(Message requestMessage);
}
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01",
SessionMode = SessionMode.Allowed)]
public interface IDuplexRouterCallback {
[OperationContract(IsOneWay=true, Action = "*")]
void ProcessMessage(Message requestMessage);
}
服務會接收到要求並傳送非同步回覆,由路由器的回呼通道接收。此回呼通道則會使用用戶端的回呼通道,來傳送回應給用戶端。從頭到尾,作業都會採用同步的行為,但是路由器會解除活動的聯繫性,並依賴基底的接收和傳送通道的雙工通訊,來建立訊息的相互關聯。
路由器服務執行個體、用戶端回呼通道參考以及路由器回呼通道,在工作階段與用戶端進行通訊的期間,都會持續存留。因此,路由器必須公開支援工作階段的端點,且下游服務也必須支援工作階段,才能順利運作。
在某些情況下,讓用戶端透過 HTTP 傳送訊息給路由器,再由路由器透過 TCP 轉送訊息給應用程式服務,可能會比較理想。啟用安全性功能或可靠工作階段時,就算是雙工路由器組態也不足以支援這樣的情況。
如前所述,唯有要求-回覆通道才支援手動定址。否則,服務模型就會依賴定址功能來建立訊息的相互關聯。由於 TCP 原生並不支援要求-回覆,所以除非合約是單向的,否則無法手動定址。因此,[圖 7] 的傳送通道必須從單向合約 (例如 IDuplexRouterService) 建立。提供的回呼通道是用來接收回應。
路由器的回呼通道必須保持存留,直到回應已傳送,同樣地,用戶端的回呼通道也必須保持存留。為了要支援這樣的情況,用戶端必須有一個與路由器的工作階段,且路由器必須有與服務的工作階段。
假設路由器所呼叫的應用程式服務是安全的,也可能需要手動定址,以轉送不會由路由器更動的訊息。如果路由器透過 TCP 呼叫應用程式服務,就需要雙工路由器實作 (如前所述),好讓傳出呼叫變成單向通道。這會強迫用戶端透過工作階段感知的繫結來傳送訊息,這代表要啟用透過 HTTP 的安全工作階段或可靠工作階段。
如果路由器是傳遞式路由器,則重點是要讓應用程式服務處理安全和可靠工作階段的標頭。如果路由器的用戶端端點需要安全工作階段或可靠工作階段以支援透過 HTTP 的工作階段,路由器就會處理標頭,且就不會與應用程式服務建立工作階段。
因此,混合通訊協定僅適用於有限的情況,除非您使用較低階層的通道以覆寫預設行為。停用安全和可靠工作階段時,用戶端可以透過 HTTP 傳送訊息給路由器,再由路由器透過 TCP 轉送給應用程式服務。如果已啟用安全或可靠工作階段,用戶端必須透過 TCP 傳送訊息給路由器,才能夠在沒有為路由器通道啟用可靠工作階段或安全工作階段的情況下建立工作階段。