在第 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,如下所示:
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 将消息发送到路由器,以便在不启用路由器信道的可靠会话或安全会话的情况下建立会话。