こんにちは、server-side kotlin や terraform を書くことが多い、エンジニアリングGの矢崎(id:Saiya)です。
タイムゾーンや日時の扱いについての話題がホットな昨今ですが、 そういった日時の扱いについて例えば以下のようなお話を受けることが少なからずありました:
- とりあえず日時は UTC からの時差情報付きで扱えばいいんでしょ?
- DB に保存するときもタイムゾーン情報付きで入れておけばいいんでしょ?
こういったお話を振られた際に、思うところを一言でサッと説明できずもやもやする事もあり、 また web サービスにおいて日時・タイムゾーン・オフセットをどう扱うべきか?納得の行く説明をあまり見つけられなかったため、 筆者なりに考えをまとめてみました。
国家的祭典のために急にサマータイムが導入されるといった話に限らず、 クラウドサービスが UTC+0 の日時になっているがユーザー層は日本時間である、といった理由でも タイムゾーンや UTC オフセット (時差) を扱う必要性のある時代ですので、ご参考にしていただければと思います。
TL;DR
- ユーザーとの日時(LocalDateTime)の解釈や表示処理では、地域のタイムゾーン("Asia/Tokyo" など)に基く日時(ZonedDateTime)を用いる
- LocalDateTime の例:
2018-08-09 00:56:34
- ZonedDateTime の例:
2018-08-09 00:56:34 (Asia/Tokyo)
- LocalDateTime の例:
- システム間の日時情報のやりとりや DB・ファイルなどへの日時の保存では、UTC からのオフセット情報付きの日時 (OffsetDateTime) を利用する *1
- OffsetDateTime の例:
2018-08-09T00:56:34+09:00
- API などでは上記のような ISO-8601 の拡張形式 が最近の主流
- OffsetDateTime の例:
なお、LocalDateTime, ZonedDateTime, OffsetDateTime という用語は Java 8 Date and Time API (JSR-310) の表現を借用していますが、 本稿の内容や考え方は Java 固有ではありません。
以下、詳細を説明します:
ユーザーと入出力する日時 (LocalDateTime) はタイムゾーン付きの日時 (ZonedDateTime) と変換して扱う
一般的なユーザーの方が普段お使いの web サービスのフォームや書類の記入欄に "Asia/Tokyo" などというタイムゾーンや "UTC+9" などという時差情報を入力するということはほぼないと思います *2。
したがって、タイムゾーンや時差情報のない日時(LocalDateTime)の入力を受けた際には、その日時を空気を読んで解釈する必要があります。 そのための手段として、ユーザーが想定する地域のタイムゾーン(例: "Asia/Tokyo")に従って日時を解釈するという方法が一般的です。
筆者が知る限りほぼあらゆる現代的な OS, 言語処理系, RDBMS などは tz database に対応しているため、 "Asia/Tokyo" といったタイムゾーンがわかれば、それを用いて日時を正確に解釈することが可能です。 よって、人間から入力された日時 (LocalDateTime)にタイムゾーン情報を付与する (ZonedDateTime) ことで日時の解釈が可能です。
ユーザーに日時を表示する際にも、ユーザーが想定する地域のタイムゾーンでの日時を表示することが一般的かと思います。 それを実現するためには、日時情報をユーザーが想定する地域のタイムゾーン(例: "Asia/Tokyo")における日時に変換してそれを表示すればよいことも上記の議論の逆からわかります。
なお、ユーザーが想定する地域のタイムゾーン(例: "Asia/Tokyo")をどうやって知るか?は個別サービスの事情に強く依存しますが、 例えば以下のような方法が考えられます:
- ユーザーの言語設定をもとに推測する
- アプリの動作している OS や ブラウザのタイムゾーン 情報を取得する
- 地域ごとのドメインを用意し、アクセスされたドメインに応じて解釈する ("jp.example.com" ならば "Asia/Tokyo" にする、とか)
- 対象ユーザーの地域が限られているサービスであるならば、一定のタイムゾーンに固定する
人間からの日時の入力 (LocalDateTime) の解釈にオフセット (例: UTC+9)を使わない理由
例えばブラウザの場合オフセット(例: UTC+9)は簡単に取得できますが、タイムゾーン(例: "Asia/Tokyo")の取得にはひと手間がかかります (参考)。
そういった理由で、人間からの日時の入力 (LocalDateTime) の解釈にオフセット (例: UTC+9)を使いたくなってしまうかもしれませんが、 先述の参考ページ などでも言及されていますように、その方法ではサマータイムやタイムゾーンの改定といった事象に対応できません。 例えば「このユーザーは UTC+9 だ」と決めうちしてしまうと、「実は Asia/Tokyo は再来月には夏時間で UTC+7 になる」といった場合に、再来月の日時情報を誤って UTC+9 で扱ってしまうことになります。
なので、「とりあえず日時は UTC からの時差情報付き(OffsetDateTime)で扱えば OK」といった考え方は適切でないと筆者は考えます。
日時を保存したりシステム間で日時を受け渡す際には UTC からのオフセット情報付きの日時 (OffsetDateTime) にする
ここまでの議論で、ユーザーと入出力する日時をプログラム上で処理する際は、"Asia/Tokyo" といったタイムゾーン付きの日時 (ZonedDateTime) で扱うべきとわかりました。
しかし、システム間通信や情報の保存においてはタイムゾーン付きの日時 (ZonedDateTime) で扱うことは以下の点で好ましくないと考えられます。それゆえにシステム間通信や保存では 2018-08-09T00:56:34+09:00
といった UTC からのオフセット情報付きの日時 (OffsetDateTime) にすることが良いと考えます。
タイムゾーンの解釈が OS, 言語処理系, RDBMS などの間で一致する保証がないため
先述の通り、タイムゾーンは一般に tz database の情報に基づいています。しかし OS, 言語処理系, RDBMS はそれぞれが個別にローカルのファイルとして tz database の情報を持つ*3ため、それぞれの間でタイムゾーンの解釈が常に一致する保証はありません。
例えばある国が急にサマータイムの導入を決定した際に、アプリケーションの動くサーバーが最新の tz database で動作し RDBMS サーバーが古い tz database で動作した場合、アプリケーション内部での日時演算と SQL 上での日時演算が齟齬をきたすことで厄介なバグやデータ汚染が発生する、といった事案が考えられます。
加えて、microservice 構成やクラウドサービスの利用を想定する場合、tz database の内容がお互いに一致することの保証が原理的に不可能であるケースも多々あります。
よって、日時の解釈のズレを予防ためには、"Asia/Tokyo" といったタイムゾーン付きの日時 (ZonedDateTime) を web サーバーから他システムへ送信したり DB 等に保存したりするべきではないといえます。
過去のデータにおけるタイムゾーンの解釈が意図せずに変わってしまうため
tz database は実はかなりの頻度で更新されています、例えば 2018 年では 05 月までの時点ですでに 5 回の改定がありました (バージョン 2018a〜e)。
そのため、データベースやログファイル等に "Asia/Tokyo" といった地域のタイムゾーン付きの日時 (ZonedDateTime) を記録した場合、過去に記録した ZonedDateTime を将来には異なる解釈をしてしまう可能性が発生します。
例えば、2018-07 月に 2020-07-24 00:00:00 (Asia/Tokyo)
に期限切れするライセンス権を発行し、その後に夏時間が導入された場合、ライセンス権の期限が 1, 2 時間前倒しに解釈されてしまうことになります。ライセンスの有効時間に比例した金額を支払っているとすると、ユーザーが損をすることになってしまいます *4。
このように過去のデータの解釈が意図せずに変わってしまうということはトラブルや思わぬ齟齬の原因になるため、データベースやファイルなど永続的な記録に地域タイムゾーン付きの日時 (ZonedDateTime) を用いるのは良い考えではないと言えます。
オフセット情報付きの日時 (OffsetDateTime) による日時情報のシステム間通信や保存のススメ
ここまでの考察のとおり、"Asia/Tokyo" といったタイムゾーン付きの日時 (ZonedDateTime)は、日時情報のシステム間通信や保存には適さないと考えられます。
かといって単なる 年/月/日 時:分:秒
(LocalDateTime) を DB に保存したり API で渡してしまうと、バグやオペレーションミスの温床になってしまいます。
例えばクラウドサービス上の UTC での日時を日本時間と勘違いしてしまう、など...。
そこで、サーバー間やシステム間での日時情報の交換や日時情報の保存では、UTC からのオフセット(時差)情報を付与する手段が考えられます。 それによって、 tz database の内容に依存することもなく、かつ取り違いの余地なく日時を扱うことが可能です。 また、tz database に基づく処理や夏時間の処理といった複雑な計算処理も不要であるため、高パフォーマンスかつシンプルな日時処理となることも期待できます。
なお、昨今の web の各種標準規格やクラウドサービスの API などでは、オフセット情報付きの日時 (OffsetDateTime) の文字列表現として 2018-08-09T00:56:34+09:00
といった ISO-8601 の拡張形式 が用いられていることが多いです。
API の設計などで日時を文字列として扱う際は、特別な理由がない限りこの形式を採用すると良いでしょう。
余談: UNIX time / UNIX epoch を使えばいいのでは?というアイデアについて
システム内部での時刻のやりとりには UNIX time を使えばよいのではないか、というアイデアも考えられます。
筆者としては、以下の点で問題がないのであれば、UNIX time の利用も選択肢ではあると思います:
- UNIX time は必ず「UTC の」1970年1月1日午前0時0分0秒 からの経過秒数であることを間違えないこと
- うっかりシステムのタイムゾーンの 1970年元旦を基準に計算したりてしまうとズレます
- 処理系によっては時刻を UNIX time と異なる数値で扱っている*5ことがあるので取り違えないこと
- 2038 年問題 などにならない十分なビット数を確保すること
ただの整数である UNIX time よりも ISO-8601 の拡張形式の方が意味・解釈を誤りにくいため、データのサイズが重大な問題でない限り ISO-8601 の拡張形式の方が好ましいのではないかと個人的には感じます。
まとめ
ここまでに述べた内容を再整理すると、筆者としてのベストプラクティスは以下の通りとなります:
- ユーザーとの日時(LocalDateTime)の解釈や表示処理では、地域のタイムゾーン("Asia/Tokyo" など)に基く日時(ZonedDateTime)を用いる
- LocalDateTime の例:
2018-08-09 00:56:34
- ZonedDateTime の例:
2018-08-09 00:56:34 (Asia/Tokyo)
- LocalDateTime の例:
- システム間の日時情報のやりとりや DB・ファイルなどへの日時の保存では、UTC からのオフセット情報付きの日時 (OffsetDateTime) を利用する
- OffsetDateTime の例:
2018-08-09T00:56:34+09:00
- DB のデータ型の都合などで UTC からのオフセット情報を持てない場合は、一定のオフセットに揃えたうえでオフセット情報なしで保存する
- API などでは上記のような ISO-8601 の拡張形式 をできるだけ用いる
- OffsetDateTime の例:
自国のタイムゾーンの改定(夏時間導入など)のみならず、クラウドサービスの利用といったグローバリゼーションの流れを踏まえても、タイムゾーンを踏まえた適切な日時の取扱いの重要性は増すことはあれど減ることはないと想像できます。
その際に、本稿の内容が一助になれば幸いです。
We're Hiring!
なお、当社ではエンジニアを絶賛募集しております。
こういった技術的なトピックに関心を持ったエンジニアの同士たちと手を動かしながらサービスを開発・運用してゆくことに少しでもご興味ございましたら、ぜひ下のフォームからエントリーしてみてください。
お待ちしております。
*1:とはいえ DB のデータ型の都合などで UTC からのオフセット情報を持てない場合は、一定のオフセットに揃えたうえでオフセット情報なしで保存するしかないです
*2:時としてそのような仕様もありえるかもしれませんが、一般の方向けの web サービスとしては決して良い UX ではないでしょう
*3:言語処理系については、OS が持つ tz database を参照していることもあります。ただし、例えば JVM は内部に tz database を持ちますし、Rails アプリでも tzdata-info gem がロードされていることがあったりします
*4:※ なにかの具体的なサービスで実際にあった事案を示しているのではなく、あくまで理論的に考えられる想定例です
*5:秒単位の UNIX time と異なるミリ秒単位であったり、起点が西暦 0 年であったり