WCF把书读薄(3)——数据契约、消息契约与错误契约
上一篇:WCF把书读薄(2)——消息交换、服务实例、会话与并发
十二、数据契约
在实际应用当中数据不可能仅仅是以int Add(int num1, int num2)这种简单的几个int的方式进行传输的,而是要封装成相对复杂的Request/Response对象,即用我们自定义的类来进行消息的传输,那么就需要一种规则来序列化/反序列化我们自己的对象成为某种标准格式。WCF可以通过数据契约来完成这一过程,WCF使用的序列化器是DataContractSerializer。
在一个类上打上DataContract标记表示这是一个数据契约,其中打上DataMember的属性会被WCF序列化,与是否public无关(P174),例子:
[DataContract] public class Customer { [DataMember] public string Name { get; set; } [DataMember] public string Phone { get; set; } [DataMember] public Address CompanyAddress { get; set; } [DataMember] public Address ShipAddress { get; set; } } [DataContract] public class Address { [DataMember] public string Province { get; set; } [DataMember] public string City { get; set; } [DataMember] public string District { get; set; } [DataMember] public string Road { get; set; } }
DataContract有三个属性,其中Name和NameSpace表示名称和命名空间,IsReference表示如果设置为true,则在序列化XML的过程当中,如果遇到了两个对象使用同一个对象的引用,则只序列化一份这个对象,默认为false(P181)。
DataMember有四个属性,Name为序列化后在XML中的节点名称,Order为在XML中的排序,默认为-1,从小到大排序,在我们队序列化后的结果不满意时可以通过这个属性进行修改,序列化后的数据规则是:父类在前之类在后,同一类型中的成员按照字母排序,IsRequired表示属性成员是否是必须成员,默认为false可缺省的,EmitDefaultValue表示该值等于默认值时是否序列化,默认为true。
在应用当中服务可能来回传递很大的DataSet,导致服务器端序列化不堪重负,于是可以修改WCF服务行为的maxItemInObjectGraph的值来控制最大序列化对象的数量上限,比如设置为2147483647(P178)。如何设置服务行为这里不再赘述,可以看我的上一篇笔记。
SOAP消息里的内容是使用DataContractSerializer序列化的,当然,如果想换一种序列化方式,可以在服务契约类上打标签比如[XmlSerializerFormat]。
十三、继承关系的序列化
依旧是老A的例子,假设有如下的数据契约和服务:
public interface IOrder { Guid Id { get; set; } DateTime Date { get; set; } string Customer { get; set; } string ShipAddress { get; set; } } [DataContract] public abstract class OrderBase : IOrder { [DataMember] public Guid Id { get; set; } [DataMember] public DateTime Date { get; set; } [DataMember] public string Customer { get; set; } [DataMember] public string ShipAddress { get; set; } } [DataContract] public class Order : OrderBase { [DataMember] public double TotalPrice { get; set; } } [ServiceContract] public interface IOrderService { [OperationContract] void ProcessOrder(IOrder order); }
在这里数据契约存在继承关系且实现了一个接口,服务契约需要传入一个接口类型作为参数,那么元数据发布后,在客户端就会得到如下的方法:
public void ProcessOrder(object order) { base.Channel.ProcessOrder(order); }
其类型变成了object,这就会造成危险,所以说不推荐在服务操作中使用接口类型作为参数。经过个人实践证明,即便用ServiceKnownType属性,到了客户端也是一个object类型参数。造成这一现象的原因就是WCF不知道如何序列化服务契约当中的IOrder,它不知道这代表了什么,于是序列化到XML时这个数据类型对应的节点就是<anyType>。
一个恰当的改法就是利用已知类型,修改服务契约,让他使用父类而不是接口,并且修改数据契约,给父类设置之类的已知类型:
[ServiceContract] public interface IOrderService { [OperationContract] void ProcessOrder(OrderBase order); } [DataContract] [KnownType(typeof(Order))] public abstract class OrderBase : IOrder { [DataMember] public Guid Id { get; set; } [DataMember] public DateTime Date { get; set; } [DataMember] public string Customer { get; set; } [DataMember] public string ShipAddress { get; set; } }
如此一来,到客户端参数就成为了OrderBase类型,正如我们所愿的。
另一套解决方案是数据契约不变,把针对已知类型的配置放在操作契约上,同样操作契约不能使用接口,如下:
[ServiceContract] [ServiceKnownType("GetKnownTypes", typeof(KnownTypeResolver))] public interface IOrderService { [OperationContract] void ProcessOrder(OrderBase order); } public static class KnownTypeResolver { public static IEnumerable<Type> GetKnownTypes(ICustomAttributeProvider provider) { yield return typeof(Order); } }
这里通过一个类来反射获取已知类型。
十四、数据契约的版本控制
不论服务端还是客户端,他们的之间发送的数据都是要序列化为XML的,序列化的依据就是XSD,如果双方要保持正常通信,那么这个XSD就必须等效,这个“等效”指的是契约命名空间和各属性的名称及顺序都必须一致。
然而程序并不是一成不变的,随着需求变化,我们可能会在服务端的数据契约当中增删一些字段,而没有更新服务引用的客户端在和新版本的服务交互时就会发生问题,对于这种版本不一致造成的问题,WCF提供了解决方案。
第一种情况是服务端增加了一个字段,而客户端依然通过老版本的数据契约进行服务调用,如此一来在服务端反序列化时就会发现缺少字段,在这种情况下,对于缺少的字段,服务端会自动采用默认值来填充(P210),如果希望客户端不更新服务则调用错误的话,就需要加上表示数据成员是必须传入的反射标记了:
[DataMember(IsRequired=true)] public string Description { get; set; }
如果希望不实用默认值,而实用我么自定义的值,则需要在数据契约内增加方法:
[DataContract] public abstract class OrderBase : IOrder { [DataMember] public Guid Id { get; set; } [DataMember] public DateTime Date { get; set; } [DataMember] public string Customer { get; set; } [DataMember] public string ShipAddress { get; set; } [DataMember] public string Description { get; set; } [OnDeserializing] void OnDeserializing(StreamingContext context) { this.Description = "NoDescription"; } }
和OnDeserializing类似的还有OnDeserialized、OnSerializing,OnSerialized几个标签,可以在其中增加序列化前后事件。
第二种情况是服务端减少了一个字段,在这种情况下采用新版本数据契约的服务端在会发给采用老版本数据契约的客户端时就会出现数据丢失的情况。
在这种情况下,需要给数据契约实现IExtensibleDataObject接口,并注入ExtensionDataObject类型的ExtensionData属性:
[DataContract] public abstract class OrderBase : IOrder, IExtensibleDataObject { [DataMember] public Guid Id { get; set; } [DataMember] public DateTime Date { get; set; } [DataMember] public string Customer { get; set; } [DataMember] public string ShipAddress { get; set; } public ExtensionDataObject ExtensionData { get; set; } }
有了这个属性,在序列化的时候就会自动带上额外的属性了,当然,如果希望屏蔽掉这个功能,则需要在服务行为和终结点行为当中进行配置:
<dataContractSerializer ignoreExtensionDataObject="true" />
十五、消息契约
其实利用数据契约已经能够很好地完成数据的传输了,而数据契约只能控制消息体,有时候我们想在数据传递过程中添加一些额外信息,而不希望添加额外的契约字段,那么我们就得改消息报头,也就是说该使用消息契约了。读老A的书,这章的确让我犯晕,上来全是原理,其中从第232页到第260页的原理已经给我这个初学SOA的新手扯晕了,看来以后讲东西千万不能上来就扯原理啊!既然根本记不住,那么就直接来写代码吧!
首先改了上面例子里的数据契约,换成消息契约:
[MessageContract] public class Order { [MessageHeader] public SoapHeader header; [MessageBodyMember] public SoapBody body; } [DataContract] public class SoapHeader { [DataMember] public Guid Id { get; set; } } [DataContract] public class SoapBody { [DataMember] public DateTime Date { get; set; } [DataMember] public string Customer { get; set; } [DataMember] public string ShipAddress { get; set; } [DataMember] public double TotalPrice { get; set; } }
消息契约是用MessageContract标签修饰的,我们要控制的消息头用MessageHeader修饰,消息体则由MessageBodyMember修饰,这样一来就把消息头和消息体拆分开来可以独立变化了。然后,修改服务契约:
[ServiceContract] public interface IOrderService { [OperationContract] void ProcessOrder(Order order); }
这里将方法的参数设置为了消息契约的对象,需要注意的是如果使用消息契约,则参数只能传一个消息契约对象,不能使用多个,也不能和数据契约混用。
接下来发布服务,在客户端编写如下代码:
static void Main(string[] args) { OrderServiceClient proxy = new OrderServiceClient(); SoapHeader header = new SoapHeader(); header.Id = Guid.NewGuid(); SoapBody body = new SoapBody(); //body.Date = DateTime.Now; //body.…… proxy.ProcessOrder(header, body); Console.ReadKey(); }
消息契约第一个典型应用就是在执行文件传输时,文件的二进制信息放到body里,而一些复加的文件信息则放在head里。
写完代码之后来看看这些标签的属性。MessageContract标签的IsWrapped属性表示是否将消息主体整个包装在一个根节点下(默认为true),WrapperName和WrapperNamespace则表示这个根节点的名称和命名空间。ProtectionLevel属性控制是否对消息加密或签名。
MessageHeader有一个MustUnderstand属性,设定消息接收方是否必须理解这个消息头,如果无法解释,则会引发异常,这个值可以用来做消息契约的版本控制。
MessageBody当中有一个Order顺序属性,它不存在于MessageHeader当中,是因为报头是与次序无关的。
十六、消息编码
SOAP当中的XML是经过编码后发送出去的,WCF支持文本、二进制和MTOM三种编码方式,分别对应XmlUTF8TextWriter/XmlUTF8TextReader、XmlBinaryWriter/XmlBinaryReader和XmlMtomWriter/XmlMtomReader。
选择哪一种编码方式取决于我们的绑定,UTF8编码没什么好解释的,BasicHttpBinding、WSHtpBinding/WS2007HttpBinding和WSDualHttpBinding在默认情况下都使用这种编码。
如果XML很大,则应该使用二进制的形式,二进制编码会将XML内容压缩传输。NetTcpBinding、NetNamedPipeBinding和NetMsmqBinding都采用这种编码。
对于传输文件这样的大规模二进制传输场合,应该采用MTOM模式。
如果我们需要自己改写编码的方式就需要改绑定的XML或者手写绑定了(P285)。
十七、异常与错误契约
接下来换另一个话题——异常处理。和普通服务器编程一样,在WCF的服务端也是会引发异常的,比如在服务器端除了一个0,这时候异常会抛出到服务器端,那么既然WCF是分布式通信框架,就需要把异常信息发送给调用它的客户端。
如果把异常的堆栈信息直接发送给客户端,显然是非常危险的(不解释),所以经过WCF的内部处理,只会在客户端抛出“由于内部错误,服务器无法处理该请求”的异常信息。
如果确实需要把异常信息传递给客户端,则有两种方式,一种是在配置文件里将serviceDebug行为的includeExceptionDetailInFaulte设置为true,另一种手段就是在操作契约上增加IncludeExceptionDetailInFaulte=true的服务行为反射标签,具体代码与前面类似。
在这种设置之下,抛出的异常的类型为FaultException<TDetail>,这是个泛型类,TDetail在没有指定的情况下是ExceptionDetail类,于是在客户端我们可以如此捕获异常:
CalculatorServiceClient proxy = new CalculatorServiceClient(); int result; try { result = proxy.Div(10, 0); Console.WriteLine(result); } catch (FaultException<ExceptionDetail> ex) { Console.WriteLine(ex.Detail.Message); (proxy as ICommunicationObject).Abort(); }
在处理异常之后,需要手动关掉服务代理。
当然,可以事先在服务器端定义好一些异常,用来直接在客户端来捕获非泛型的异常:
public int Div(int num1, int num2) { if (num2 == 0) { throw new FaultException("被除数不能为0!"); } return num1 / num2; }
CalculatorServiceClient proxy = new CalculatorServiceClient(); int result; try { result = proxy.Div(10, 0); Console.WriteLine(result); } catch (FaultException ex) { Console.WriteLine(ex.Message); (proxy as ICommunicationObject).Abort(); }
但是从习惯上来讲我们喜欢把异常封装成一个含有其他信息的对象序列化返回给客户端,显而易见这个对象一定要是一个数据契约,首先定义一个数据契约来记录出错的方法和消息:
[DataContract] public class CalculatorError { public CalculatorError(string operation, string message) { this.Operation = operation; this.Message = message; } [DataMember] public string Operation { get; set; } [DataMember] public string Message { get; set; } }
之后在服务端抛出,这里的泛型类就是承载错误的数据契约的类型:
if (num2 == 0) { CalculatorError error = new CalculatorError("Div", "被除数不能为0!"); throw new FaultException<CalculatorError>(error, error.Message); }
如此做还不够,还需要给会抛出这种异常的操作加上“错误契约”:
[ServiceContract] public interface ICalculatorService { [OperationContract] [FaultContract(typeof(CalculatorError))] int Div(int num1, int num2); }
如此就能在客户端捕获具体泛型类的错误了:
try { result = proxy.Div(10, 0); Console.WriteLine(result); } catch (FaultException<CalculatorError> ex) { Console.WriteLine(ex.Detail.Operation); Console.WriteLine(ex.Detail.Message); (proxy as ICommunicationObject).Abort(); }
需要注意的是,一个操作可以打多个错误契约标记,但是这些错误契约的名称+命名空间是不能重复的,因为自定义的错误类型会以WSDL元数据发布出去,如果有重复的名称,就会发生错误(下P17)。
同时,WCF也支持通过标签方式将错误类采用XML序列化,这里不再赘述(下P18)。