最近一直在负责公司内部框架的升级工作,今天对一个小问题进行了重新思考——时间的处理。具体来说,是如何有效地进行时间的处理以提供对跨时区的支持。对于一个分布式的应用来说,倘若客户端和服务端部署与不同的地区,在对时间进行处理的时候,就需要考虑时区的问题。以我们现在的一个项目为例,这是一个为澳大利亚某机构开发的一个基于Smart Client应用(Windows Form客户端),服务器部署于墨尔本,应用的最终用户可能需要跨越不同的州。澳洲地广人稀,不同的州也有可能会跨越不同的时区。假设数据库并不支持对时区的区分,服务端需要对针对客户端所在的时区对时间进行相应的处理。不过,对该问题解决方案的介绍我会放在后续的文章中,在这里我们先来介绍一些基础性的内容——谈谈我们熟悉的System.DateTime类型。
一、你是否知道System.DateTimeKind?
System.DateTime类型,我们再熟悉不过。顺便说一下,这个类型不是class,而是一个struct,换言之它是值类型,而不是引用类型。DateTime处理包含我们熟悉的年、月、日、时、分、秒和毫秒等基本属性之外,还具有一个重要的表示时间类型(Kind)的属性:Kind。该属性的类型为System.DateTimeKind枚举。DateTimeKind定义如下,它具有三个枚举值:Unspecified、Utc和Local。后两个分别表示UTC(格林威治时间)和本地时间。Unspecified顾名思义,就是尚未指定具体类型,这是默认值。
1: [Serializable, ComVisible(true)]
2: public enum DateTimeKind
3: {
4: Unspecified,
5: Utc,
6: Local
7: }
在DateTime类型中,表示时间类型的Kind属性是只读的,只能在构造函数中指定。相关构造函数和Kind属性的定义如下面的代码片断所示:
1: [Serializable]
2: public struct DateTime
3: {
4: //Others...
5: public DateTimeKind Kind { get; }
6:
7: public DateTime(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind);
8: public DateTime(int year, int month, int day, int hour, int minute, int second, int millisecond, DateTimeKind kind);
9: public DateTime(int year, int month, int day, int hour, int minute, int second, int millisecond, Calendar calendar, DateTimeKind kind);
10: }
虽然,Kind属性是只读的,但是我们还用另外一中设定Kind的方式,那就是调用DateTime的静态方法的SpecifyKind。该方法不会真正去修改一个现有DateTime对象的Kind属性,而是会重新创建一个新的DateTime对象。方法返回的对象具有和指定时间相同的基本属性(年、月、日、时、分、秒和毫秒),该DateTime对象具有你指定的DateTimeKind值。
1: public struct DateTime
2: {
3: //Others...
4: public static DateTime SpecifyKind(DateTime value, DateTimeKind kind);
5: }
二、几个常用DateTime对象的DateTimeKind
处理直接通过构造函数构建DateTime对象之外,我们还经常用到DateTime的几个静态只读属性去获取一些特殊的时间,比如Now、UtcNow、MinValue和MaxValue等,那么这些DateTime对象的DateTimeKind又是什么呢?
- 当我们通过构造函数创建一个DateTime对象的时候,Kind默认为DateTimeKind.Unspecified。
- DateTime.Now表示当前系统时间,Kind属性值为DateTimeKind.Local,所以DateTime.Now应该是DateTime.LocalNow;
- 而DateTime.UtcNow返回以UTC表示的当前时间,毫无疑问,Kind属性自然是DateTimeKind.Utc;
- DateTime.MinValue和DateTime.MaxValue表示的DateTime所能表示的最大范围,它们的Kind属性为DateTimeKind.Unspecified。
上面列表对几个常用DateTime对象Kind属性的描述可以通过下面的程序来证实:
1: DateTime endOfTheWorld = new DateTime(2012, 12, 21);
2: Console.WriteLine("endOfTheWorld.Kind = {0}", endOfTheWorld.Kind);
3: Console.WriteLine("DateTime.SpecifyKind(endOfTheWorld, DateTimeKind.Utc).Kind = {0}",
4: DateTime.SpecifyKind(endOfTheWorld, DateTimeKind.Utc).Kind);
5: Console.WriteLine("endOfTheWorld.Kind = {0}", endOfTheWorld.Kind);
6: Console.WriteLine("DateTime.Now.Kind = {0}", DateTime.Now.Kind);
7: Console.WriteLine("DateTime.UtcNow.Kind = {0}", DateTime.UtcNow.Kind);
8: Console.WriteLine("DateTime.MinValue.Kind = {0}", DateTime.MinValue.Kind);
9: Console.WriteLine("DateTime.MaxValue.Kind = {0}", DateTime.MaxValue.Kind);
输出结果:
1: endOfTheWorld.Kind = Unspecified
2: DateTime.SpecifyKind(endOfTheWorld, Dat
3: endOfTheWorld.Kind = Unspecified
4: DateTime.Now.Kind = Local
5: DateTime.UtcNow.Kind = Utc
6: DateTime.MinValue.Kind = Unspecified
7: DateTime.MaxValue.Kind = Unspecified
三、DateTime的对等性问题
接下来,我们来谈谈另外一个比较有意思的问题——两个DateTime对象对等性。在这之前,我首先提出这样一个问题:“如果两个DateTime对象相等,是否意味着它们表示同一个时间点?”我想有人会认为是。但是答案是“不一定”,我们可以举一个反例。在下面的程序中,我创建了三个DateTime对象,年、月、日、时、分、秒均是相同的,但Kind分分别指定为DateTimeKind.Local、DateTimeKind.Unspecified和DateTimeKind.Utc。
1: DateTime endOfTheWorld1 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Local);
2: DateTime endOfTheWorld2 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Unspecified);
3: DateTime endOfTheWorld3 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Utc);
4:
5: Console.WriteLine("endOfTheWorld1 == endOfTheWorld2 = {0}", endOfTheWorld1 == endOfTheWorld2);
6: Console.WriteLine("endOfTheWorld2 == endOfTheWorld3 = {0}", endOfTheWorld2 == endOfTheWorld3);
由于我们处于东8区,基于DateTimeKind.Local的endOfTheWorld1和基于DateTimeKind.Utc的endOfTheWorld3,不可能表示的是同一个时刻。但是从下面的输出结果来看,它们却是“相等的”,不但如此,Kind为Unspecified的endOfTheWorld2也和这两个时间对象相等。
1: endOfTheWorld1 == endOfTheWorld2 = True
2: endOfTheWorld2 == endOfTheWorld3 = True
由此可见,DateTimeKind对等性判断和DateTimeKind无关,那么在内部是如何进行判断的呢?要回答这个问题,这就要谈谈DateTime另外一个重要的属性——Ticks了。该属性定义如下,是DateTime的只读属性,类型为长整型,表示该DateTime对象通过日期和时间体现出来的计时周期数。每个计时周期表示一百纳秒,即一千万分之一秒。1 毫秒内有 10,000 个计时周期。此属性的值表示自公元元年( 0001 年) 1 月 1 日午夜 12:00:00(表示 DateTime.MinValue)以来经过的以100 纳秒为间隔的间隔数。
1: public struct DateTime
2: {
3: //Others...
4: public long Ticks { get; }
5: }
注意,这里的基准时间0001 年 1 月 1 日午夜 12:00:00,并没有说是一定是UTC时间,所以Ticks和DateTimeKind无关,这里通过下面的实例看出来:
1: DateTime endOfTheWorld1 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Local);
2: DateTime endOfTheWorld2 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Unspecified);
3: DateTime endOfTheWorld3 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Utc);
4:
5: Console.WriteLine("endOfTheWorld1.Ticks = {0}", endOfTheWorld1.Ticks);
6: Console.WriteLine("endOfTheWorld2.Ticks = {0}", endOfTheWorld2.Ticks);
7: Console.WriteLine("endOfTheWorld3.Ticks = {0}", endOfTheWorld3.Ticks);
从下面的输出结果我们不难看出,上面创建的具有不同DateTimeKind的三个DateTime的Ticks属性的值都是相等的。实际上,DateTime的对等性判断就是通过Ticks的大小来判断的。
1: endOfTheWorld1.Ticks = 634917312000000000
2: endOfTheWorld2.Ticks = 634917312000000000
3: endOfTheWorld3.Ticks = 634917312000000000
我们经常说的UTC时间和本地时间之间的相互转化,实际上指的就是将一个具有某种DateTimeKind的DateTime对象转化成具有另外一种DateTimeKind的DateTime对象,并且确保两个DateTime对象对象表示相同的时间点。关于时间转换的实现,我们有很多不同的选择。
四、通过DateTime类型的ToLocalTime和ToUniversalTime方法实现UTC和Local的转换
对基于三种不同DateTimeKind的DateTime对象之间的转化,最方便的就是直接采用DateTime类型的两个对应的方法:ToLocalTime和ToUniversalTime,这两个方法的定义如下。
1: public struct DateTime
2: {
3: //Others...
4: public DateTime ToLocalTime();
5: public DateTime ToUniversalTime();
6: }
实际上我们所说的不同DateTimeKind之间的DateTime之间的转化主要包括两个方面:将一个DateTimeKind.Local(或者DateTimeKind.Unspecified)时间转换成DateTimeKind.Utc时间,或者将DateTimeKind.Utc(或者DateTimeKind.Unspecifed时间)转换成DateTimeKind.Local时间。为了深刻地理解两种不同转换采用的转化规则,我写了如下一段程序:
1: DateTime endOfTheWorld1 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Local);
2: DateTime endOfTheWorld2 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Unspecified);
3: DateTime endOfTheWorld3 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Utc);
4:
5: Console.WriteLine("endOfTheWorld1.ToLocalTime() = {0}",endOfTheWorld1.ToLocalTime());
6: Console.WriteLine("endOfTheWorld2.ToLocalTime() = {0}", endOfTheWorld2.ToLocalTime());
7: Console.WriteLine("endOfTheWorld3.ToLocalTime() = {0}\n", endOfTheWorld3.ToLocalTime());
8:
9: Console.WriteLine("endOfTheWorld1.ToUniversalTime() = {0}", endOfTheWorld1.ToUniversalTime());
10: Console.WriteLine("endOfTheWorld2.ToUniversalTime() = {0}", endOfTheWorld2.ToUniversalTime());
11: Console.WriteLine("endOfTheWorld3.ToUniversalTime() = {0}", endOfTheWorld3.ToUniversalTime());
对于DataTimeKind为Utc和Local之间的转化,没有什么可以说得,就是一个基于时差的换算而已。大家容易忽视的是DataTimeKind.Unspecifed时间分别向其他两种DateTimeKind时间的转换问题。从下面的输出我们可以看出,当DateTimeKind.Unspecifed时间向DateTimeKind.Local转换的时候,实际上是当成DateTimeKind.Utc时间;而向DateTimeKind.Utc转换的时候,则当成是DateTimeKind.Local。顺便补充一下:不论被转换的时间属于怎么的DateTimeKind,调用ToLocalTime和ToUniversalTime方法的返回的时间的Kind属性总是DateTimeKind.Local和DateTimeKind.Utc,两者之间的转换并不只是年月日和时分秒的改变。
1: endOfTheWorld1.ToLocalTime() = 12/21/2012 12:00:00 AM
2: endOfTheWorld2.ToLocalTime() = 12/21/2012 8:00:00 AM
3: endOfTheWorld3.ToLocalTime() = 12/21/2012 8:00:00 AM
4:
5: endOfTheWorld1.ToUniversalTime() = 12/21/2012 4:00:00 PM
6: endOfTheWorld2.ToUniversalTime() = 12/21/2012 4:00:00 PM
7: endOfTheWorld3.ToUniversalTime() = 12/21/2012 12:00:00 AM
五、通过TimeZoneInfo实现Utc和Local的转换
上面提供的方式虽然简单,但是功能上确有局限,因为转换的过程是基于本机当前的时区。这解决不了我在开篇介绍的应用场景:服务端根据访问者所在的时区(而不是本机的时区)进行时间的转换。换句话说,我们需要能够基于任意时区的时间转换方式,这就可以通过System.TimeZoneInfo。
TimeZoneInfo实际上对原来System.TimeZone类型的一个改进。它是一个可序列化的类型(这一点在分布式场景中进行基于时区的时间处理实现非常重要),表示具体某个时区的信息。它提供了一系列静态方法供我们对某个DateTime对象进行基于指定TimeZoneInfo的时间转换,在这我们介绍我们常用的2个:ConvertTimeFromUtc和ConvertTimeToUtc。前者将一个DateTimeKind.Utc或者Unspecified的DateTime时间转换成基于指定时区的DateTimeKind.Local时间;后者则将一个基于指定时区的DateTimeKind.Local或者DateTimeKind.Unspecified时间象转化成一DateTimeKind.Utc时间。此外,TimeZoneInfo还提供了两个静态属性Local和Utc表示本地时区和格林威治时区。
1: [Serializable]
2: public sealed class TimeZoneInfo : IEquatable<TimeZoneInfo>, ISerializable, IDeserializationCallback
3: {
4: //Others...
5: public static DateTime ConvertTimeFromUtc(DateTime dateTime, TimeZoneInfo destinationTimeZone);
6: public static DateTime ConvertTimeToUtc(DateTime dateTime, TimeZoneInfo sourceTimeZone);
7:
8: public static TimeZoneInfo Local { get; }
9: public static TimeZoneInfo Utc { get; }
10: }
我们照例来做个试验。还是刚才创建的三个DateTime对象,现在我们分别调用ConvertTimeFromUtc将DateTimeKind.Utc或者DateTimeKind.Unspecified时间转换成DateTimeKind.Local时间;然后将调用ConvertTimeToUtc将DateTimeKind.Local或者DateTimeKind.Unspecified时间转换成DateTimeKind.Utc时间。
1: DateTime endOfTheWorld1 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Local);
2: DateTime endOfTheWorld2 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Unspecified);
3: DateTime endOfTheWorld3 = new DateTime(2012, 12, 21, 0, 0, 0, DateTimeKind.Utc);
4:
5: Console.WriteLine("TimeZoneInfo.ConvertTimeFromUtc(endOfTheWorld2,TimeZoneInfo.Local) = {0}",
6: TimeZoneInfo.ConvertTimeFromUtc(endOfTheWorld2, TimeZoneInfo.Local));
7: Console.WriteLine("TimeZoneInfo.ConvertTimeFromUtc(endOfTheWorld3,TimeZoneInfo.Local) = {0}\n",
8: TimeZoneInfo.ConvertTimeFromUtc(endOfTheWorld3, TimeZoneInfo.Local));
9:
10: Console.WriteLine("TimeZoneInfo.ConvertTimeToUtc(endOfTheWorld1,TimeZoneInfo.Local) = {0}",
11: TimeZoneInfo.ConvertTimeToUtc(endOfTheWorld1, TimeZoneInfo.Local));
12: Console.WriteLine("TimeZoneInfo.ConvertTimeToUtc(endOfTheWorld2,TimeZoneInfo.Local) = {0}",
13: TimeZoneInfo.ConvertTimeToUtc(endOfTheWorld2, TimeZoneInfo.Local));
同上面进行的转换方式一样,在向DateTimeKind.Utc时间进行转换的时候,DateTimeKind.Unspecifed时间被当成DateTimeKind.Local;而在向DateTimeKind.Local时间转换的时候,DateTimeKind.Unspecifed则被当成DateTimeKind.Utc时间。
1: TimeZoneInfo.ConvertTimeFromUtc(endOfTheWorld2,TimeZoneInfo.Local) = 12/22/2012 8:00:00 AM
2: TimeZoneInfo.ConvertTimeFromUtc(endOfTheWorld3,TimeZoneInfo.Local) = 12/22/2012 8:00:00 AM
3:
4: TimeZoneInfo.ConvertTimeToUtc(endOfTheWorld1,TimeZoneInfo.Local) = 12/21/2012 4:00:00 PM
5: TimeZoneInfo.ConvertTimeToUtc(endOfTheWorld2,TimeZoneInfo.Local) = 12/21/2012 4:00:00 PM
ConvertTimeFromUtc和ConvertTimeToUtc方法在转换的时候,如果发现被转换的时间和需要转化时间具有相同的DateTimeKind会抛出异常。也就是说,我们不能调用ConvertTimeFromUtc方法并传入DateTimeKind.Local时间,也不能调用ConvertTimeToUtc方法并传入DateTimeKind.Urc时间。如右图所式,我们将一个DateTimeKind.Utc时间(DateTime.UtcNow)传入ConvertTimeToUtc方法,结果抛出一个ArgumentException异常。错误消息为:“The conversion could not be completed because the supplied DateTime did not have the Kind property set correctly. For example, when the Kind property is DateTimeKind.Local, the source time zone must be TimeZoneInfo.Local. Parameter name: sourceTimeZone”。