我们要什么?

当一个应用的用户遍布全世界的时候,程序的代码少不了要和时区打交道。服务器端针对用户的定时任务需要定到用户所在时区的时。

在Glow Nurture中,比较典型的一个例子就是:如果用户没有记录服用Prenatal Vitamin,两天后晚上9点给用户发送程序内通知提醒服用Vitamin。
翻译成直白的程序需求就是:获取某用户所在时区某年月日21点对应服务器所在时区的时间戳。

Python提供了什么?

Python 提供了datetime, time, calendar模块,然后感谢Stuart Bishop stuart@stuartbishop.net, 我们还有pytz可以使用。

由于存在datetime模块,time模块,在datetime模块下又存在datetime类,time类,为避免阅读上的误解,以下说到time, datetime时指模块,datetime.time, datetime.datetime指datetime模块下的time类和datetime类。

datetime模块定义了如下类:

datetime.date     - 理想化的日期对象,假设使用格力高历,有year, month, day三个属性
datetime.time     - 理想化的时间对象,不考虑闰秒(即认为一天总是24*60*60秒),有hour, minute, second, microsecond, tzinfo五个属性
datetime.datetime     - datetime.date和datetime.time的组合
datetime.timedelta     - 后面我们会用到的类,表示两个datetime.date, datetime.time或者datetime.datetime之间的差。
datetime.tzinfo     - 时区信息

*Python 3.2开始提供了datetime.timezone类,不过我们暂时还是使用的2.7,后面代码均以2.7版本测试运行。

time模块提供了各种时间操作转换的方法。

calendar模块则是提供日历相关的方法。

pytz模块,使用Olson TZ Database解决了跨平台的时区计算一致性问题,解决了夏令时带来的计算问题。由于国家和地区可以自己选择时区以及是否使用夏令时,所以pytz模块在有需要的情况下得更新自己的时区以及夏令时相关的信息。比如当前pytz版本的OLSON_VERSON = ‘2013g’, 就是包括了Morocco可以使用夏令时。

如何正确为你所用

不是题外话的题外话,客户端必须正确收集用户的timezone信息。比较常见的一个错误是,保存用户所在时区的偏移值。比如对于中国的时区,保存了+8。这里其实丢失了用户所在的地区(同样的时间偏移,可能对应多个国家或者地区)。而且如果用户所在时区是有夏令时的话,在每年开始和结束夏令时的时候,这个偏移值都是要发生变化的。

我们可以通过pytz模块查看当前全球都有哪些timezone。这是一个挺长的list。我们可以找到自己所在的'Asia/Shanghai’。使用pytz.timezone(‘Asia/Shanghai’)构建一个tzinfo对象。

>>> import pytz
>>> pytz.all_timezones
[… 'Asia/Shanghai’, ...]
>>> pytz.timezone(‘Asia/Shanghai’)
<DstTzInfo 'Asia/Shanghai' LMT+8:06:00 STD>

我们开始要把timezone加入时间的转换里面了。

  • 首先,timestamp和datetime的转换。timestamp,一个数字,表示从UTC时间1970/01/01开始的秒数。
>>> from datetime import datetime
>>> datetime.fromtimestamp(0, pytz.timezone('UTC'))
datetime.datetime(1970, 1, 1, 0, 0, tzinfo=<UTC>)

>>> tz  = pytz.timezone('Asia/Shanghai')
>>> tz2 = pytz.timezone('US/Eastern')

>>> datetime.fromtimestamp(0, tz)
datetime.datetime(1970, 1, 1, 8, 0, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)

>>> datetime.fromtimestamp(0, tz2)
datetime.datetime(1969, 12, 31, 19, 0, tzinfo=<DstTzInfo 'US/Eastern' EST-1 day, 19:00:00 STD>)

我们可以看到timestamp是UTC绑定的。给定一个timestamp,构建datetime的时候无论传入的是什么时区,对应出来的结果都是同一个时间。
但是python里面这里有个坑。

>>> ts = 1408071830
>>> dt = datetime.fromtimestamp(ts, tz)
datetime.datetime(2014, 8, 15, 11, 3, 50, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)
>>> time.mktime(dt.timetuple())
1408100630.0
>>> dt.timetuple()
time.struct_time(tm_year=2014, tm_mon=8, tm_mday=15, tm_hour=11, tm_min=3, tm_sec=50, tm_wday=4, tm_yday=227, tm_isdst=0)
>>> dt.astimezone(pytz.utc)
datetime.datetime(2014, 8, 15, 3, 3, 50, tzinfo=<UTC>)
>>> time.mktime(dt.astimezone(pytz.utc).timetuple())
1408071830.0 

time模块的mktime方法支持从timetuple取得timestamp,datetime对象可以直接转换成timetuple。这时候直接使用time.mktime(dt.timetuple())看起来就是很自然的获取timestamp方法。但是我们注意到timetuple方法是直接把当前时间的年月日时分秒直接取出来的。所以这个转换过程在timetuple这个方法这一步丢了时区信息。根据timestamp的定义,正确的方法是把datetime对象利用asttimezone显式转换成UTC时间。

  • 第二,datetime和date以及time的关系
    datetime模块同时提供了datetime对象,time对象,date对象。他们之间的关系可以从如下代码简单看出来。
>>> d = datetime.date(2014, 8, 20)
>>> t = datetime.time(11, 30)
>>> dt = datetime.datetime.combine(d, t)
datetime.datetime(2014, 8, 20, 11, 30)
>>> dt.date()
datetime.date(2014, 8, 20)
>>> dt.time()
datetime.time(11, 30)

>>> dt = datetime.datetime.fromtimestamp(1405938446, pytz.timezone('UTC'))
datetime.datetime(2014, 7, 21, 10, 27, 26, tzinfo=<UTC>)
>>> dt.date()
datetime.date(2014, 7, 21)
>>> dt.time()
datetime.time(10, 27, 26)
>>> dt.timetz()
datetime.time(10, 27, 26, tzinfo=<UTC>)

>>> datetime.datetime.combine(dt.date(), dt.time())
datetime.datetime(2014, 7, 21, 10, 27, 26)
>>> datetime.datetime.combine(dt.date(), dt.timetz())
datetime.datetime(2014, 7, 21, 10, 27, 26, tzinfo=<UTC>)

简单说就是,datetime可以取得date和time对象,datetime和time对象可以带timezone信息。date和time对象可以使用datetime.datetime.combine合并获得datetime对象。

  • 第三,日期的加减
    datetime,date对象都可以使用timedelta来进行。
    直接看代码
>>> d1 = datetime.datetime(2014, 5, 20)
>>> d2 = d1+datetime.timedelta(days=1, hours=2)
>>> d1
datetime.datetime(2014, 5, 20, 0, 0)
>>> d2
datetime.datetime(2014, 5, 21, 2, 0)
>>> x = d2 - d1
>>> x
datetime.timedelta(1, 7200)
>>> x.seconds
7200
>>> x.days
1
  • 第四,如何对datetime对象正确设置timezone信息

先看代码。

>>> ddt1 = datetime.datetime(2014, 8, 20, 10, 0, 0, 0, pytz.timezone('Asia/Shanghai'))
>>> ddt1 
datetime.datetime(2014, 8, 20, 10, 0, tzinfo=<DstTzInfo 'Asia/Shanghai' LMT+8:06:00 STD>) 
>>> ddt2
 datetime.datetime(2014, 8, 20, 11, 0)

>>> ddt1.astimezone(pytz.utc)
datetime.datetime(2014, 8, 20, 1, 54, tzinfo=<UTC>)
>>> ddt2.astimezone(pytz.utc)
ValueError: astimezone() cannot be applied to a naive datetime

>>> tz = timezone('Asia/Shanghai')
>>> tz.localize(ddt1)
ValueError: Not naive datetime (tzinfo is already set)
>>> tz.localize(ddt2)
datetime.datetime(2014, 8, 20, 11, 0, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)

这里抛出来的ValueError,引入了一个naive datetime的概念。简单说naive datetime就是不知道时区信息的datetime对象。没有timezone信息的datetime理论上讲不能定位到具体的时间点。所以对于设定了timezone的datetime对象,可以使用astimezone方法将timezone设定为另一个。对于不包含timezone的datetime对象,使用timezone.localize方法设定timezone。

但是,这里有没有发现一个问题?我们明明设定的是11点整的,使用astimezone之后跑出来个54分是想怎样?

我们注意到,datetime直接传入timezone对象构建出来的带timezone的datetime对象和使用locallize方法构建出来的datetime对象,在打印出来的时候tzinfo显示有所不同,一个是LMT+8:06,一个是CST+8:00,不用说了,54分就搁这来的吧。LMT学名Local Mean Time,用于比较平均日出时间的。有兴趣的可以自己看看Shanghai和Urumqi的LMT时间。CST是China Standard Time,不用解释了。根据pytz的文档

Unfortunately using the tzinfo argument of the standard datetime constructors ‘’does not work’’ with pytz for many timezones.
It is safe for timezones without daylight saving transitions though, such as UTC:
The preferred way of dealing with times is to always work in UTC, converting to localtime only when generating output to be read by humans.
...
You can take shortcuts when dealing with the UTC side of timezone conversions. normalize() and localize() are not really necessary when there are no daylight saving time transitions to deal with.

我们按照这个说法再试试看,如下,这回pytz.timezone('Asia/Shanghai’)没有再玩幺蛾子了。

>>> x = datetime.datetime(2014, 8, 20, 10, 0, 0, 0, pytz.utc)
>>> x
datetime.datetime(2014, 8, 20, 10, 0, tzinfo=<UTC>)
>>> x.astimezone(pytz.timezone('Asia/Shanghai'))
datetime.datetime(2014, 8, 20, 18, 0, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)

所以最保险的方法是使用locallize方法构造带时区的时间。

顺带说下,里面提到了normalize是用来校正计算的时间跨越DST切换的时候出错的情况,还是参见文档,关键部分摘录如下:

This library differs from the documented Python API for tzinfo implementations; if you want to create local wallclock times you need to use the localize() method documented in this document. In addition, if you perform date arithmetic on local times that cross DST boundaries, the result may be in an incorrect timezone (ie. subtract 1 minute from 2002-10-27 1:00 EST and you get 2002-10-27 0:59 EST instead of the correct 2002-10-27 1:59 EDT). A normalize() method is provided to correct this. Unfortunately these issues cannot be resolved without modifying the Python datetime implementation (see PEP-431).
  • 回到最初的问题,我程序需要给用户两天后的21点发送通知,这个时间怎么计算?
>>> import pytz
>>> import time
>>> import datetime
>>> tz = pytz.timezone('Asia/Shanghai')
>>> user_ts = int(time.time())
>>> d1 = datetime.datetime.fromtimestamp(user_ts)
>>> d1x = tz.localize(d1)
>>> d1x
datetime.datetime(2015, 5, 26, 1, 43, 41, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)

>>> d2 = d1x + datetime.timedelta(days=2)
>>> d2
datetime.datetime(2015, 5, 28, 1, 43, 41, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)
>>> d2.replace(hour=21, minute=0)
>>> d2
datetime.datetime(2015, 5, 28, 21, 0, 41, tzinfo=<DstTzInfo 'Asia/Shanghai' CST+8:00:00 STD>)

基本步骤为,根据时间戳和时区信息构建正确的时间d1x,使用timedelta进行对时间进行加减操作,使用replace方法替换小时等信息。

  • 总结,基本上时间相关的这些方法,大部分你都可以直接按照自己的需要封装到一个独立的utility模块,然后就不需要再去管它了。你要做的是,至少有一个人先正确地管一下。