通過WCF基本的異常處理模式[上篇], 我們知道了:在默認的情況下,服務端在執行某個服務操作時拋出的異常(在這裡指非FaultException異常),其相關的錯誤信息僅僅限於服務端可見,並不會被WCF傳遞到客戶端;如果將開啟了IncludeExceptionDetailInFaults的ServiceDebug服務行為通過聲明(通過在服務類型上應用ServiceBehaviorAttrite特性)或者配置的方式應用到相應的服務上,異常相關的所有細節信息將會原封不動地向客戶端傳送。
這兩種方式體現了兩種極端的異常傳播(Exception Propagation)機制,對於基於服務操作執行過程中拋出的異常的錯誤細節,要麼完全對客戶端屏蔽,要麼全部暴露於客戶端。在真正通過WCF來架構我們的分布式系統中,我們往往需要一種折中的異常傳播機制:自定義服務端異常信息。這樣既可以讓客戶端得到一個易於理解的錯誤信息,又在一定程度上避免了一些敏感信息的洩露。
一、 通過FaultException直接指定錯誤信息
對於執行服務操作中拋出的異常,如果服務的定義者僅僅希望服務的調用者得到一段自定義的錯誤信息文本(字符串),我們要做的實際上很簡單:在服務操作中直接拋出一個FaultException異常,該異常對象通過以字符串形式體現的自定義錯誤信息創建。下面的代碼中,CalculaorService的Divide方式在指定的時候對第二參數進行了驗證,如果為零則創建一個FaultException,並指定錯誤信息(“被除數y不能為零!”)。
1: using System.ServiceModel;
2: using Artech.WcfServices.Contracts;
3: namespace Artech.WcfServices.Services
4: {
5: [ServiceBehavior(IncludeExceptionDetailInFaults = true)]
6: public class CalculatorService : ICalculator
7: {
8: public int Divide(int x, int y)
9: {
10: if (0 == y)
11: {
12: throw new FaultException("被除數y不能為零!");
13: }
14: return x / y;
15: }
16: }
17: }
客戶端在調用該服務操作的時候,如果傳入零作為被除數,將會直接捕獲服務端定義的拋出的這個異常(實際上,這其中經歷了異常對象的序列化、消息交換以及異常對象的反序列化等一系列的操作)。客戶端具體的異常捕獲情況如下面的程序體現:
1: using System;
2: using System.ServiceModel;
3: using Artech.WcfServices.Contracts;
4: namespace Artech.WcfServices.Clients
5: {
6: class Program
7: {
8: static void Main(string[] args)
9: {
10: using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>(
11: "calculatorservice"))
12: {
13: ICalculator calculator = channelFactory.CreateChannel();
14: using (calculator as IDisposable)
15: {
16: try
17: {
18: int result = calculator.Divide(1, 0);
19: }
20: catch (FaultException ex)
21: {
22: Console.WriteLine(ex.Message);
23: (calculator as ICommunicationObject).Abort();
24: }
25: }
26: }
27: }
28: }
29: }
輸出結果:
被除數y不能為零!
雖然在很多情況下,在服務端指定服務操作的過程中直接拋出含有自定義錯誤信息的FaultException異常,就能過客戶端感知到遇到的具體錯誤並進行必要的排錯和糾錯。但是,我們更多地,還是傾向於直接定義一個類型來描述異常信息。我個人傾向於這樣一類的類型為錯誤明細類型(Fault Detail Type)。服務端根據具體的異常場景創建相應的錯誤類型對象,並基於該對象我們上面提到的System.ServiceModel.FaultException<TDetail>異常,其中泛型類型參數為異常細節類型。在這個過程中,還涉及到一個重要的概念:錯誤契約(Fault Contract),接下來,我們就來介紹一下FaultException<TDetail>和錯誤契約。
二、 通過FaultException<TDetail>采用自定義類型封裝錯誤
由於用於封裝錯誤信息的異常細節類型的對象最終需要通過消息交換的方式從服務端傳播到客戶端,所以該對象必須是一個可序列化的對象。WCF通過兩種典型序列化器實現對數據對象的序列化和反序列化,其中一個是傳統的System.Xml.Serialization.XmlSerializer,該序列換器被ASP.NET Web服務用於對象和XML之間的序列化和反序列化;另一個則是System.Runtime.Serialization.DataContractSerializer,用於基於數據契約對象的序列化和反序列化,後者是WCF默認采用的序列化器。所以,可序列化的錯誤明細類型一般是一個數據契約,或者是一個應用了System.SerializableAttribute特性的類型。關於序列化,和與此相關的數據契約、數據契約序列化器等,在《WCF技術剖析(卷1)》的第5章有深入、全面的介紹。
我們仍然用我們上面提到的計算服務來舉例,現在我們需要定義一個獨立的類型來描述基於CalculatorService的異常,我們索性將該類型起名為CalculationError。我們將CalculationError定義成一個應用了System.Runtime.Serialization.DataContractAttribute特性的數據契約,簡單起見,我們僅僅定義了兩個數據成員(應用了System.Runtime.Serialization.DataMemberAttribute特性):Operation表示導致異常相應的運算操作(我們假設CalculatorService具有一系列運算操作,雖然我們的例子中僅僅給出為一一個除法運算操作:Divide),而Message表述具體的錯誤消息。CalculationError的定義在被客戶端(Client項目)和服務(Services項目)引用的契約(Contracts項目)中,具體定義如下:
1: using System;
2: using System.Runtime.Serialization;
3: namespace Artech.WcfServices.Contracts
4: {
5: [DataContractAttribute(Namespace="http://www.artech.com/")]
6: public class CalculationError
7: {
8: public CalculationError(string operation,string message)
9: {
10: if (string.IsNullOrEmpty(operation))
11: {
12: throw new ArgumentNullException("operation");
13: }
14: if (string.IsNullOrEmpty(message))
15: {
16: throw new ArgumentNullException("message");
17: }
18: this.Operation = operation;
19: this.Message = message;
20: }
21: [DataMember]
22: public string Operation
23: { get; set; }
24: [DataMember]
25: public string Message
26: { get; set; }
27: }
28: }
照理說,我們已經正確定義了錯誤明細類型CalculationError,在CalculatorService的Divide操作中就可以直接拋出一個Fault<CalculationError>,並將一個創建一個CalculationError對象作為該異常對象的明細(通過Detail屬性表示),具體的代碼如下所示:
1: using System.ServiceModel;
2: using Artech.WcfServices.Contracts;
3: namespace Artech.WcfServices.Services
4: {
5: public class CalculatorService : ICalculator
6: {
7: public int Divide(int x, int y)
8: {
9: if (0 == y)
10: {
11: var error = new CalculationError("Divide", "被除數y不能為零!");
12: throw new FaultException<CalculationError>(error,error.Message);
13: }
14: return x / y;
15: }
16: }
17: }
客戶端服務調用相關的異常處理也作如下相應的修改:
1: using System;
2: using System.ServiceModel;
3: using Artech.WcfServices.Contracts;
4: namespace Artech.WcfServices.Clients
5: {
6: class Program
7: {
8: static void Main(string[] args)
9: {
10: using (ChannelFactory<ICalculator> channelFactory = new ChannelFactory<ICalculator>(
11: "calculatorservice"))
12: {
13: ICalculator calculator = channelFactory.CreateChannel();
14: using (calculator as IDisposable)
15: {
16: try
17: {
18: int result = calculator.Divide(1, 0);
19: }
20: catch (FaultException<CalculationError> ex)
21: {
22: Console.WriteLine("運算錯誤");
23: Console.WriteLine("運算操作:{0}",ex.Detail.Operation);
24: Console.WriteLine("錯誤消息:{0}",ex.Detail.Message);
25: (calculator as ICommunicationObject).Abort();
26: }
27: }
28: }
29: }
30: }
31: }
但是我們的客戶端程序並不能按照我們實現預想的那樣正常運行,而會拋出如圖1所示的未被處理的FaultException異常,而我們試圖捕獲的異常類型為FaultException<CalculationError>。
圖1 客戶端不能正常捕獲FaultException<CalculationError>異常
三、錯誤契約(Fault Contract)
要回答上面出錯的原因,就需要談到WCF或者SOA一個根本的特征:服務的提供者和消費者之間采用基於“契約(Contract)”的交互方式。不同於面向服務,在面向組件設計中,組件之間的交互實際上是基於類型的,交互雙方需要共享相同類型集(接口、抽象類或者具體類等)。在《WCF技術剖析(卷1)》中,我們曾多次對契約進行過深入的探討。從抽象層面上講,契約時交互雙方或者多方就某一問題達成的一種共識,使確保正常交互指定的一系列的規范。
從本質上講,服務契約(Service Contract)中的每一個操作契約(Operation Contract),定義了WCF為實現該服務操作的調用采用的消息交換模式(MEP:Message Exchange Pattern),並且結合基於參數、返回值類型的數據契約、消息契約定義了請求消息和回復消息的結構(Schema)。數據契約建立了對相同數據的兩種不同表現形式(托管對象和XML)之間的雙向適配,以利於承載相同信息的數據在兩種不同形態之間的轉換,即序列換和反序列化。而消息契約在定義了托管對象的各個成員與相應的消息元素之間的匹配關系。借助於消息契約,在對一個托管對象進行序列化並生成消息的時候,可以有效地控制某一個數據成員(屬性或者字段)被序列化成的XML應該置於消息報頭(Header)還是消息主體(Body)。
總的來說,上述的這些契約基本上都是圍繞著一個正常服務調用下的消息交換:服務的消費者通過向服務的提供者發送請求消息,服務的提供者在接受到該請求後,激活服務實例並調用相應的服務操作,最終將返回的結果以回復消息的方式返回給服務的消費者(對於One-way,則不需要消息的回復)。但是,如果服務操作不能正確地執行,服務端將會通過一種特殊的消息將錯誤信息返回給客戶端,這種消息被稱為錯誤消息(Fault Message)。對於錯誤消息,同樣需要相應的契約來定義其結構,我們把這種契約稱為錯誤契約(Fault Contract)。
WCF通過System.ServiceModel.FaultContractAttribute特性定義,由於錯誤契約是基於服務操作級別的,所以該特性直接應用於服務契約接口或者類的操作契約方法成員上。下面的代碼片斷體現了FaultContractAttribute的定義:
1: [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
2: public sealed class FaultContractAttribute : Attribute
3: {
4: public FaultContractAttribute(Type detailType);
5: public string Action { get; set; }
6: public Type DetailType { get; }
7: public bool HasProtectionLevel { get; }
8: public string Name { get; set; }
9: public string Namespace { get; set; }
10: public ProtectionLevel ProtectionLevel { get; set; }
11: }
FaultContractAttribute的6個屬性分別具有如下的含義:
Action:和一般的SOAP消息一樣,對於Fault SOAP,WS-Address報頭Action是必須的,該屬性控制Action報頭的值。如果Action屬性沒有在應用FaultContractAttribute時顯式指定,那麼它將按照下面的規則進行指定:{服務契約命名空間}/{服務契約名稱}/{操作契約名稱}{明細類型名稱}Fault;
DetailType:也就是上面所介紹的用於封裝錯誤信息的錯誤明細類型,比如我們前面定義的CalculationError;
Name和Namespace:在最終的Fault SOAP中,錯誤明細對象被序列化成的XML將會被置於Fault SOAP的主體部分,而這兩個屬性則用於控制這段XML片斷對應的名稱和命名空間;如果這兩個屬性並未作顯式設置,WCF將會使用DetailType對應的數據契約名稱和命名空間;
HasProtectionLevel和ProtectionLevel:這兩個屬性涉及到保護級別,屬於安全(Security)的問題,在這裡就不多作介紹了。
下面的代碼中,我們通過FaultContractAttribute將我們定義錯誤明細類型CalculationError應用到Divide操作之上,這樣的話,我們的例子就能夠正常運行了。
1: using System.ServiceModel;
2: namespace Artech.WcfServices.Contracts
3: {
4: [ServiceContract(Namespace = "http://www.artech.com/")]
5: public interface ICalculator
6: {
7: [OperationContract]
8: [FaultContract(typeof(CalculationError))]
9: int Divide(int x, int y);
10: }
11: }
按照我們在上面提到的關於Action、Name和Namespace的默認設定,上面這段代碼和下面的代碼是完全等效的。
1: using System.ServiceModel;
2: namespace Artech.WcfServices.Contracts
3: {
4: [ServiceContract(Name="ICalculator",Namespace = "http://www.artech.com/")]
5: public interface ICalculator
6: {
7: [OperationContract(Name="Divide")]
8: [FaultContract(typeof(CalculationError),
9: Action = "http://www.artech.com/ICalculator/DivideCalculationErrorFault",
10: Name = "CalculationError", Namespace = "http://www.artech.com/")]
11: int Divide(int x, int y);
12: }
13: }
對於我們前面的例子,當客戶端調用CalculatorService的Divide操作執行除法預算,並傳入零作為被除數,服務端將會拋出FaultException<CalculationError>異常。WCF服務端框架將會產生一個Fault Message,並將序列化後的CalculationError對象作為錯誤明細放置到Fault Message的主體部分。如果采用的消息版本是Soap12Addressing10,即SOAP 1.2和WS-Addressing 1.0,最終生成的Fault Message將會如下面的XML片斷所示:
1: <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">
2: <s:Header>
3: <a:Action s:mustUnderstand="1">http://www.artech.com/ICalculator/DivideCalculationErrorFault</a:Action> <a:RelatesTo>urn:uuid:3498ba2d-edd0-4d3b-ba4a-9b35327b5fa3</a:RelatesTo>
4: </s:Header>
5: <s:Body>
6: <s:Fault>
7: <s:Code>
8: <s:Value>s:Sender</s:Value>
9: </s:Code>
10: <s:Reason>
11: <s:Text xml:lang="zh-CN">被除數y不能為零!</s:Text>
12: </s:Reason>
13: <s:Detail>
14: <CalculationError xmlns="http://www.artech.com/" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
15: <Message>被除數y不能為零!</Message>
16: <Operation>Divide</Operation>
17: </CalculationError>
18: </s:Detail>
19: </s:Fault>
20: </s:Body>
21: </s:Envelope>
錯誤契約作為服務描述的一部分,會參與到描述服務的元數據(Metadata)中。當服務元數據通過WSDL的形式被發布的時候,作為對操作的描述的錯誤契約體現在WSDL的<wsdl:portType>/<wsdl:operation>/<wsdl:fault>節點。下面一段XML代表CalculatorService的WDSL:
1: <?xml version="1.0" encoding="utf-8"?>
2: <wsdl:definitions name="CalculatorService" targetNamespace="http://www.artech.com/" >
3: <wsdl:import namespace="http://tempuri.org/" location="http://127.0.0.1:3721/calculatorservice/mex?wsdl=wsdl0"/>
4: <wsdl:types>
5: <xs:schema elementFormDefault="qualified" targetNamespace="http://www.artech.com/" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://www.artech.com/">
6: <xs:element name="Divide">
7: <xs:complexType>
8: <xs:sequence>
9: <xs:element minOccurs="0" name="x" type="xs:int"/>
10: <xs:element minOccurs="0" name="y" type="xs:int"/>
11: </xs:sequence>
12: </xs:complexType>
13: </xs:element>
14: <xs:element name="DivideResponse">
15: <xs:complexType>
16: <xs:sequence>
17: <xs:element minOccurs="0" name="DivideResult" type="xs:int"/>
18: </xs:sequence>
19: </xs:complexType>
20: </xs:element>
21: <xs:complexType name="CalculationError">
22: <xs:sequence>
23: <xs:element minOccurs="0" name="Message" nillable="true" type="xs:string"/>
24: <xs:element minOccurs="0" name="Operation" nillable="true" type="xs:string"/>
25: </xs:sequence>
26: </xs:complexType>
27: <xs:element name="CalculationError" nillable="true" type="tns:CalculationError"/>
28: </xs:schema>
29: </wsdl:types>
30: <wsdl:message name="ICalculator_Divide_InputMessage">
31: <wsdl:part name="parameters" element="tns:Divide"/>
32: </wsdl:message>
33: <wsdl:message name="ICalculator_Divide_OutputMessage">
34: <wsdl:part name="parameters" element="tns:DivideResponse"/>
35: </wsdl:message>
36: <wsdl:message name="ICalculator_Divide_CalculationErrorFault_FaultMessage">
37: <wsdl:part name="detail" element="tns:CalculationError"/>
38: </wsdl:message>
39: <wsdl:portType name="ICalculator">
40: <wsdl:operation name="Divide">
41: <wsdl:input wsaw:Action="http://www.artech.com/ICalculator/Divide" message="tns:ICalculator_Divide_InputMessage"/>
42: <wsdl:output wsaw:Action="http://www.artech.com/ICalculator/DivideResponse" message="tns:ICalculator_Divide_OutputMessage"/>
43: <wsdl:fault wsaw:Action="http://www.artech.com/ICalculator/DivideCalculationErrorFault" name="CalculationErrorFault" message="tns:ICalculator_Divide_CalculationErrorFault_FaultMessage"/>
44: </wsdl:operation>
45: </wsdl:portType>
46: <wsdl:service name="CalculatorService">
47: <wsdl:port name="CustomBinding_ICalculator" binding="i0:CustomBinding_ICalculator">
48: <soap12:address location="http://127.0.0.1:3721/calculatorservice"/>
49: <wsa10:EndpointReference> <wsa10:Address>http://127.0.0.1:3721/calculatorservice</wsa10:Address>
50: </wsa10:EndpointReference>
51: </wsdl:port>
52: </wsdl:service>
53: </wsdl:definitions>
對於錯誤契約的應用,還有一點需要特別說明:不僅僅是將自定義的錯誤明細類型(比如CalculationError)應用到相應的操作契約上,你需要顯失地利用FaultContractAttribute特性將其應用到服務契約接口或者類中相應的操作方法上面,對於一些基元類型,比如Int32,String等,你也需要這樣。也即是說,同樣對於我們的計算服務的例子,如果服務端試圖通過拋出一個FaultException<string>來提供錯誤(如下面的代碼所示),客戶端最後捕獲到的僅僅是一個FaultException異常,而非FaultException<string>異常。
1: using System.ServiceModel;
2: using Artech.WcfServices.Contracts;
3: namespace Artech.WcfServices.Services
4: {
5: public class CalculatorService : ICalculator
6: {
7: public int Divide(int x, int y)
8: {
9: if (0 == y)
10: {
11: throw new FaultException<string>("被除數y不能為零!", "被除數y不能為零!");
12: }
13: return x / y;
14: }
15: }
16: }
在這種情況下,你需要做的仍然是在相應的操作上面,通過應用FaultContractAttribute特性指定String類型作為其DetailType,如下面的代碼所示:
1: using System.ServiceModel;
2: namespace Artech.WcfServices.Contracts
3: {
4: [ServiceContract(Namespace = "http://www.artech.com/")]
5: public interface ICalculator
6: {
7: [OperationContract]
8: [FaultContract(typeof(string))]
9: int Divide(int x, int y);
10: }
11: }
從FaultContractAttribute的定義我們可以看出,該特性可以在同一個目標對象上面多次應用(AllowMultiple = true)。這也很好理解:對於同一個服務操作,可能具有不同的異常場景,在不同的情況下,需要拋出不同的異常。
1: [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
2: public sealed class FaultContractAttribute : Attribute
3: {
4: //省略成員
5: }
但是,如果你在同一個操作方法上面應用了多了FaultContractAttribute特性的時候,需要遵循一系列的規則,我們將在《WCF基本異常處理模式(下篇)》中進行逐條介紹。