Djangogirls Tutorialで今まで何気なく書いていた、Postモデルのpublished_dateとcreated_dateで使うdjango.utils.timezone.now。これについて疑問に思ったことがあったので調べました。
対象
1. shell上でPost.objects.get(pk=1).pulished_dateを出力するとUTC時間で出力されるけれども、テンプレートで描画したブラウザ上では日本時間でで出力されるのはなぜ?
Djangoでは、タイムゾーンサポートを有効にした場合(つまりsettings.pyでUSE_TZ=Trueとした場合、日時は
- データベースでは、タイムゾーンなしの日時(値はUTCの日時)
- modelのインスタンスのdatetimeFieldのアトリビュートでは、タイムゾーンがUTCのdatetimeオブジェクト
- テンプレートやフォームなどエンドユーザーがやり取りする層ではsettings.pyのTIME_ZONEで設定したタイムゾーンでの時間
となります。
タイムゾーンについて、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('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になるので注意しないとな、と思いました。
DropboxのAPIは全てUTCなのでタイムゾーンの情報は書いていませんね。ドキュメントにもUTC使うよ!と書いてくれているのでレスポンスにタイムゾーンの情報がなくても「ああ、なるほどね」となります。
https://www.dropbox.com/developers/documentation/http/documentation#file_requests-get
https://developer.github.com/v4/scalar/datetime/
Connpassは現地時間ですね。
ISO-8601フォーマットで2012-04-17T20:30:00+09:00
のような形で返されます。