mizzsugar’s blog

Pythonで学んだことや読書録を書きます。

djangoでタイムゾーンとうまく付き合う

Djangogirls Tutorialで今まで何気なく書いていた、Postモデルのpublished_dateとcreated_dateで使うdjango.utils.timezone.now。これについて疑問に思ったことがあったので調べました。


対象

  • (レベル感)Djangoチュートリアルくらいの複雑さのものを自力で作ることができる

  • (問題)今のところ作りたいものは作れているけど正直タイムゾーンの扱いの仕組みをわかっていない人


1. shell上でPost.objects.get(pk=1).pulished_dateを出力するとUTC時間で出力されるけれども、テンプレートで描画したブラウザ上では日本時間でで出力されるのはなぜ?

Djangoでは、タイムゾーンサポートを有効にした場合(つまりsettings.pyでUSE_TZ=Trueとした場合、日時は

となります。

タイムゾーンについて、Pythonでは「native」と「aware」という概念があります。

以下、ざっくり、タイムゾーンなしの時間を「native」な時間、タイムゾーンありの時間を「aware」な時間とします。

nativeとawareについては下記の記事が分かりやすいです。

【Django】native timeをaware timeに変換する方法 | エンジニアの眠れない夜

公式ドキュメントはこちらです。

datetime --- 基本的な日付型および時間型 — Python 3.8.2 ドキュメント

例えば、Djangoでこんなモデルがあるとします。

models.py

from django.conf import settings
from django.db import models
from django.utils import timezone


class Post(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE
    )
    title = models.CharField(max_length=200)
    text = models.TextField()
    created_date = models.DateTimeField(default=timezone.now)
    published_date = models.DateTimeField(blank=True, null=True)

    def publish(self):
        self.published_date = timezone.now()
        self.save()

    def __str__(self):
        return self.title

    class Meta:
        db_table = 'posts'


Djangoのshell機能で動きを確認すると・・・

>>> from django.contrib.auth.models import User
>>> from blog.models import Post
>>> 
>>> author = User.objects.create(username='dummy', email='dummy@example.com')
>>> post = Post.objects.create(
    author=author,
    title='dummy',
    text='dummy'
)


Postを日本時間で2019/10/10 19:00:00 に保存した場合、created_dateは下記のようになります。

  • データベース(PostgreSQL以外) 2019-10-10 10:00:00

  • データベース(PostgreSQL) 2019-10-10 10:00:00+00

Djangoでは、PostgreSQLだとdatetimeFieldはデータベース上ではtimestamp with time zoneとなり、タイムゾーンUTCとなる仕様です。

その他のデータベースだとタイムゾーンなしとなります。値はUTCの日時で保存されます。

# 上記省略
>>> post.created_date
datetime.datetime(2019, 10, 10, 10, 0, 0, tzinfo=<UTC>)

Djangoでは、タイムゾーンサポートを有効にした場合、datetimeオブジェクトはawareなオブジェクトとして扱われます。

内部的な処理にはタイムゾーンUTCのdatetimeオブジェクトを使い、 エンドユーザー入出力を行うレイヤー(テンプレートやフォームなど)では現地時間でやり取りするという思想のもと、このようになっています。

なぜフォームやテンプレートだけ現地時間で内部の処理はUTCかというと、 ユーザーが複数のタイムゾーンを使用しており、ユーザに対して彼らの時計と同じ日時を表示したい場合に内部はUTCにすると柔軟に時間の変換ができ、時間を扱うオブジェクトを処理する過程で値を間違えることを防げるからです。 また、UTCだとサマータイムを適用している場合に変換ミスが起こることを防ぎます。

とはいっても複数のタイムゾーン使っていないとイメージ湧きづらいと思うので、とりあえずDjangoではタイムゾーンを使うのがデフォルトなので よほどのことがない限りタイムゾーンサポートを有効にするべしということを抑えられれば、と思います。

詳しくは↓

https://docs.djangoproject.com/ja/2.2/topics/i18n/timezones/


2. 時間を検索条件に入れたい時、どんな形で時間のオブジェクトを渡すのが良いのだろう?

タイムゾーンを有効にしている場合、 タイムゾーンに指定したdatetimeオブジェクトかUTCタイムゾーンとしたdatetimeオブジェクトが良いと思います。 どちらかと言えばどちらでもいいというのが個人的な所感です笑

ケースバイケースですが、 DjangoのFormクラスのDatetimeFieldのcleaned_dataや、DRFのSerializerのDatetimeFieldのvalidated_dataでは 現地をタイムゾーンにしたdatetimeオブジェクトが出力されるので それらを使うならわざわざUTCに直してから使うこともないかなと思います。

forms.py

from django import forms


class SearchPostForm(forms.Form):
    from_date = forms.DateTimeField()
    to_date = forms.DateTimeField()


filterで検索してみる

>>> from blog.forms import SearchPostForm
>>> from blog.models import Post
>>>
>>>
>>> form = SearchPostForm({'from_date':'2019-10-10 10:00:00', 'to_date':'2019-10-10 11:00:00'})
>>> form.is_valid()
True
>>> form.cleaned_data
{'from_date': datetime.datetime(2019, 10, 10, 10, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>), 'to_date': datetime.datetime(2019, 10, 10, 11, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)}
>>>
>>> from_date = form.cleaned_data.get('from_date')
>>> to_date = form.cleaned_data.get('to_date')
>>>
>>> Post.objects.filter(published_date__gte=from_date, published_date__lte=to_date)[0].published_date
datetime.datetime(2019, 10, 10, 1, 0, tzinfo=<UTC>)


serializers.py

from rest_framework import serializers

class SearchPostSerializer(serializers.Serializer):
    from_date = serializers.DateTimeField()
    to_date = serializers.DateTimeField()


filterで検索してみる

>>> from blog.serializers import SearchPostSerializer
>>> from blog.models import Post
>>>
>>>
>>> serializer = SearchPostSerializer(data={'from_date':'2019-10-10T10:00:00+09:00', 'to_date':'2019-10-10T11:00:00+09:00'})
>>> serializer.is_valid()
True
>>> serializer.validated_data
>>> serializer.validated_data
OrderedDict([('from_date', datetime.datetime(2019, 10, 10, 10, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)), ('to_date', dat
etime.datetime(2019, 10, 10, 11, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>))])
>>>
>>> from_date = serializer.validated_data.get('from_date')
>>> to_date = serializer.validated_data.get('to_date')
>>>
>>> Post.objects.filter(published_date__gte=from_date, published_date__lte=to_date)[0].published_date
datetime.datetime(2019, 10, 10, 1, 0, tzinfo=<UTC>)


また、nativeなオブジェクトを使うとこんなwarningが出ます。

RuntimeWarning: DateTimeField received a naive datetime * to while time zone support is active.

Awareなオブジェクトを使う設定にしているのにnativeなの使うなよってことですね。

試しにnativeなオブジェクトで使ってみたところ、UTCとして扱われるような動きをしています。 Nativeオブジェクトだと意図しない検索結果になることがあるのでお勧めしません。


3. APIではどのように出力するのが良いだろう

テンプレートに時間を表示したい場合、自動でローカルタイムに変更されて表示されます。では、APIでは?

Postモデルの一覧を返したいとします。

Post.objects.get(pk=1)django.https.JsonResponse(またはHttpResponse)を使って返すと、UTCのままになります。

views.py

from django.forms.models import model_to_dict
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_http_methods

from blog.models import Post


@require_http_methods(['GET'])
def get(request, pk: int):
    post = get_object_or_404(Post, pk=pk)
    return JsonResponse(
        {
            'post': model_to_dict(post)
        }
    )


レスポンス

{"post": {"id": 1, "author": 1, "title": "not_published", "text": "dummy", "created_date": "2019-10-10T10:00:00Z", "published_date": null}}


現地時間でエンドユーザーに使ってもらいたい場合、JSを使ってフロント側で変換するか、view関数内で変換する必要があります。


① (JavaScriptを使ってエンドユーザーに時間を表示する場合)UTC時間でAPIは返してJavaScriptに現地時間に直してもらう

Moment.jsを使うとこんな感じになります。

Moment.js | Home

moment('2019-10-10T10:00:00.000Z').tz("Asia/Tokyo").format()
// ブラウザ上では`2019-10-10 19:00:00`となります。


フォーマットを変更するには

moment('2019-10-10T10:00:00.000Z').tz("Asia/Tokyo").format('YYYY年MM月DD日 HH時mm分SS秒’)
// ブラウザ上では` 2019年10月10日 19時00分00秒` となります。


タイムゾーンUTCのdatetimeオブジェクトをDjango.utils.timezone.localtimeで現地時間に変換する。

localtime()で現地をタイムゾーンにしたdatetimeオブジェクトに変換されます。

>>> from django.utils import timezone
>>>
>>>
>>> time = timezone.now()  # 2019-10-10 10:00:00 tzinfo=<UTC>とします
>>> timezone.localtime(time)
datetime.datetime(2019, 10, 10, 19, 00, 00, 00000, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)


③ (DRFを使っている場合)Serializerに現地時間に返してもらう。

上記と同じです。Serializerのvalidated_dataをResponseに入れて返します。

serializers.py

from rest_framework import serializers

from blog.models import Post


class PostModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ('author', 'title', 'text', 'published_date')
>>> from blog.models import Post
>>> from blog.serializer import PostSerializer
>>> 
>>> 
>>> post = Post.objects.get(pk=2)
>>> serializer = PostSerializer(post)
>>> serializer.data
{'author': 1, 'title': 'already_published', 'text': 'dummy', 'published_date': '2019-10-10T10:00:00+09:00'}
>>> # +09:00となっているのでタイムゾーンがAsia/Tokyoの時間とわかる。


なお、SerializerのDatetimeFieldはデフォルトでISO-8601フォーマットになっています。それ以外のフォーマットで出力したい場合、DatetimeFieldの引数formatを指定してください。

例えばこんな感じで

from rest_framework import serializers

serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S %Z")


Formatのドキュメント↓

https://www.django-rest-framework.org/api-guide/fields/#datetimefield-format-strings


どちらでも良いと思いますが、DRFがよしなにやってくれているので3が間違いが少なく楽かなと個人的には思います。

ただ、いろんなAPIのドキュメントやFAQを見ていると、 どのタイムゾーンの時間なのかはレスポンスに加えないと使う人がどのタイムゾーン時間なのか分からず困るAPIになるので注意しないとな、と思いました。


DropboxAPIは全てUTCなのでタイムゾーンの情報は書いていませんね。ドキュメントにもUTC使うよ!と書いてくれているのでレスポンスにタイムゾーンの情報がなくても「ああ、なるほどね」となります。

https://www.dropbox.com/developers/documentation/http/documentation#file_requests-get


GitHubAPIUTCですね。

https://developer.github.com/v4/scalar/datetime/


Connpassは現地時間ですね。 ISO-8601フォーマットで2012-04-17T20:30:00+09:00のような形で返されます。

https://connpass.com/about/api/