史上最详解Python日期和时间处理(二)

此下篇主要讲解跟时区相关的概念和程序中经常使用的场景,希望通过此文大家可以搞定所有时区相关的编程问题(如果还有不明白的地方,请联系我,我将进一步补充)。

本文的目录结构如下:

时区基本概念

时区

由于地球自转导致不同地区的人看到太阳升起和落下的时间不同,于是人们就定义了时区的概念,将全球分为24个时区,其中位于英国的本初子午线作为零时区中线,然后向东划分出十二个时区(分别为+1, +2....+12),向西也划分成十二个时区(分别为-1, -2 .....-12)。其中最早进入新的一天的是+12时区,当+12时区为中午12点时,正好零时区进入第二天(它们相差12小时,所以+12)

GMT和UTC

GMT(Greenwich Mean Time),即格林尼治标准时间,也就是本初子午线所在的时区。UTC(Universal Time Coordinated),即标准世界时间。GMT和UTC虽然表示的时间相同,但是两个是不同的概念,大家注意区分,实践过程中,我们通常使用UTC时间作为标准时间。

时区偏移Offset

时区偏移(Offset)是指所处时区时间相对于UTC时间的偏移量,比如中国的CST时间其偏移量就是+8,即相对于UTC时间需要+8小时。有些程序会使用秒或者分钟来替代小时,所以使用的偏移量计算时间的时候需要注意具体使用的时间单位。具体可以参考wiki时区偏移

夏令时(DST)

关于夏令时我觉得这篇文章已经讲解比较详细了, 大家可以直接参考,在此不再赘述。但是夏令时进一步增加了复杂度,这意味着即使同一个时区,一年中也会随着夏令时和非夏令时而导致offset的变化。

模糊时间(Ambiguous Time)

指的是在夏令时转换过程中的一段时间,在夏令时转换时,会有两个正确的时间,那么到底应该如何显示呢,所以要让程序知道到底选择哪个时间,就必须要有一个参数来确定这件事情。关于模糊时间的操作,Python2和Python3是不同的,具体可以参考Paul Ganssle的这篇文章pytz: The Fastest Footgun in the West

设置时区

tzinfo

在《上篇》中我们已经说过Python用于表示时间的对象会分为原始的(naive)和有知的(aware)两种,而要表示有知的时间,就必须给相应的对象传递tzinfo参数。tzinfo参数主要用在datetime.datetime对象和datetime.time对象,其类初始化函数定义如下:

1
2
3
4
5
# datetime.datetime
class datetime.datetime(year, month, day[, hour[, minute[, second[, microsecond[, tzinfo]]]]])

# datetime.time
class datetime.time([hour[, minute[, second[, microsecond[, tzinfo]]]]])

可以看到初始化datetime和time对象时,都有一个tzinfo参数,当我们传递一个tzinfo对象给这个参数的时候我们就可以初始化一个有知的时间对象。

那么这个tzinfo对象到底是怎么来的呢? 先来看下Python官网的定义:

This is an abstract base class, meaning that this class should not be instantiated directly. You need to derive a concrete subclass, and (at least) supply implementations of the standard tzinfo methods needed by the datetime methods you use. The datetime module does not supply any concrete subclasses of tzinfo.

从这段定义我们可以看出,tzinfo只是一个抽象类,而且官网已经明确说了不提供相应的实现,那我们怎么做呢?有两种做法:一是自己实现,Python官网还给出了示例代码,参考这里;另一种就是使用我们下边要讲到的dateutil模块。

dateutil

  • 安装dateutil

    1
    pip install python-dateutil

  • 简介 官网对dateutil的介绍就一句话

The dateutil module provides powerful extensions to the standard datetime module, available in Python.

看到了吧,专门为拓展datetime而开发的,其中我们感兴趣的主要是如何构造时区tzinfo。

  • 常见的使用场景:

1.转换为相应时区的时间

1
2
3
4
5
6
import dateutil.tz as tz
from datetime import datetime

my_tz = tz.gettz('Asia/Shanghai')
d = datetime(2018, 8, 20, tzinfo=my_tz)
# datetime.datetime(2018, 8, 20, 0, 0, tzinfo=tzfile('/usr/share/zoneinfo/Asia/Shanghai'))

也就是说通过tz.gettz()我们可以得到一个tzinfo对象从而可以将其作为参数传递给datetime初始化函数。 通过这种方式,我们就可以表示一个本地时间了,比如我们获取了当前时间后想要表示成NewYork时间(换句话说就是要表示不同地方的当前时间),该怎么处理呢? 先看正确的做法:

