Django時區詳解

引言

相信使用Django的各位開發者在存儲時間的時候常常會遇到這樣子的錯誤:javascript

RuntimeWarning: DateTimeField received a naive datetime while time zone support is active.複製代碼

這個錯誤究竟是什麼意思呢?什麼是naive datetime object?什麼又是aware datetime object?html

在Django配置中若是將settings.TIME_ZONE設置爲中國時區(Asia/Shanghai),爲何如下時間函數會獲得時間相差較大的結果?前端

# settings.py
TIME_ZONE = 'Asia/Shanghai'

# python manage.py shell
>>> from datetime import datetime
>>> datetime.now()
datetime.datetime(2016, 12, 7, 12, 41, 22, 729326)
>>> from django.utils import timezone
>>> timezone.now()
datetime.datetime(2016, 12, 7, 4, 41, 36, 685921, tzinfo=<UTC>)複製代碼

接下來筆者將詳細揭祕在Django中關於時區的種種內幕,若有不對,敬請指教。java

準備

UTC與DST

UTC能夠視爲一個世界統一的時間,以原子時爲基礎,其餘時區的時間都是在這個基礎上增長或減小的,好比中國的時區就爲UTC+8。
DST(夏時制)則是爲了充分利用夏天日照長的特色,充分利用光照節約能源而人爲調整時間的一種機制。經過在夏天將時間向前加一小時,令人們早睡早起節約能源。雖然不少西方國家都採用了DST,可是中國不採用DST。(資料來源:DST 百度百科python

naive datetime object vs aware datetime object

當使用datetime.now()獲得一個datetime對象的時候,此時該datetime對象沒有任何關於時區的信息,即datetime對象的tzinfo屬性爲None(tzinfo屬性被用於存儲datetime object關於時區的信息),該datetime對象就被稱爲naive datetime object。shell

>>> import datetime
>>> naive = datetime.datetime.now()
>>> naive.tzinfo
>>>複製代碼

既然naive datetime object沒有關於時區的信息存儲,相對的aware datetime object就是指存儲了時區信息的datetime object。
在使用now函數的時候,能夠指定時區,但該時區參數必須是datetime.tzinfo的子類。(tzinfo是一個抽象類,必須有一個具體的子類才能使用,筆者在這裏使用了pytz.utc,在Django中的timezone源碼中也實現了一個UTC類以防沒有pytz庫的時候timezone功能能正常使用)數據庫

>>> import datetime
>>> import pytz
>>> aware = datetime.datetime.now(pytz.utc)
>>> aware
datetime.datetime(2016, 12, 7, 8, 32, 7, 864077, tzinfo=<UTC>)
>>> aware.tzinfo
<UTC>複製代碼

在Django中提供了幾個簡單的函數如is_aware, is_naive, make_aware和make_naive用於辨別和轉換naive datetime object和aware datetime object。express

datetime.now簡析

在調用datetime.now()的時候時間是如何返回的呢?在官方文檔裏只是簡單地說明了now函數返回當前的具體時間,以及能夠�指定時區參數,並無具體的說明now函數的實現。django

classmethod datetime.now(tz=None)
Return the current local date and time. If optional argument tz is None or not specified, this is like today(), but, if possible, supplies more precision than can be gotten from going through a time.time() timestamp (for example, this may be possible on platforms supplying the C gettimeofday() function).segmentfault

If tz is not None, it must be an instance of a tzinfo subclass, and the current date and time are converted to tz’s time zone. In this case the result is equivalent to tz.fromutc(datetime.utcnow().replace(tzinfo=tz)). See also today(), utcnow().

OK,那麼接下來直接從datetime.now()的源碼入手吧。

@classmethod
    def now(cls, tz=None):
        "Construct a datetime from time.time() and optional time zone info."
        t = _time.time()
        return cls.fromtimestamp(t, tz)複製代碼

你們能夠看到datetime.now函數經過time.time()返回了一個時間戳,而後調用了datetime.fromtimestamp()將一個時間戳轉化成一個datetime對象。
那麼,不一樣時區的時間戳會不會不同呢?不,時間戳不會隨着時區的改變而改變,時間戳是惟一的,被定義爲格林威治時間1970年01月01日00時00分00秒(北京時間1970年01月01日08時00分00秒)起至如今的總秒數。關於時間戳與時間的關係能夠參考這一則漫畫

datetime.fromtimestamp

既然時間戳不會隨時區改變,那麼在fromtimestamp中應該對時間戳的轉換作了時區的處理。
直接上源碼:

 @classmethod
    def _fromtimestamp(cls, t, utc, tz):
        """Construct a datetime from a POSIX timestamp (like time.time()). A timezone info object may be passed in as well. """
        frac, t = _math.modf(t)
        us = round(frac * 1e6)
        if us >= 1000000:
            t += 1
            us -= 1000000
        elif us < 0:
            t -= 1
            us += 1000000

        converter = _time.gmtime if utc else _time.localtime
        y, m, d, hh, mm, ss, weekday, jday, dst = converter(t)
        ss = min(ss, 59)    # clamp out leap seconds if the platform has them
        return cls(y, m, d, hh, mm, ss, us, tz)

 @classmethod
    def fromtimestamp(cls, t, tz=None):
        """Construct a datetime from a POSIX timestamp (like time.time()). A timezone info object may be passed in as well. """
        _check_tzinfo_arg(tz)

        result = cls._fromtimestamp(t, tz is not None, tz)
        if tz is not None:
            result = tz.fromutc(result)
        return result複製代碼

當直接調用datetime.now()的時候,並無傳進tz的參數,所以_fromtimestamp中的utc參數爲False,因此converter被賦值爲time.localtime函數。

time.localtime

localtime函數的使用只須要知道它返回一個九元組表示當前的時區的具體時間便可:

def localtime(seconds=None): # real signature unknown; restored from __doc__
    """
    localtime([seconds]) -> (tm_year,tm_mon,tm_mday,tm_hour,tm_min,
                              tm_sec,tm_wday,tm_yday,tm_isdst)

    Convert seconds since the Epoch to a time tuple expressing local time.
    When 'seconds' is not passed in, convert the current time instead.
    """
    pass複製代碼

筆者以爲更須要注意的是什麼因素影響了time.localtime返回的時區時間,那麼,就須要談及time.tzset函數了。

在Python官方文檔中關於time.tzset函數解釋以下:

time.tzset()
Resets the time conversion rules used by the library routines. The environment variable TZ specifies how this is done.

Availability: Unix.

Note Although in many cases, changing the TZ environment variable may affect the output of functions like localtime() without calling tzset(), this behavior should not be relied on.
The TZ environment variable should contain no whitespace.

能夠看到,一個名爲TZ的環境變量的設置會影響localtime的時區時間的返回。(有興趣的同窗能夠去在Unix下執行man tzset,就知道TZ變量是如何影響localtime了)
最後,筆者給出一些測試的例子,因爲獲取的時間戳不隨時間改變,所以直接調用fromtimestamp便可:

>>> from datetime import datetime
>>> from time import time, tzset
>>> china = datetime.fromtimestamp(time())
>>> import os
>>> os.environ['TZ'] = 'UTC'
>>> tzset()
>>> utc = datetime.fromtimestamp(time())
>>> china
datetime.datetime(2016, 12, 7, 16, 3, 34, 453664)
>>> utc
datetime.datetime(2016, 12, 7, 8, 4, 30, 108349)複製代碼

以及直接調用localtime的例子:

>>> from time import time, localtime, tzset
>>> import os
>>> china = localtime()
>>> china
time.struct_time(tm_year=2016, tm_mon=12, tm_mday=7, tm_hour=16, tm_min=7, tm_sec=5, tm_wday=2, tm_yday=342, tm_isdst=0)
>>> os.environ['TZ'] = 'UTC'
>>> tzset()
>>> utc = localtime()
>>> utc
time.struct_time(tm_year=2016, tm_mon=12, tm_mday=7, tm_hour=8, tm_min=7, tm_sec=34, tm_wday=2, tm_yday=342, tm_isdst=0)複製代碼

(提早劇透:TZ這一個環境變量在Django的時區中發揮了重大的做用)

Django TimeZone

timezone.now() vs datetime.now()

筆者在前面花費了大量的篇幅來說datetime.now函數的原理,而且說起了TZ這一個環境變量,這是由於在Django導入settings的時候也設置了TZ環境變量。

當執行如下語句的時候:

from django.conf import settings複製代碼

毫無疑問,首先會訪問django.conf.__init__.py文件。
在這裏settings是一個lazy object,可是這不是本章的重點,只須要知道當訪問settings的時候,真正實例化的是如下這一個Settings類。

class Settings(BaseSettings):
    def __init__(self, settings_module):
        # update this dict from global settings (but only for ALL_CAPS settings)
        for setting in dir(global_settings):
            if setting.isupper():
                setattr(self, setting, getattr(global_settings, setting))

        # store the settings module in case someone later cares
        self.SETTINGS_MODULE = settings_module

        mod = importlib.import_module(self.SETTINGS_MODULE)

        tuple_settings = (
            "INSTALLED_APPS",
            "TEMPLATE_DIRS",
            "LOCALE_PATHS",
        )
        self._explicit_settings = set()
        for setting in dir(mod):
            if setting.isupper():
                setting_value = getattr(mod, setting)

                if (setting in tuple_settings and
                        not isinstance(setting_value, (list, tuple))):
                    raise ImproperlyConfigured("The %s setting must be a list or a tuple. " % setting)
                setattr(self, setting, setting_value)
                self._explicit_settings.add(setting)

        if not self.SECRET_KEY:
            raise ImproperlyConfigured("The SECRET_KEY setting must not be empty.")

        if hasattr(time, 'tzset') and self.TIME_ZONE:
            # When we can, attempt to validate the timezone. If we can't find
            # this file, no check happens and it's harmless.
            zoneinfo_root = '/usr/share/zoneinfo'
            if (os.path.exists(zoneinfo_root) and not
                    os.path.exists(os.path.join(zoneinfo_root, *(self.TIME_ZONE.split('/'))))):
                raise ValueError("Incorrect timezone setting: %s" % self.TIME_ZONE)
            # Move the time zone info into os.environ. See ticket #2315 for why
            # we don't do this unconditionally (breaks Windows).
            os.environ['TZ'] = self.TIME_ZONE
            time.tzset()

    def is_overridden(self, setting):
        return setting in self._explicit_settings

    def __repr__(self):
        return '<%(cls)s "%(settings_module)s">' % {
            'cls': self.__class__.__name__,
            'settings_module': self.SETTINGS_MODULE,
        }複製代碼

在該類的初始化函數的最後,能夠看到當USE_TZ=True的時候(即開啓Django的時區功能),設置了TZ變量爲settings.TIME_ZONE。

OK,知道了TZ變量被設置爲TIME_ZONE以後,就能解釋一些很奇怪的事情了。

好比,新建一個Django項目,保留默認的時區設置,並啓動django shell:

# settings.py
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True

# python3 manage.py shell
>>> import datetime
>>> datetime.datetime.now()
datetime.datetime(2016, 12, 7, 9, 19, 34, 741124)
>>> datetime.datetime.utcnow()
datetime.datetime(2016, 12, 7, 9, 19, 45, 753843)複製代碼

默認的Python Shell經過datetime.now返回的應該是當地時間,在這裏即中國時區,可是當settings.TIME_ZONE設置爲UTC的時候,經過datetime.now返回的就是UTC時間。

能夠試試將TIME_ZONE設置成中國時區:

# settings.py
TIME_ZONE = 'Asia/Shanghai'

# python3 manage.py shell
>>> import datetime
>>> datetime.datetime.now()
datetime.datetime(2016, 12, 7, 17, 22, 21, 172761)
>>> datetime.datetime.utcnow()
datetime.datetime(2016, 12, 7, 9, 22, 26, 373080)複製代碼

此時datetime.now返回的就是中國時區了。

當使用timezone.now函數的時候,狀況則不同,在支持時區功能的時候,該函數返回的是一個帶有UTC時區信息的aware datetime obeject,即它不受TIME_ZONE變量的影響。
直接看它的源碼實現:

def now():
    """
    Returns an aware or naive datetime.datetime, depending on settings.USE_TZ.
    """
    if settings.USE_TZ:
        # timeit shows that datetime.now(tz=utc) is 24% slower
        return datetime.utcnow().replace(tzinfo=utc)
    else:
        return datetime.now()複製代碼

不支持時區功能,就返回一個受TIME_ZONE影響的naive datetime object。

實踐場景

假設如今有這樣一個場景,前端經過固定格式提交一個時間字符串供後端的form驗證,後端解析獲得datetime object以後再經過django orm存儲到DatetimeField裏面。

Form.DateTimeField

在django關於timezone的官方文檔中,已經說明了通過form.DatetimeField返回的在cleaned_data中的時間都是當前時區aware datetime object

Time zone aware input in forms¶

When you enable time zone support, Django interprets datetimes entered in forms in the current time zone and returns aware datetime objects in cleaned_data.

If the current time zone raises an exception for datetimes that don’t exist or are ambiguous because they fall in a DST transition (the timezones provided by pytz do this), such datetimes will be reported as invalid values.

Models.DatetimeField

在存儲時間到MySQL的時候,首先須要知道在Models裏面的DatetimeField經過ORM映射到MySQL的時候是什麼類型。
筆者首先創建了一個Model做爲測試:

# models.py
class Time(models.Model):

    now = models.DateTimeField()

# MySQL Tables Schema
+-------+-------------+------+-----+---------+----------------+
| Field | Type        | Null | Key | Default | Extra          |
+-------+-------------+------+-----+---------+----------------+
| id    | int(11)     | NO   | PRI | NULL    | auto_increment |
| now   | datetime(6) | NO   |     | NULL    |                |
+-------+-------------+------+-----+---------+----------------+複製代碼

能夠看到,在MySQL中是經過datetime類型存儲Django ORM中的DateTimeField類型,其中datetime類型是不受MySQL的時區設置影響,與timestamp類型不一樣。
關於datetime和timestamp類型能夠參考這篇文章

所以,若是筆者關閉了時區功能,卻向MySQL中存儲了一個aware datetime object,就會獲得如下報錯:

"ValueError: MySQL backend does not support timezone-aware datetimes. "複製代碼

關於對時區在業務開發中的一些見解

後端應該在數據庫統一存儲UTC時間並返回UTC時間給前端,前端在發送時間和接收時間的時候要把時間分別從當前時區轉換成UTC發送給後端,以及接收後端的UTC時間轉換成當地時區。

相關文章
相關標籤/搜索