WCF把书读薄(1)——终结点与服务寄宿
由于最近可能要使用WCF做开发,开始重读蒋金楠的《WCF全面解析》,并整理个人学习WCF的笔记。
蒋金楠的书是我的第一本WCF入门书,虽说硬着头皮啃下来了,但是原理内容太多太多,没有长期的经验是无法掌握的,而且这本书写得太过于偏重原理,并不是一本稍微看看就能速成并实现工作需求的书。
于是这篇文章的目的就是把原理和类结构分析给干掉(毕竟书上都有,我何必抄书?),把一些关键话语和配置方式摘出来,以便日后一旦遇到问题有个速查的手册,也相当于为这本书做了一个索引。
不过不得不说的是,对我这样的新手,读完了书中对WCF类结构的分析,对自己的框架设计能力的确起到了一定促进作用。
这篇文章算是写给我自己看的吧。本人是WCF新手,文中不免有错误之处,还望老鸟们不吝赐教!
一、WCF在企业架构中的位置
WCF是微软对分布式技术的一个整合平台,它对外提供了一套简洁的API,屏蔽了分布式通信的复杂性。
WCF是微软实现SOA(面向服务架构)的解决方案,何谓服务?个人理解:没有界面、让别人来调的应用程序就是服务。比如说我的网站要显示天气预报,我只要知道天气预报网站给我的接口就可以了,具体实现细节我不用管,这就是SOA的松耦合性。
作为客户端也就是服务的消费者,我必须要知道服务部署在哪,要知道如何通信,也要知道双方的数据传输规则,在WCF中这三样内容被整体称为“终结点”,上述三个方面被称为“地址、绑定、契约”(也就是“ABC”),也就是说终结点是服务的“出入口”。下面是一副概念图:
作为客户端,我既然知道服务如何定义(既然拿到了服务接口),就可以通过代理模式来封装网络通信,使服务的真正消费程序依赖接口编程。在WCF当中客户端持有的是一个等效的服务接口,代理类内部根据终结点的配置(反射xml)自动包办了信道创建等通信相关逻辑,使得我们的客户端编程十分简单。
两台机子调服务必然牵扯到数据的传输,通信数据不能赤裸裸滴暴露在网络上,势必会有一些加解密、压缩、编码等所谓“切面”操作,这一点WCF是通过“绑定”的手段来实现的,个人理解它其实就是一个“创建装饰模式的工厂”。
数据传输依托于数据结构,数据结构包含了方法的定义以及传输参数的定义,这两者分别对应WCF的服务契约和数据契约,这两个契约通过反射标签的方式定义,在WCF框架运行时替我们序列化成我们制定的形式,使得我们的代码依旧十分简洁,不用关注序列化细节,同时WCF还提供了一些契约版本兼容的手段。
对我们一线码农来说,既然WCF编程本身比较简单,那么我们的难点就是设计一个牛逼的接口了。
二、WCF例子
下面来做一个基于服务的加法计算器——由客户端调用宿主暴露出来的加法服务。
首先定义服务接口(服务契约),建立一个名为Service.Interface的类库,引用WCF的核心程序集——System.ServiceModel.dll,之后添加如下代码:
namespace Service.Interface
{
[ServiceContract(Name="AddService", Namespace="http://www.xxx.com/")]
public interface ICalculator
{
[OperationContract]
int Add(int num1, int num2);
}
}
如上述代码所示,我们需要显示地把需要暴露出去的服务接口打上服务契约和操作契约的反射标记,其中服务契约当中的Name属性用来决定客户端代理类当中接口的名字(这一接口和ICalculator等价)。
接下来创建服务实现,新建一个类库Service,引用上面的接口项目,并增加如下代码:
namespace Service
{
public class CalculatorService : ICalculator
{
public int Add(int num1, int num2)
{
return num1 + num2;
}
}
}
在实际应用当中,这个具体Server类应该作为领域层的Facade,而不是充斥着大量业务逻辑代码,另外个人认为应当把接口和服务定义在两个dll里。
搞定了服务契约和服务实现之后,接下来要把这个服务host到一个宿主里,WCF支持把服务host到IIS里或者一个单独的进程里,这里把加法服务host到一个cmd进程里。于是我们想当然地建立一个叫Service.Host的cmd项目。
首先作为服务,一定是和编程语言无关的,那么势必有某种规则来统一不同语言之间的数据结构差异,WCF服务的WSDL形式描述信息通过“元数据”发布出来,也就是说我们要在Host程序里定义一个写代码来定义元数据的发布行为,另外我们需要定义一个“终结点”来把服务本身给暴露出去。这些代码写起来比较复杂,不过WCF允许我们通过写XML的方式来进行配置(又见反射工厂= =!),给我们省了很大的开发量。
让Host引用System.ServiceModel.dll以及服务接口还有Service实现项目(IoC的气味浓郁…),实现如下代码:
class Program
{
static void Main(string[] args)
{
using (ServiceHost host = new ServiceHost(typeof(CalculatorService)))
{
host.Open();
Console.Read();
}
}
}
这样就完成了服务端代码,当然,还需要对应的配置文件App.Config:
<configuration>
<system.serviceModel>
<behaviors>
<serviceBehaviors>
<behavior name="metadataBehavior">
<serviceMetadata httpGetEnabled="true"
httpGetUrl="http://127.0.0.1:9527/calculatorservice/metadata" />
</behavior>
</serviceBehaviors>
</behaviors>
<services>
<service name="Service.CalculatorService"
behaviorConfiguration="metadataBehavior" >
<endpoint address="http://127.0.0.1:9527/calculatorservice"
binding="wsHttpBinding"
contract="Service.Interface.ICalculator" />
</service>
</services>
</system.serviceModel>
</configuration>
配置的上半部分定义的一个发布元数据的行为,访问httpGetUrl节当中的地址就能获取服务的元数据。下半部分则是我们的服务,其中定义了一个终结点,我们可以看到终结点的ABC三大属性。现在可以运行这个cmd程序来把服务host起来了。
最后创建客户端,一个cmd项目Client,引用接口项目,然后右击添加服务引用,输入上面的元数据地址,即可找到这一服务。添加这一服务引用后,项目当中会自动生成一个app.config文件,我们改写它(其实可以直接用自动生成的,这里由于引用了接口dll,就重写了一下,实际中不会这么弄):
<configuration>
<system.serviceModel>
<client>
<endpoint name="MyServiceEndpoint1"
address="http://127.0.0.1:9527/calculatorservice"
binding="wsHttpBinding"
contract="Service.Interface.ICalculator" />
</client>
</system.serviceModel>
</configuration>
这个客户端的终结点的ABC和服务端的是匹配的,name是终结点的名字,方便我们在编程时根据名字(反射)调用相关的配置信息,客户端如下:
namespace Client
{
class Program
{
static void Main(string[] args)
{
using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>("MyServiceEndpoint1"))
{
ICalculator proxy = channelFactory.CreateChannel();
Console.WriteLine(proxy.Add(1, 1));
}
Console.Read();
}
}
}
可以看出,客户端通过配置文件创建了一个叫信道工厂的东西并“开启”了它(using块),在其中由信道工厂创建了一个服务代理,并通过代理透明地调用服务(疑似有AOP的味道…),至此一个最简单的WCF服务就开发完成了,程序本身其实简单,其实在信道工厂的背后WCF为我们实现了很多东西。
至于如何把WCF服务host到IIS上,网上有不少帖子,这里就不赘述了。
三、配置终结点地址
上一步看到的服务端host使用的是XML,其实它的部分代码如下(P9):
using (ServiceHost host = new ServiceHost(typeof(CalculatorService)))
{
host.AddServiceEndpoint(typeof(ICalculator), new WSHttpBinding(),"http://127.0.0.1:9527/calculatorservice");
if (host.Description.Behaviors.Find<ServiceMetadataBehavior>() == null)
{
ServiceMetadataBehavior behavior = new ServiceMetadataBehavior();
behavior.HttpGetEnabled = true;
behavior.HttpGetUrl = new Uri("http://127.0.0.1:3721/calculatorservice/metadata");
host.Description.Behaviors.Add(behavior);
}
这里的AddServiceEndpoint方法顾名思义就是添加服务终结点,其中有三个参数,分别是服务契约、绑定和地址。
对于一个服务终结点,可以像上面一样直接指定绝对地址,也可以通过“基地址+绝对地址”的方式配置,就是说,上面Host用的XML可以改成这样:
<configuration>
<system.serviceModel>
<behaviors>
<serviceBehaviors>
<behavior name="metadataBehavior">
<serviceMetadata httpGetEnabled="true"
httpGetUrl="http://127.0.0.1:9527/calculatorservice/metadata" />
</behavior>
</serviceBehaviors>
</behaviors>
<services>
<service name="Service.CalculatorService"
behaviorConfiguration="metadataBehavior" >
<host>
<baseAddresses>
<add baseAddress="http://127.0.0.1:9527/"/>
</baseAddresses>
</host>
<endpoint address="calculatorservice"
binding="wsHttpBinding"
contract="Service.Interface.ICalculator" />
</service>
</services>
</system.serviceModel>
</configuration>
一个服务可以配置多个基地址,但是它们的传输协议不能相同,否则会报错(P26)。
对于host到IIS的情况,svc文件的地址就是服务的基地址,所以不用配置基地址,只要在终结点里配置相对地址(P27)。
对于实现了多个服务契约的服务(接口分离原则),假定有一个新的接口叫ICalculator2,则需要配置另一个终结点,这两个终结点实质上公用一个绑定对象(P28)。
<services>
<service name="Service.CalculatorService"
behaviorConfiguration="metadataBehavior" >
<host>
<baseAddresses>
<add baseAddress="http://127.0.0.1:9527/"/>
</baseAddresses>
</host>
<endpoint address="calculatorservice"
binding="wsHttpBinding"
contract="Service.Interface.ICalculator" />
<endpoint address="calculatorservice"
binding="wsHttpBinding"
contract="Service.Interface.ICalculator2" />
</service>
</services>
在客户端同样要配置两个终结点与这两个服务契约相对应:
<configuration>
<system.serviceModel>
<bindings>
<wsHttpBinding>
<binding name="MyWSHttpBinding" />
</wsHttpBinding>
</bindings>
<client>
<endpoint address="http://127.0.0.1:9527/calculatorservice"
binding="wsHttpBinding" bindingConfiguration="MyWSHttpBinding"
contract="Service.Interface.ICalculator"
name="MyServiceEndpoint1">
</endpoint>
<endpoint address="http://127.0.0.1:9527/calculatorservice"
binding="wsHttpBinding" bindingConfiguration="MyWSHttpBinding"
contract="Service.Interface.ICalculator2"
name="MyServiceEndpoint2">
</endpoint>
</client>
</system.serviceModel>
</configuration>
仔细一看这个配置会发现contract指向的是接口dll当中的类名,这也导致了客户端要引用这个dll,这就相当恶心了,既然是SOA怎么能引dll呢?于是去掉Client对接口dll的引用,这样一来客户端创建信道工厂的代码就会出错了,原因是找不到接口,其实在我们引用WCF服务之后,在客户端会生成一个等效的接口,打开服务引用的客户端代码会Reference.cs发现,这里面含有一个叫AddService的接口,其中包含Add方法,也打着服务标签,这其实就是上面图中所画的“等效契约”,之所以叫AddService,是因为真正的服务契约标签上给他配的Name=”AddService”的缘故,下面有个类AddServiceClient实现了这个接口,这就是我们的代理类(所谓的“透明代理模式”),行了,这就是我想要的,于是果断改写客户端:
static void Main(string[] args)
{
AddServiceClient addProxy = new AddServiceClient("MyServiceEndpoint1");
Console.WriteLine(addProxy.Add(1, 1));
SubServiceClient subProxy = new SubServiceClient("MyServiceEndpoint2");
Console.WriteLine(subProxy.Sub(10, 5));
Console.Read();
}
客户端配置文件也要修改成等效的契约:
<client>
<endpoint address="http://127.0.0.1:9527/calculatorservice"
binding="wsHttpBinding" bindingConfiguration="MyWSHttpBinding"
contract="ServiceReference.AddService"
name="MyServiceEndpoint1">
</endpoint>
<endpoint address="http://127.0.0.1:9527/calculatorservice"
binding="wsHttpBinding" bindingConfiguration="MyWSHttpBinding"
contract="ServiceReference.SubService"
name="MyServiceEndpoint2">
</endpoint>
</client>
这样Client就摆脱对接口dll的引用了(P30)。
下面利用地址报头进行辅助寻址,WCF通信是建立在消息交换基础之上的,一个完整的SOAP消息应该包含报头和主体,报头用于添加一些额外的控制信息,比方说在服务器端终结点增加一个报头:
<endpoint address="calculatorservice"
binding="wsHttpBinding"
contract="Service.Interface.ICalculator">
<headers>
<sn xmlns="http://www.xxx.com/">{DDA095DA-93CA-49EF-BE01-EF5B47179FD0}</sn>
</headers>
</endpoint>
这样一来客户端调用就会抛异常了,原因是无法找到匹配的终结点,所以需要在客户端加上同样的报头才能让终结点匹配(P40)。
报头可以是一个序列化后的对象,可以通过代码实现报头的添加(P40)。
如希望屏蔽掉报头对寻址的影响,可以给服务实现类打上标签:
[ServiceBehavior(AddressFilterMode = AddressFilterMode.Any)]
public class CalculatorService : ICalculator, ICalculator2
这个特性用来改变针对终结点的筛选机制,默认的枚举值是Exact精确匹配,改为Any则是任意匹配,另一个枚举值是Prefix表示基于前缀匹配(P41)。
四、端口共享与监听地址配置
将某个WCF服务host到一个进程上,本质就是通过这个进程来监听Socket请求(P43),而对于防火墙,通常只保留80/443端口,而前面的WCF例子当中如果把两个服务都通过一个端口暴露就会抛异常(P42),所以首先,我们要在控制面板-管理工具-服务当中启NET.TCP Port共享服务,并在WCF服务中使用TCP协议,此外要自己增加一个绑定:
<configuration>
<system.serviceModel>
<bindings>
<netTcpBinding>
<binding name="MyBinding1" portSharingEnabled="true" />
</netTcpBinding>
</bindings>
并修改终结点的绑定:
<endpoint address="calculatorservice"
binding="netTcpBinding" bindingConfiguration="MySharingBinding1"
contract="Service.Interface.ICalculator">
有时候出于负载均衡考虑,消息的监听地址和实际服务地址并不是一样的,比如用端口9999的地址来监听请求,之后转发给端口8888的地址来实现,那么在配置服务的时候就需要在终结点上增加一个监听地址:
<endpoint address="http://127.0.0.1:8888/calculatorservice" listenUri="http://127.0.0.1:9999/calculatorservice"
listenUriMode="Explicit"
binding="netTcpBinding" bindingConfiguration="MySharingBinding1"
contract="Service.Interface.ICalculator">
这里的listenUriMode属性有两个值,一个是Explicit,意思是你怎么配的,就怎么调用,另一个是Unique会采用不同的策略来保证监听地址唯一(P49)。
五、信道与绑定
在WCF应用层的下方存在一系列首位相连的信道,它们组成了信道栈(十有八九是装饰模式或者职责链之类的东西),我们通过代理类调用一个服务方法,调用进入信道栈,在这些信道栈上首先处理消息编码、安全传输等功能(P66),经过这些信道处理后的消息会以某种方式进行序列化,并进行传输,到达接收端后也会反过来执行这些信道来恢复消息(所以两边的配置要一致)。
与纯Socket类似,在WCF当中,消息收发的过程是:创建绑定对象,创建信道管理器(监听器/工厂),开始监听,并在监听线程的循环内收发消息(P68)。
信道分为三类:传输信道、编码信道、协议信道。WCF的绑定是讲究顺序的,因为绑定的顺序决定信道管理器的顺序,进而决定信道顺序(P72)。
WCF的信道由信道工厂(客户端的叫法)/监听器(服务端的叫法)创建,信道工厂/监听器由绑定元素创建,这三者都是可以通过我们手写代码进行二次开发的(P83-P96)。
绑定是绑定元素的有序集合,如果想确定某绑定是否具有某功能,只要看有没有对应的绑定元素就可以了(P105)。绑定也是可以由我们自定义的(P99),二次开发的自定义绑定也可以通过配置文件来调用(P110),关于配置一个自定义绑定可以见这篇文章(http://www.cnblogs.com/leslies2/archive/2011/10/14/2195800.html)。
WCF提供了三种消息交换模式:数据报模式,发出消息后不希望对方回复。这样的发送一般采用异步方式;请求回复模式,一般采用同步调用;双工模式,任何一方都可以给对方发送消息,可以让服务端回调客户端方法(P76)。
WCF同时为我们提供了一些系统自带的绑定,这些绑定大多能解决我们的需求,上面的帖子里也已经总结了一个列表。其中Net开头的绑定限制在.Net平台使用,这些绑定适用于内网通信,以WS开头的绑定适合跨平台通信等(P105)。
个人理解,自定义绑定是WCF提供的一种扩展机制,这些东西一旦用上只能查书看帖子来配置,所以贴代码是没意义的。
不得不提的是,其中通过链状的工厂生成链状的“方法切面”这个思路是很值得学习的!
六、服务契约与操作契约
接口提取的是变动中的“不变”部分,同样,契约则定义了服务对外的“承诺”,即消息交互的“结构”。作为服务的消费者,我们需要知晓服务的契约,按照契约规定的方式调用和传参,才能得到预期的结果。调用WCF的方式神似于“针对接口编程”,因为服务是“自治”的,两者依赖于“契约”,而无需知道服务的实现细节。(P115)
WCF定义了服务契约和数据契约,如果把一个服务看做是一个程序集的元数据的话,则前者定义了期中的方法,后者定义了其中的属性(数据结构)。服务是通过WSDL(Web服务描述语言)定义的,它实质上是个xml结构,通过它实现了跨语言,以满足混搭系统的需要。(P116)
如前面例子看到,定义务契约要用到两个标签[ServiceContract]和[OperationContract],前者贴在接口的定义上,后者贴在方法的定义上。
[ServiceContract]服务契约标签有Name和NameSpace等很多属性,它们对应wsdl中某些节点的值。
Name属性默认是接口名,但是我们可以修改,通过这个配置可以给服务起个新名字,其中Name属性能改变客户端生成的代理类的名字,为“Name的属性值+Client”。NameSpace属性建议使用公司或者项目的名称。ConfigurationName属性用于指定服务契约在终结点中的配置名称,通过它我们就不用在xml终结点的契约配置里配一对类名了,只要配上这个ConfigurationName的值即可,脱离的xml和具体类名的耦合。如果不配,默认就是接口名。
[OperationContract]操作契约标签同样也有很多属性,操作契约是对操作的描述,WCF使用它的目的是把这些操作转化成某种格式的消息以进行消息交换。
Name属性表示操作的唯一名称,默认使用的就是方法名,需要注意的是这个名称不能重复,否则会抛异常,所以最好不要在契约里重载方法。前面曾经提到过可以在终结点内添加报头(header),WCF在进行消息筛选的时候需要根据请求消息的Action报头决定目标终结点,在执行操作的时候也要根据这个值选择操作,所以这个Action值也是唯一不能重复的(P126)。Action属性的默认值是“服务契约命名空间/服务契约名称/操作名称”,它对应请求消息,回复消息则对应一个RelpyAction属性,它的值默认是“服务契约命名空间/服务契约名称/操作名称Responst”。个人感觉这个值不用开发人员来动。
在WCF当中,契约是扁平的结构,契约所依托的接口可以继承,接口的之接口也可以是个操作契约,但是虽然有两个接口,整个服务契约却是一个(P126)。
既然服务是要映射成“元数据”的,那么肯定就有“反射”这一说,WCF当中对服务的“反射”是通过如下方法实现的:
ContractDescription cd = ContractDescription.GetContract(typeof(客户端代理类名));
通过这个方法我们能拿到契约的描述信息,这其中又有一个Operations集合属性,描述这个契约里的所有操作。而这个Operations集合的每个operation当中又有一个Messages集合,它描述请求/响应的消息,可以通过这个消息分析报文(P136)。
七、客户端代理类
最后来回顾一下客户端,假定我们给服务起的名字叫MyCalculatorService,其产生的客户端类为MyCalculatorServiceClient,对其F12,可以发现它继承自一个泛型的ClientBase<TChannel>,以及实现了一个接口,这个接口就是所谓的“等效接口”:
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "4.0.0.0")]
[System.ServiceModel.ServiceContractAttribute(Namespace="http://www.xxx.cn/", ConfigurationName="ServiceReference.MyCalculatorService")]
public interface MyCalculatorService {
[System.ServiceModel.OperationContractAttribute(Action="http://www.xxx.cn/MyCalculatorService/Add", ReplyAction="http://www.xxx.cn/MyCalculatorService/AddResponse")]
int Add(int num1, int num2);
}
可以看出,这里的接口和方法上也贴了标签,说明WCF的确是根据服务端代码上的反射标签生成的wsdl内容,客户端对服务的wsdl进行分析,并产生了这个接口,而客户端MyCalculatorServiceClient类中,除了拥有一些构造函数,剩余部分就是对这个接口的实现。所有的实现都是通过“base.Channel.方法名”调用的,这个父类就是ClientBase<TChannel>,它其中包含一个Channel属性:
// 摘要:
// 获取用于将消息发送到不同配置的服务终结点的内部通道。
//
// 返回结果:
// 指定类型的通道。
protected TChannel Channel { get; }
这里的TChannel类型就是信道的类型。
显然客户端使用的并不是服务器端的接口,这种“透明代理模式”正符合最上面那幅图当中的结构。