如何解决分布式系统中的跨时区问题[实例篇]
关于如何解决分布式系统中的跨时区问题,上一篇详细介绍了解决方案的实现原理,在这一篇中我们通过一个完整的例子来对这个问题进行深入探讨。尽管《原理篇》中介绍了那么多,解决方案的本质就是:在进行服务调用过程中将客户端的时区信息作为上下文传入服务端,并以此作为时间转换的依据。我们首先定一个具体的类型来定义包含时区信息的上下文类型,我们将这个类型起名为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: