我們要什麼?

當一個應用的使用者遍佈全世界的時候,程式的程式碼少不了要和時區打交道。伺服器端針對使用者的定時任務需要定到使用者所在時區的時。

在Glow Nurture中,比較典型的一個例子就是:如果使用者沒有記錄服用Prenatal Vitamin,兩天後晚上9點給使用者傳送程式內通知提醒服用Vitamin。 翻譯成直白的程式需求就是:獲取某使用者所在時區某年月日21點對應伺服器所在時區的時間戳。

Python提供了什麼?

Python 提供了datetime, time, calendar模組,然後感謝Stuart Bishop [email protected]

, 我們還有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模組,然後就不需要再去管它了。你要做的是,至少有一個人先正確地管一下。