终结点是整个WCF的核心,由经典的ABC三要素组成。作为表示地址的EndpointAddress,很多人仅仅将其看成是一个表示标识服务并且表示服务所在地址的Uri,其实服务标识和定位服务仅仅是EndpointAddress一个基本的功能,它不仅仅是Uri那么简单
一、EndpointAddress的三个功能
作为终结点的三要素之一的地址(Address),在基于WCF的通信中不仅仅定位着服务的位置,而且还提供额外的寻址信息。除此之外,终结点地址还和安全有关系,因为它包含着用于进行服务认证的服务身份信息。这三个典型功能(服务标识/定位、辅助寻址和服务身份标识)分别对应着Uri、Headers和Identity三个只读属性:
1: public class EndpointAddress
2: {
3: //其他成员
4: public Uri Uri { get; }
5: public AddressHeaderCollection Headers { get; }
6: public EndpointIdentity Identity { get; }
7: }
EndpointAddress的属性Uri通过一个统一资源标识符(URI:Uniform Resource Identifier)既作为服务的唯一标识,也作为服务的目标地址。这个地址可以是服务的物理地址,也可以是逻辑地址。
而类型为EndpointIdentity的Identity属性服务的身份,被客户端用于针对服务的认证。具体来说,客户端终结点通过地址的该属性表示自己希望调用服务的真实身份。在调用之前,服务端将自己的凭证(Windows凭证、X.509证书凭证等)提供给客户端。客户端通过整个以EndpointIdentity对象代表的服务身份与凭证进行比较从而验证正在调用服务确实是自己所希望调用的,而不是一个钓鱼服务。关于基于EndpointIdentity的服务认证,在《服务凭证(Service Credential)与服务身份(Service Identity)》中有详细的介绍。
属性Headers是一个类型为AddressHeaderCollection的集合。其元素是一个代表地址报头的AddressHeader对象。EndpointAddress通过以Headers属性代表的地址报头列表存放一些寻址的信息。本篇文章着重讲述地址报头。
WCF的通信完全建立在消息交换上,而WCF支持多种不同类型的消息。消息的格式可以使基于XML的,也可以是非XML的(比如采用JSON格式的消息)。而我们使用的最多地XML消息类型是SOAP。一个完整的SOAP消息由一个消息主体(Body)和一组消息报头(Header)组成。主体部分一般是对业务数据的封装,而消息报头用于保存一些控制信息。
对于客户端来说,终结点地址上的AddressHeader列表最终都会被添加到请求消息(这里指SOAP消息)的报头集合中。而针对服务端来说,在根据请求消息进行终结点路由过程中,会提取相应的报头信息和本地终结点的地址报头进行比较以选择出于请求消息相匹配的终结点。
二、AddressHeader和AddressHeaderCollection
我们先来看看表示单个地址报头的AddressHeader对象。如下面的代码片断所示,AddressHeader实际上是个抽象类。实际上WCF并没有定义继承AddressHeader的公有子类(AddressHeader所有具体的子类都是内部类型),我们只能通过定义在AddressHeader中的三个CreateHeader方法来创建AddressHeader对象。
1: public abstract class AddressHeader
2: {
3: //其他成员
4: public static AddressHeader CreateAddressHeader(object value);
5: public static AddressHeader CreateAddressHeader(object value, XmlObjectSerializer serializer);
6: public static AddressHeader CreateAddressHeader(string name, string ns, object value);
7:
8: public T GetValue<T>();
9: public T GetValue<T>(XmlObjectSerializer serializer);
10: public MessageHeader ToMessageHeader();
11:
12: public abstract string Name { get; }
13: public abstract string Namespace { get; }
14: }
CreateAddressHeader方法接受一个可序列化的对象作外参数,它会对指定对象进行序列化并将序列化后的内容作为地址报头的内容。默认采用的序列化器类型是DataContractSerializer,我们也可以调用第二个重载认为地指定序列化器。除了提供可序列化对象作为地址报头的内容之外,我们还可以调用第三个CreateAddressHeader方法重载直接指定一个字符串作为创建的地址报头的值。AddressHeader的值可以通过调用GetValue<T>方法返回。该方法的执行涉及到对报头值得反序列化,所以需要指定相应的序列化器。默认情况下采用DataContractSerializer。
AddressHeader对象最终需要转换成SOAP消息的报头,而SOAP报头具有自己的名称和命名空间。当我们调用第三个CreateAddressHeader方法重载的时候,除了传入作为报头值得字符串之外,还需要传输名称和命名空间。而传输的名称和命名空间可以通过只读属性Name和Namespace返回。针对可序列化对象创建的AddressHeader对象,其属性Name和Namespace返回的是对象序列化后生成的XML的根节点的名称和命名空间。
消息报头通过System.ServiceModel.Channels.MessageHeader类型表示。AddressHeader向MessageHeader的转换可以直接通过调用ToMessageHeader方法实现。AddressHeaderCollection表示AddressHeader的集合。如下面的代码片断所示,AddressHeaderCollection继承自ReadOnlyCollection<AddressHeader>,所以该集合是只读的。
1: public sealed class AddressHeaderCollection:ReadOnlyCollection<AddressHeader>
2: {
3: //其他成员
4: public AddressHeaderCollection();
5: public AddressHeaderCollection(IEnumerable<AddressHeader> addressHeaders);
6:
7: public void AddHeadersTo(Message message);
8: public AddressHeader[] FindAll(string name, string ns);
9: public AddressHeader FindHeader(string name, string ns);
10: }
通过AddHeadersTo方法可以很容易地将一个AddressHeaderCollection对象添加到一个代表消息的Message对象的报头列表中。FindAll和FindHeader根据报头的名称和命名空间找到对应的AddressHeader。FindAll得到所有相关的AddressHeader,而FindHeader只获得满足条件的第一个AddressHeader。
由于EndpointAddress的Headers属性代表的是一个只读的集合,我们不能直接将创建的AddressHeader添加到该集合中。所以地址报头只能在创建EndpointAddress的时候通过构造函数参数的方式指定。如下面的代码片断所示,EndpointAddress的四个构造函数中,既提供了作为可选参数的addressHeaders,又提供类型为AddressHeaderCollection的headers参数。它们都表示为EndpointAddress添加的AddressHeader列表。
1: public class EndpointAddress
2: {
3: //其他成员
4: public EndpointAddress(Uri uri, params AddressHeader[] addressHeaders);
5: public EndpointAddress(Uri uri, EndpointIdentity identity, params AddressHeader[] addressHeaders);
6: public EndpointAddress(Uri uri, EndpointIdentity identity, AddressHeaderCollection headers);
7: public EndpointAddress(Uri uri, EndpointIdentity identity, AddressHeaderCollection headers, XmlDictionaryReader metadataReader,
8: XmlDictionaryReader extensionReader);
9: }
三、终结点地址报头的指定
在进行服务寄宿的时候,我们可以为添加的终结点地址指定一个或者多个AddressHeader。而客户端在通过指定EndpointAddress对象创建ChannelFactory<TChannel>或者ClientBase<TChannel>对象的时候,都可以为终结点地址指定相应的地址报头。在下面的代码中,我们为寄宿的CalculatorService添加了一个基于WSHttpBinding的终结点。而终结点地址具有一个值为“Licensed User”的地址报头,表示许可用户才能调用该终结点。
1: using (ServiceHost host = new ServiceHost(typeof(CalculatorService)))
2: {
3: Uri uri = new Uri("http://127.0.0.1:3721/calculatorservice");
4: AddressHeader header = AddressHeader.CreateAddressHeader("Licensed User", "http://www.artech.com", "UserType");
5: EndpointAddress address = new EndpointAddress(uri, header);
6: Binding binding = new WS2007HttpBinding();
7: ContractDescription contract = ContractDescription.GetContract(typeof(ICalculator));
8: ServiceEndpoint endpoint = new ServiceEndpoint(contract, binding, address);
9: host.AddServiceEndpoint(endpoint);
10: host.Open();
11: //...
12: }
终结点地址报头同样可以通过配置的方式来定义。一个通过AddressHeader对象最终体现为一个XML元素。不论是服务端终结点配置节(<services>/<service>/<endpoint>),还是客户端终结点配置节(<client>/<endpoint>)都具有一个<headers>子节点。你可以在该节点中定义任意的XML作为该终结点的地址报头列表。
1: <configuration>
2: <system.serviceModel>
3: <services>
4: <service ...>
5: <endpoint ...>
6: <headers>
7: <服务终结点地址报头>
8: </headers>
9: </endpoint>
10: </service>
11: </services>
12: <client>
13: <endpoint ...>
14: <headers>
15: <客户端终结点地址报头>
16: </headers>
17: </endpoint>
18: </client>
19: </system.serviceModel>
20: </configuration>
上面通过编程方式指定的地址报头就可以通过如下一段配置来指定。而<headers>结点下的<UserType>元素就是通过编程方式指定的AddressHeader的值序列化后的XML。
1: <configuration>
2: <system.serviceModel>
3: <services>
4: <service ...>
5: <endpoint ...>
6: <headers>
7: <UserType xmlns=" http://www.artech.com ">Licensed User</UserType>
8: </headers>
9: </endpoint>
10: </service>
11: </services>
12: </system.serviceModel>
13: </configuration>
服务端和客户端终结点的地址报头具有不同的作用,服务端终结点的地址报头主要用于辅助实现对终结点的选择。由于一个服务可以具有多个终结点,服务端在接收到请求消息后需要将其分发给匹配的终结点。WCF通过消息筛选机制实现基于请求消息对匹配终结点的选择。在默认情况下,WCF采用基于地址匹配的消息筛选策略。由于消息(SOAP)具有一个<To>报头表示调用服务的地址,被选择的终结点的地址必须具有相匹配的Uri。其次,如果终结点地址具有相应的地址报头,要求请求消息具有相应的报头。只有满足这两个条件的终结点才会最终被选择用于处理请求消息。
如果客户端终结点地址指定了相应的地址报头,最终发送的消息将包含一个相应的报头。比如说针对下面一段进行服务调用的代码,创建ChannelFactory<TChannel>针对的终结点具有一个“Licensed User”地址报头。最终生成的SOAP消息将具有一个<UserType>报头。
服务调用代码:
1: Uri uri = new Uri("http://127.0.0.1:3721/calculatorservice");
2: AddressHeader header = AddressHeader.CreateAddressHeader("Licensed User","http://www.artech.com", "UserType");
3: EndpointAddress address = new EndpointAddress(uri, header);
4: Binding binding = new WS2007HttpBinding();
5: ContractDescription contract = ContractDescription.GetContract(typeof(ICalculator));
6: ServiceEndpoint endpoint = new ServiceEndpoint(contract, binding, address);
7:
8: using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>(endpoint))
9: {
10: ICalculator calculator = channelFactory.CreateChannel();
11: double result = calculator.Divide(1, 2);
12: //...
13: }
14:
SOAP消息:
1: <s:Envelope ...>
2: <s:Header>
3: <UserType xmlns="http://www.artech.com/">Licensed User</UserType>
4: ...
5: </s:Header>
6: <s:Body>
7: ...
8: </s:Body>
9: </s:Envelope>
由于客户端终结点地址报头的最终目的是为请求消息添加添加相应的报头,所以如果我直接在消息上以手工的方式添加相应的报头也能得到相同的效果。如果要实现消息报头的手工添加,首选需要解决的是如何获得请求消息。当前的请求消息可以通过表示操作指定上下文的OperationContext对象获取。如下面的代码所示,OperationContext具有IncomingMessageHeaders和OutgoingMessageHeaders两个类型为System.ServiceModel.Channels.MessageHeaders的属性,分别表示如栈消息和出栈消息的报头列表。对于客户端来说,所谓入栈消息和出栈消息就是指的回复消息和请求消息,而对于服务端则正好相反。OperationContext中的静态Current属性表示当前的操作调用/执行上下文。
1: public sealed class OperationContext : ...
2: {
3: //其他成员
4: public MessageHeaders IncomingMessageHeaders { get; }
5: public MessageHeaders OutgoingMessageHeaders { get; }
6: public static OperationContext Current { get; set; }
7: }
倘若客户端终结点不曾定义<UserType>地址报头,但是服务端却要求请求消息必须具有这么一个消息报头,那么可以可以通过如下的编程方式将创建的AddressHeader手工地添加到请求消息的报头集合中。
1: Uri uri = new Uri("http://127.0.0.1:3721/calculatorservice");
2: AddressHeader header = AddressHeader.CreateAddressHeader("Licensed User", "http://www.artech.com", "UserType");
3: using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>("calculatorservice"))
4: {
5: ICalculator calculator = channelFactory.CreateChannel();
6: using (OperationContextScope operationContextScope = new OperationContextScope(calculator as IContextChannel))
7: {
8: OperationContext.Current.OutgoingMessageHeaders.Add(header.ToMessageHeader());
9: double result = calculator.Divide(1, 2);
10: //...
11: }
12: }
现在我们通过一个实例来演示终结点的地址报头如何影响实现终结点选择的消息筛选机制。这个实例通过为服务端终结点指定地址报头实现针对客户端的授权,让经过许可的客户端才能访问这个服务。具体来说,我们将一个代码序列号的GUID作为终结点的地址报头。对于客户端发送的消息,只有具有相应的报头才能访问服务。
现在我们通过一个实例来演示终结点的地址报头如何影响实现终结点选择的消息筛选机制。这个实例通过为服务端终结点指定地址报头实现针对客户端的授权,让经过许可的客户端才能访问这个服务。具体来说,我们将一个代码序列号的GUID作为终结点的地址报头。对于客户端发送的消息,只有具有相应的报头才能访问服务。[三个实例源代码下载地址:实例1、实例2和实例3]
一、无地址报头下服务调用(实例1)
我们采用计算服务的例子,整个实例的解决方案具有右图所示的3个项目。其中类库项目Service.Interface用于定义契约接口。Service项目是一个控制台应用程序,用于定义服务类型和作为服务的宿主。控制台应用程序Client代码进行服务调用的客户端。在本书后续部分的绝大部分实例都会采用这个结构。
实例演示的目的旨在旨在指导读者编程,或者说明某个方面的原理,所以我会将服务承载的业务功能尽量地简化。所以我们分别在Service.Interface和Service项目中定义了如下所示的契约接口ICalculator和服务类型CalculatorService。ICalculator仅仅具有唯一的表示加法运算的Add操作。
ICalculator:
1: using System.ServiceModel;
2: namespace Artech.WcfServices.Service.Interface
3: {
4: [ServiceContract(Name = "CalculatorService", Namespace ="http://www.artech.com/")]
5: public interface ICalculator
6: {
7: [OperationContract]
8: double Add(double x, double y);
9: }
10: }
CalculatorService:
1: using Artech.WcfServices.Service.Interface;
2: namespace Artech.WcfServices.Service
3: {
4: public class CalculatorService : ICalculator
5: {
6: public double Add(double x, double y)
7: {
8: return x + y;
9: }
10: }
11: }
服务CalculatorService通过控制台程序Service进行寄宿。下面是服务寄宿代码和相应的配置。从配置可以看到,服务唯一的终结点具有一个作为地址报头的<sn>元素,它的值代表服务的序列号。
服务寄宿程序:
1: using System;
2: using System.ServiceModel;
3: namespace Artech.WcfServices.Service
4: {
5: class Program
6: {
7: static void Main(string[] args)
8: {
9: using (ServiceHost host = new ServiceHost(typeof(CalculatorService)))
10: {
11: host.Open();
12: Console.Read();
13: }
14: }
15: }
16: }
配置:
1: <configuration>
2: <system.serviceModel>
3: <services>
4: <service name="Artech.WcfServices.Service.CalculatorService">
5: <endpoint address="http://127.0.0.1:3721/calculatorservice"
6: binding="ws2007HttpBinding"
7: contract="Artech.WcfServices.Service.Interface.ICalculator">
8: <headers>
9: <sn xmlns="http://www.artech.com/">
10: {DDA095DA-93CA-49EF-BE01-EF5B47179FD0}
11: </sn>
12: </headers>
13: </endpoint>
14: </service>
15: </services>
16: </system.serviceModel>
17: </configuration>
客户端通过ChannelFactory<TChannel>创建的服务代理进行服务调用。下面是进行服务调用的程序和客户端配置。
服务调用程序:
1: using System;
2: using System.ServiceModel;
3: using Artech.WcfServices.Service.Interface;
4: namespace Artech.WcfServices.Client
5: {
6: class Program
7: {
8: static void Main(string[] args)
9: {
10: using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>("calculatorservice"))
11: {
12: ICalculator calculator = channelFactory.CreateChannel();
13: Console.WriteLine("x + y = {2} when x = {0} and y = {1}", 1, 2,calculator.Add(1,2));
14: }
15: Console.Read();
16: }
17: }
18: }
配置:
1: <configuration>
2: <system.serviceModel>
3: <client>
4: <endpoint name="calculatorservice"
5: address="http://127.0.0.1:3721/calculatorservice"
6: binding="ws2007HttpBinding"
7: contract="Artech.WcfServices.Service.Interface.ICalculator"/>
8: </client>
9: </system.serviceModel>
10: </configuration>
由于进行服务调用的客户端终结点并没有一个相应的表示序列号的<sn>地址报头,在进行服务调用的时候没有显式地将序列号作为报头添加到请求消息中,所以针对服务端来说,这是一个不被许可的客户端。客户端运行后将会抛出如下图所示的EndpointNotFoundException异常。(S201)
二、为请求消息添加地址报头(实例2)
假设服务端将作为序列化的GUID分发给经过许可的客户端,那么它就可以将其作为客户端终结点的地址报头定义到配置文件中,也可以在消息发送之前将序列化作为报头添加到请求消息中。第一种方式比较简单,我们来演示第二种方式。我们采用如下的代码进行服务调用,在调用之前将序列号作为报头添加到请求消息的报头列表中。在这种情况下,服务嗲用将会顺利进行。(S202)
1: using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>("calculatorservice"))
2: {
3: ICalculator calculator = channelFactory.CreateChannel();
4: using (OperationContextScope contextScope = new OperationContextScope(calculator as IClientChannel))
5: {
6: string sn = "{DDA095DA-93CA-49EF-BE01-EF5B47179FD0}";
7: string ns = "http://www.artech.com/";
8: AddressHeader addressHeader = AddressHeader.CreateAddressHeader("sn", ns, sn);
9: MessageHeader messageHeader = addressHeader.ToMessageHeader();
10: OperationContext.Current.OutgoingMessageHeaders.Add(messageHeader);
11: Console.WriteLine("x + y = {2} when x = {0} and y = {1}", 1, 2, calculator.Add(1, 2));
12: }
13: }
输出结果:
1: x + y = 3 when x = 1 and y = 2
之所以在请求消息中不存在于终结点地址报头相匹配的报头会导致抛出EndpointNotFoundException异常,原因在于按照默认的消息筛选机制找不到匹配的终结点。为了解决这个问题,对于客户端来说,可以通过在消息中添加相应的报头满足服务端筛选的条件;而对于服务端来说,则可以改变为了实现终结点的选择而采用消息筛选机制。总之一句话,只要服务端能够根据匹配的终结点就可以抑制EndpointNotFoundException异常的抛出。
三、改变地址筛选策略(实例3)
我们可以在服务类型上应用ServiceBehaviorAttribute特性并为AddressFilterMode属性进行相应的设置来改变针对终结点地址的筛选机制。如下面的代码所示,AddressFilterMode属性是一个类型为AddressFilterMode的枚举。三个枚举项(Exact、Prefix和Any)分别代表三种地址匹配的策略,即精确匹配,基于前缀匹配和匹配任意地址。
1: [AttributeUsage(AttributeTargets.Class)]
2: public sealed class ServiceBehaviorAttribute : Attribute, IServiceBehavior
3: {
4: [DefaultValue(0)]
5: public AddressFilterMode AddressFilterMode { get; set; }
6: }
7: public enum AddressFilterMode
8: {
9: Exact,
10: Prefix,
11: Any
12: }
其中Exact和Prefix都需要进行地址报头的匹配,而Any则不需要。从应用在AddressFilterMode的DefaultValueAttribute特性可以看出,该属性的默认值是Exact,所以在默认的情况下采用的是针对地址的精确匹配。那么如果我们在CalculatorService上应用ServiceBehaviorAttribute特性并将AddressFilterMode设置为Any,即使请求消息中不具有相关的报头,服务调用也会成功。(S203)
1: [ServiceBehavior(AddressFilterMode = AddressFilterMode.Any)]
2: public class CalculatorService : ICalculator
3: {
4: //省略成员
5: }
本例虽然名为通过“通过地址报头实现对客户端的授权”,其实在真正的应用中我们不会通过这样的方式对服务授权。因为终结点的地址报头是元数据的一部分,客户端在获取服务发布的元数据时会将地址报头一并获取。