如何解決分布式系統中的跨時區問題[實例篇]
關于如何解決分布式系統中的跨時區問題,上一篇詳細介紹了解決方案的實現原理,在這一篇中我們通過一個完整的例子來對這個問題進行深入探討。盡管《原理篇》中介紹了那么多,解決方案的本質就是:在進行服務調用過程中將客戶端的時區信息作為上下文傳入服務端,并以此作為時間轉換的依據。我們首先定一個具體的類型來定義包含時區信息的上下文類型,我們將這個類型起名為ApplicationContext。
一、通過CallContext實現ApplicationContext
在《通過WCF擴展實現Context信息的傳遞》一文中,我通過HttpSessionState和CallContext實現了一個ApplicationContext類,為ASP.NET和其他類型的應用提供上下文信息的容器。在這里進行了簡化,僅僅實現了基于CallContext的部分。這樣一個ApplicationContext類型定義如下:
1: [CollectionDataContract(Namespace="http://www.artech.com/")]
2: public class ApplicationContext:Dictionary<string, object>
3: {
4: internal const string contextHeaderName = "ApplicationContext";
5: internal const string contextHeaderNamespace = "http://www.artech.com/";
6:
7: private ApplicationContext() { }
8: public static ApplicationContext Current
9: {
10: get
11: {
12: if (null == CallContext.GetData(typeof(ApplicationContext).FullName))
13: {
14: lock (typeof(ApplicationContext))
15: {
16: if (null == CallContext.GetData(typeof(ApplicationContext).FullName))
17: {
18: var context = new ApplicationContext();
19: context.TimeZone = TimeZoneInfo.Local;
20: CallContext.SetData(typeof(ApplicationContext).FullName, context);
21: }
22: }
23: }
24:
25: return (ApplicationContext)CallContext.GetData(typeof(ApplicationContext).FullName);
26: }
27: set
28: {
29: CallContext.SetData(typeof(ApplicationContext).FullName, value);
30: }
31: }
32: public TimeZoneInfo TimeZone
33: {
34: get
35: {
36: return TimeZoneInfo.FromSerializedString((string)this["__TimeZone"]);
37: }
38: set
39: {
40: this["__TimeZone"] = value.ToSerializedString();
41: }
42: }
43:
44: public static void Clear()
45: {
46: CallContext.FreeNamedDataSlot(typeof(ApplicationContext).FullName);
47: }
48: }
ApplicationContext繼承自Dictionary<string,object>類型,并被定義成集合數據契約。我們采用Singleton的方式來定義ApplicationContext,當前上下文通過靜態方法Current獲取。而Current屬性返回的是通過CallContext的GetData方法獲取,并且Key為類型的全名。便是當前時區的TimeZone屬性的類型為TimeZoneInfo,通過序列化和反序列對當前時區進行設置和獲取。Clear則將整個ApplicationContext對象從CallContext中移除。
二、創建一個用于時間轉化的DateTimeConverter
服務端需要進行兩種方式的時間轉化,其一是將可戶端傳入的時間轉換成UTC時間,其二就是將從數據庫獲取的UTC時間轉化成基于當前時區上下文的Local時間。為此我定義了如下一個靜態的幫助類DateTimeConverter專門進行這兩方面的時間轉換,而時間轉換依據的時區來源于當前ApplicationContext的TimeZone屬性。
1: public static class DateTimeConverter
2: {
3: public static DateTime ConvertTimeToUtc(DateTime dateTime)
4: {
5: if(dateTime.Kind == DateTimeKind.Utc)
6: {
7: return dateTime;
8: }
9: return TimeZoneInfo.ConvertTimeToUtc(dateTime, ApplicationContext.Current.TimeZone);
10: }
11:
12: public static DateTime ConvertTimeFromUtc(DateTime dateTime)
13: {
14: if (dateTime.Kind == DateTimeKind.Utc)
15: {
16: return dateTime;
17: }
18: return TimeZoneInfo.ConvertTimeFromUtc(dateTime, ApplicationContext.Current.TimeZone);
19: }
20: }
三、通過WCF擴展實現ApplicationContext的傳播
讓當前的ApplicationContext在每次服務調用時自動傳遞到服務端,并作為服務端當前的ApplicationContext,整個過程通過兩個步驟來實現:其一是客戶端將當前ApplicationContext對象進行序列化,并置于出棧消息的報頭(SOAP Header);其二是服務在接收到請求消息時從入棧消息中提取該報頭并進行反序列化,最終將生成的對象作為服務端當前的ApplicationContext。
客戶端對當前ApplicationContext輸出可以通過WCF的MessageInspector對象來完成。為此,我們實現了IClientMessageInspector接口定義了如下一個自定義的MessageInspector:ContextMessageInspector。在BeforeSendRquest方法中,基于當前ApplicationContext創建了一個MessageHeader,并將其插入出棧消息的報頭集合中。該消息報頭對應的命名空間和名稱為定義在ApplicationContext中的兩個常量。
1: public class ContextMessageInspector:IClientMessageInspector
2: {
3: public void AfterReceiveReply(ref Message reply, object correlationState) { }
4: public object BeforeSendRequest(ref Message request, IClientChannel channel)
5: {
6: MessageHeader<ApplicationContext> header = new MessageHeader<ApplicationContext>(ApplicationContext.Current);
7: request.Headers.Add(header.GetUntypedHeader(ApplicationContext.contextHeaderName, ApplicationContext.contextHeaderNamespace));
8: return null;
9: }
10: }
相應地,服務端對ApplicationContext的接收和設置可以通過WCF的CallContextInitializer來實現。為此,我們實現了ICallContextInitializer接口定義了如下一個自定義的CallContextInitializer:ContextCallContextInitializer。在BeforeInvoke方法中,通過相同的命名空間和名稱從入棧消息中提取ApplicationConntext作為當前的ApplicationContext。為了避免當前ApplicationContext用在下一次服務請求處理中 (ApplicationContext保存在當前線程的TLS中,而WCF采用線程池的機制處理客戶請求),我們在AfterInvoke方法中調用Clear方法將當前ApplicationContext清除。
1: public class ContextCallContextInitializer: ICallContextInitializer
2: {
3: public void AfterInvoke(object correlationState)
4: {
5: ApplicationContext.Clear();
6: }
7: public object BeforeInvoke(InstanceContext instanceContext, IClientChannel channel, Message message)
8: {
9: var index = message.Headers.FindHeader(ApplicationContext.contextHeaderName, ApplicationContext.contextHeaderNamespace);
10: if (index >= 0)
11: {
12: ApplicationContext.Current = message.Headers.GetHeader<ApplicationContext>(index);
13: }
14: return null;
15: }
16: }
用于ApplicationContext發送的ContextMessageInspector,和用于ApplicationContext接收的ContextCallContextInitializer,最終我們通過一個EndpointBehavior被應用到WCF運行時框架中。為此我們定義了如下一個自定義的EndpointBehavior:ContextBehavior。
1: public class ContextBehavior : IEndpointBehavior
2: {
3: public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
4: public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
5: {
6: clientRuntime.MessageInspectors.Add(new ContextMessageInspector());
7: }
8: public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher)
9: {
10: foreach (DispatchOperation operation in endpointDispatcher.DispatchRuntime.Operations)
11: {
12: operation.CallContextInitializers.Add(new ContextCallContextInitializer());
13: }
14: }
15: public void Validate(ServiceEndpoint endpoint) { }
16: }
由于ContextBehavior這個終結點行為需要通過培植的方式來使用,我們需要定義它的BehaviorExtensionElement(本質上是一個配置元素):
1: public class ContextBehaviorElement : BehaviorExtensionElement
2: {
3: public override Type BehaviorType
4: {
5: get { return typeof(ContextBehavior); }
6: }
7: protected override object CreateBehavior()
8: {
9: return new ContextBehavior();
10: }
11: }
四、建立一個Alertor Service來模擬跨時區場景
到目前為止,所有基礎性編程已經完成,我們現在創建一個具體的分布式應用來使用上面定義的類型。為此,我們模擬一個用戶提醒服務(Alertor Service):我們為某個人創建相應的通知或者提醒,比如什么時候開會,什么時候見客戶之類的。首先,所有的Alert條目被最終保存在數據庫中,對應的表的結構如右圖所示。四個字段分別表示Alert的Id、被通知的人、消息和被觸發的時間。這里的表示時間的類型就是我們常用的datetime(不具有時區偏移量信息)。
與這個數據表結構相對應,一個Alert類型被創建出來表示一個具體的Alert條目。Alert被定義成數據契約,下面的代碼給出了該類的具體定義。
1: [DataContract]
2: public class Alert
3: {
4: [DataMember]
5: public string Id { get; private set; }
6: [DataMember]
7: public string Person { get; private set; }
8: [DataMember]
9: public string Message { get; private set; }
10: [DataMember]
11: public DateTime Time { get; set; }
12: public Alert(string persone, string message, DateTime time)
13: {
14: this.Id = Guid.NewGuid().ToString();
15: this.Person = persone;
16: this.Message = message;
17: this.Time = time;
18: }
19: }
然后我們定義服務契約:IAlert接口。該結構定義了兩個操作成員,CreateNewAlert用于創建一個信息的Alert條目;而GetAlerts則用于獲取某個人對應的所有Alert列表。
1: [ServiceContract(Namespace = "http://www.artech.com/")]
2: public interface IAlertor
3: {
4: [OperationContract]
5: void CreateNewAlert(Alert alert);
6: [OperationContract]
7: IEnumerable<Alert> GetAlerts(string person);
8: }
下面是實現上面這個服務契約的具體服務的實現:AlertorService。DbHelper是我創建的一個簡單的進行數據操作的幫助類,AlertorService用它來執行一段參數化的SQL語句,以及執行一段SELECT語句返回一個DbDataReader。對此你無需過多關注沒,你需要關注的是在CreateNewAlert方法中,在進行數據保存之前先調用了DateTimeConverter的ConvertTimeToUtc將基于客戶端時區的本地時間轉化成了UTC時間;而在GetAlerts方法中在將從數據庫中返回的Alert列表返回給客戶端的時候,調用了DateTimeConverter的ConvertTimeFromUtc將UTC時間轉化成了基于客戶端時區的本地時間。
1: public class AlertorService:IAlertor
2: {
3: private DbHelper helper = new DbHelper("TestDb");
4: public void CreateNewAlert(Alert alert)
5: {
6: alert.Time = DateTimeConverter.ConvertTimeToUtc(alert.Time);
7: var parameters = new Dictionary<string, object>();
8: parameters.Add("@id", alert.Id);
9: parameters.Add("@person", alert.Person);
10: parameters.Add("@message", alert.Message);
11: parameters.Add("@time", alert.Time);
12: helper.ExecuteNoQuery("INSERT INTO dbo.Alert(Id, Person, Message, Time) VALUES(@id,@person,@message,@time)", parameters);
13: }
14: public IEnumerable<Alert> GetAlerts(string person)
15: {
16: var parameters = new Dictionary<string, object>();
17: parameters.Add("@person", person);
18: using (var reader = helper.ExecuteReader("SELECT Person, Message, Time FROM dbo.Alert WHERE Person = @person", parameters))
19: {
20: while (reader.Read())
21: {
22: yield return new Alert(reader[0].ToString(),reader[1].ToString(),DateTimeConverter.ConvertTimeFromUtc( (DateTime)reader[2]));
23: }
24: }
25: }
26: }
在對上面的服務進行寄宿的時候,采用了如下的配置,將上面創建的ContextBehavior終結點行為應用到了相應的終結點上。
1: <?xml version="1.0" encoding="utf-8" ?>
2: <configuration>
3: <system.serviceModel>
4: <behaviors>
5: <endpointBehaviors>
6: <behavior name="contextBehavior">
7: <contextPropagtion />
8: </behavior>
9: </endpointBehaviors>
10: </behaviors>
11: <extensions>
12: <behaviorExtensions>
13: <add name="contextPropagtion" type="Artech.TimeConversion.ContextBehaviorElement, Artech.TimeConversion.Lib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
14: </behaviorExtensions>
15: </extensions>
16: <services>
17: <service name="Artech.TimeConversion.Service.AlertorService">
18: <endpoint address="http://127.0.0.1:3721/alertservice" behaviorConfiguration="contextBehavior"
19: binding="ws2007HttpBinding" bindingConfiguration="" contract="Artech.TimeConversion.Service.Interface.IAlertor" />
20: </service>
21: </services>
22: </system.serviceModel>
23: </configuration>
客戶端在通過如下的配置將ContextBehavior應用到用于服務調用的終結點上:
1: <?xml version="1.0" encoding="utf-8" ?>
2: <configuration>
3: <system.serviceModel>
4: <behaviors>
5: <endpointBehaviors>
6: <behavior name="contextBehavior">
7: <contextPropagation />
8: </behavior>
9: </endpointBehaviors>
10: </behaviors>
11: <client>
12: <endpoint address="http://127.0.0.1:3721/alertservice" behaviorConfiguration="contextBehavior"
13: binding="ws2007HttpBinding" bindingConfiguration="" contract="Artech.TimeConversion.Service.Interface.IAlertor"
14: name="alertservice" />
15: </client>
16: <extensions>
17: <behaviorExtensions>
18: <add name="contextPropagation" type="Artech.TimeConversion.ContextBehaviorElement, Artech.TimeConversion.Lib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
19: </behaviorExtensions>
20: </extensions>
21: </system.serviceModel>
22: </configuration>
而下面的代碼代表了客戶端程序:我們為某個人(Foo)創建了三個Alert,主要這里指定的時間的DateTimeKind為默認的DateTimeKind.Unspecified。然后調用服務或者這三條Alert對象,并將消息的時間打印出來。
1: public class Program
2: {
3: static void Main(string[] args)
4: {
5: CreateAlert("Foo", "Weekly Meeting with Testing Team", new DateTime(2010, 9, 1, 8, 0, 0));
6: CreateAlert("Foo", "Architecture and Design Training", new DateTime(2010, 9, 2, 8, 0, 0));
7: CreateAlert("Foo", "New Stuff Orientaion", new DateTime(2010, 9, 3, 8, 0, 0));
8:
9: foreach (var alert in GetAlerts("Foo"))
10: {
11: Console.WriteLine("Alert:\t{0}", alert.Message);
12: Console.WriteLine("Time:\t{0}\n", alert.Time);
13: }
14:
15: Console.Read();
16: }
17:
18: static IEnumerable<Alert> GetAlerts(string person)
19: {
20: using (ChannelFactory<IAlertor> channelFactory = new ChannelFactory<IAlertor>("alertservice"))
21: {
22: IAlertor alertor = channelFactory.CreateChannel();
23: using (alertor as IDisposable)
24: {
25: return alertor.GetAlerts(person);
26: }
27: }
28: }
29: static void CreateAlert(string person, string message, DateTime time)
30: {
31: Alert alert = new Alert(person, message, time);
32: using (ChannelFactory<IAlertor> channelFactory = new ChannelFactory<IAlertor>("alertservice"))
33: {
34: IAlertor alertor = channelFactory.CreateChannel();
35: using (alert as IDisposable)
36: {
37: alertor.CreateNewAlert(alert);
38: }
39: }
40: }
41: }
運行上面的程序之后。服務端數據庫中被添加的三條Alert紀錄對應的時間,會以UTC形式存儲。如左圖所示,數據表中的時間比我們指定的的時間早8個小時。
下面是客戶端的輸出結果,可見Alert的提醒時間依然是基于本地時區的時間,這達到了我們在《原理篇》提出的要求:客戶端應用根本不用考慮時區問題,就像是一個單純的本地應用一樣。客戶端調用服務傳入的時間是DateTimeKind.Local時間或者DateTimeKind.Unspecified時間,同理通過服務調用返回的時間也應該是基于客戶端所在時區的時間。
1: Alert: New Stuff Orientaion
2: Time: 9/3/2010 8:00:00 AM
3:
4: Alert: Weekly Meeting with Testing Team
5: Time: 9/1/2010 8:00:00 AM
6:
7: Alert: Architecture and Design Training
8: Time: 9/2/2010 8:00:00 AM
9: