在 2008 年 4 月的“服務站”部分中,我向您介紹了如何創建簡單的路由器,以在調用客 戶端與目標服務之間實現消息的透明流動。在此過程中,我回顧了重要的 Windows® Communication Foundation (WCF) 尋址和消息篩選語義,您可以了解到如何設計路由器約定使其處理非類型化消息,以 及如何配置綁定和行為才能允許消息在不經路由器處理的情況下進行傳遞。在本期中,我將繼續討論該話 題,介紹對路由器采用更實用的方案時涉及的更多實現細節。
傳遞路由器方案
在第 1 部分中我已經提到,將傳遞路由器插入到客戶端和服務之間時,客戶 端的關系是與目標服務而不是路由器的關系。盡管必須要使用路由器可以理解的傳輸協議和消息編碼器發 送消息,但消息的全部內容(包括安全性標頭和可靠的會話等內容)並不是由路由器處理的。可能會應用 傳遞路由器的幾種情況有負載平衡、基於內容的路由或消息傳輸。
服務器資源的負載平衡和工作分配非常適合網絡負載平衡 (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 屬性指示命名 空間。由於已將此標頭指定為端點配置的一部分,因此它包含在服務的元數據中。因此,客戶端在生成代 理的同時,還會生成包括標頭的客戶端配置,如下所示:
<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 出現錯誤或關閉時,取消向路由器注冊服務端點。
圖 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);
}
雙工路由器的體系結構已在圖 7 中說明。就客戶端而言,將發送請求並等待同步答復。路由器接收進 行單向操作的請求並保存客戶端的回調信道,以便發送答復。同時,路由器使用雙工信道轉發消息並提供 回調信道接收來自服務的答復。
圖 7 雙工路由器體系結構
服務可接收請求,並發送由路由器的回調信道接收的同步答復。然後,此回調信道使用客戶端回調信 道將響應發送回客戶端。自始至終,操作一直以同步方式進行,但是路由器會將活動分離,並在基本的接 收和發送信道中使用雙工通信將消息關聯起來。
這一操作的路由器實現顯示在圖 8 中。請求-答復路由器實現中有幾項相關的更改。首先,該路由器 支持會話並實現了一個雙工約定。該路由器將消息轉發到服務時,會使用 DuplexChannelFactory<T> 創建一個雙工信道,這意味著提供了一個用於接收服務響應的回調對象 。
圖 8 雙工路由器實現
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession, ConcurrencyMode = ConcurrencyMode.Multiple, AddressFilterMode=AddressFilterMode.Any, ValidateMustUnderstand=false)] public class DuplexRouterService : IDuplexRouterService, IDisposable { object m_duplexSessionLock = new object(); IDuplexRouterService m_duplexSession; public void ProcessMessage(Message requestMessage) { lock (this.m_duplexSessionLock) { if (this.m_duplexSession == null) { IDuplexRouterCallback callback = OperationContext.Current.GetCallbackChannel <IDuplexRouterCallback>(); DuplexChannelFactory<IDuplexRouterService> factory = new DuplexChannelFactory<IDuplexRouterService> (new InstanceContext(null, new DuplexRouterCallback(callback)), "serviceEndpoint"); factory.Endpoint.Behaviors.Add(new MustUnderstandBehavior(false)); this.m_duplexSession = factory.CreateChannel(); } } this.m_duplexSession.ProcessMessage(requestMessage); } public void Dispose() { if (this.m_duplexSession != null) { try { ICommunicationObject obj = this.m_duplexSession as ICommunicationObject; if (obj.State == CommunicationState.Faulted) obj.Abort(); else obj.Close(); } catch {} } } } public class DuplexRouterCallback : IDuplexRouterCallback { private IDuplexRouterCallback m_clientCallback; public DuplexRouterCallback(IDuplexRouterCallback clientCallback) { m_clientCallback = clientCallback; } public void ProcessMessage(Message requestMessage) { this.m_clientCallback.ProcessMessage(requestMessage); } }
回調對象將實現回調約定,並接收來自服務的響應。此回調對象必須使用客戶端回調信道將響應返回 客戶端。
路由器服務實例、客戶端回調信道引用和路由器回調信道在與客戶端進行會話期間都會一直存在。為 此,路由器必須公開支持會話的端點,並且下游服務必須支持會話,才能奏效。
混合傳輸會話
在某些情況下,客戶端最好通過 HTTP 將消息發送到路由器,而路由器通過 TCP 將這些消息轉發到應 用程序服務。啟用安全功能或可靠會話時,即使是雙工路由器配置也不足以支持此方案。
如前所述,只有請求-答復信道才支持手動尋址。否則,服務模型將依賴尋址功能來關聯消息。因為 TCP 並不對請求-答復提供本機支持,所以手動尋址也不可取,除非約定是單向的。因此,圖 7 中的發送 信道必須基於單向約定(如 IDuplexRouterService)創建。提供回調信道是為了接收響應。
路由器的回調信道在發送響應之前同樣必須保持活動狀態,而客戶端的回調信道也必須保持活動狀態 。要對此提供支持,客戶端必須建立與路由器的會話,並且路由器必須建立與服務的會話。
假設路由器調用的應用程序服務是安全的,那麼轉發未經路由器處理的消息可能需要手動尋址。如果 路由器通過 TCP 調用應用程序服務,則需要前面討論的雙工路由器實現,以便傳出調用是單向信道。這 可強制客戶端通過會話感知綁定發送消息,這意味著將啟用基於 HTTP 的安全會話或可靠會話。
如果路由器是傳遞路由器,則重點是讓應用程序服務處理安全和可靠會話標頭。如果路由器需要建立 與其客戶端端點的安全會話或可靠會話以便支持基於 HTTP 的會話,則路由器將處理這些標頭,並且不會 建立與應用程序服務的會話。
因此,混合協議僅適用於有限的情形,除非到達信道層的更低層來覆蓋默認行為。禁用安全會話和可 靠會話時,客戶端可以通過 HTTP 將消息發送到路由器,同時路由器通過 TCP 將這些消息轉發到應用程 序服務。如果啟用了安全會話或可靠會話,則客戶端必須通過 TCP 將消息發送到路由器,以便在不啟用 路由器信道的可靠會話或安全會話的情況下建立會話。
請將您想詢問的問題和提出的意見發送至 [email protected]。
代碼下載位置: ServiceStation2008_06.exe (340 KB)