1
2
3
4
5
6
7
NYC = tz.gettz('America/New_York')

now_utc = datetime.now(tz.tzutc())
# datetime.datetime(2018, 8, 25, 8, 15, 53, 143709, tzinfo=tzutc())

now_utc.astimezone(NYC)
# datetime.datetime(2018, 8, 25, 4, 15, 53, 143709, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))

错误的做法:

1
2
3
4
now = datetime.utcnow()
# datetime.datetime(2018, 8, 25, 8, 17, 21, 161843)
now.astimezone(NYC)
# datetime.datetime(2018, 8, 24, 20, 17, 21, 161843, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))

为什么会出现这种情况呢,因为单纯的使用的utcnow()得到是一个原始naive time对象,而根据时区转换时,该时间会先从本地时区(+8 Shanghai)转换成UTC时区时间,然后再转化为NewYork时间,因此导致最终多减了8小时。

另外,有的时候我们获取到的是offset信息而不是时区信息,那么我们也可以将UTC时间转换成对应的当地时间,如下:

1
2
3
# 通过tzoffset也可以构筑tzinfo对象
now_utc.astimezone(tz.tzoffset('NewYork', -14400))
# datetime.datetime(2018, 8, 25, 4, 15, 53, 143709, tzinfo=tzoffset('NewYork', -14400))

2.获取当前的时区信息,并可以做相应转换

1
2
3
4
5
from dateutil.tz import tzlocal

tz_local = tzlocal()
type(tz_local)
# dateutil.tz.tz.tzlocal

1
2
d = datetime(2018, 8, 20, 9, 10, 5, tzinfo=tz_local)
# datetime.datetime(2018, 8, 20, 9, 10, 5, tzinfo=tzlocal())

1
2
d.astimezone(tz.UTC)
# datetime.datetime(2018, 8, 20, 1, 10, 5, tzinfo=tzutc())

pytz

在时间处理的时候,我们还经常能看到pytz这个库,这个库比较有意思的是,它与datetime的tzinfo并不完全兼容,很多时候它是独立的一套处理时间的库。 我们来看下如下代码(代码来自Paul Ganssle的文章,Paul是dateutil的核心开发者):

1
2
3
4
5
6
7
8
import pytz
from datetime import datetime, timedelta

NYC = pytz.timezone('America/New_York')
# 将timezone直接传入datetime初始化函数
dt = datetime(2018, 2, 14, 12, tzinfo=NYC)
print(dt)
# 2018-02-14 12:00:00-04:56

可以看到实际的offset成了-04:56,这就不对了,正确使用pytz的姿势如下:

1
2
3
4
要使用localize方法来转化datetime对象
dt = NYC.localize(datetime(2018, 2, 14, 12))
print(dt)
# 2018-02-14 12:00:00-05:00

再进行一些操作,如下

1
2
3
4
5
from datetime import timedelta

dt_spring = dt + timedelta(days=60)
print(dt_spring)
# 2018-04-15 12:00:00-05:00

注意,这里的offset是-5:00,而考虑到夏令时,应当是-4:00,这是由于pytz在之前localize的时候就已经将offset设定好了,其在做其它运算之后也无法改变其offset,所以导致无法针对夏令时调整offset,所以针对pytz,每一次做类似timedelta的运算之后,都需要使用normalize函数进行调整,如下:

1
2
print(NYC.normalize(dt_spring))
# 2018-04-15 13:00:00-04:00

在项目实践除非对性能有极端要求,并不推荐使用pytz,毕竟不是每个人都熟悉这个库,项目协作过程中很难避免误用。关于pytz和dateutil的性能比较,可以参考Paul Ganssle的这篇文章pytz: The Fastest Footgun in the West

时区处理的最佳实践

所有中间步骤均使用UTC时间或者时间戳

所有中间的存储或者计算均应当使用UTC时间或者Timestamp,只有在最终显示的时候如果需要转换成本地时间,那么再将时间转换为特定时区的时间进行显示。

存储timezone信息,而不是offset

如果需要针对用户本地时区做时间转换,需要存储timezone的信息,如timezone名称,而不是offset。这是由于有些地区可能有夏令时,offset会改变,所以最好是存时区名称之类的信息,这样通过tzinfo会自动进行调整。

Reference