こんにちは、クラウド請求書受領サービス Bill One の開発をしている加藤です。
新しくアプリケーションを開発する際は、それが日本国内をターゲットにしたものであっても、グローバル対応するときに問題が発生したり、そもそもグローバル対応できなかったりする作りにはしたくないはずです。まずは日本の顧客向けにスタートしたBill Oneを開発する際も、同じことを考えていました。
本稿ではグローバル対応の中でも、日付・時刻にフォーカスします。 次の「タイムゾーン呪いの書 3部作」にあるように、日付・時刻の扱いの難しさはよく知られています。
この3部作は2021年の改訂ということもあり、Bill Oneの開発を始めたばかりの頃に直接参考にできたわけではありません。 初版やエムスリーさんの記事を大いに参考にさせていただき、実装を検討しました。
本稿ではBill Oneにおける実装の振り返りを兼ねて、実装編 で説明されている次の3つの視点に沿って、Bill Oneにおける日付・時刻の扱い方を確認してみます。
- 内部データ表現
- データの出力と永続化
- データの入力
具体例として、Bill Oneで利用している次の環境が登場しますが、考え方は他のプログラミング言語やランタイムでも適用できるものだと認識しています。
- データベース: PostgreSQL
- サーバーサイド: Kotlin/JVMによるWeb API
- クライアントサイド: JavaScript*1によるClient-side RenderingのSPA (Single Page Applicaiton)
内部データ表現
まずは、アプリケーション内部の時刻のデータ表現です。大原則とされている次の方針は、基本的に守られている認識です。
タイムゾーンが未確定なままの「年・月・日・時・分・秒」を、そのまま保持し続けない、そのまま持ち回らない
うるう秒をあつかう必要があるか?
Bill Oneではそこまで厳密な時刻が要求される要件がなかったため、うるう秒を扱いません。Bill OneではGoogle Cloud上のPaaS (Platform as a Service) を利用しており、NTPを介してうるう秒の前後で希釈されます。 これによって、タイムゾーンの呪いを遠ざけることができました。
日時の内部表現としては、Unix timeに変換可能なオフセットつきの時刻形式である、JavaのOffsetDateTimeを基本的に使用しています。
Bill Oneで扱う請求書には、支払期日などの日付のみのデータもしばしば出現します。タイムゾーン付きの日付(例えば ZonedDate)というデータ形式はあまり一般的ではないようなので、ローカル日付(LocalDate)で保持し、タイムゾーンとセットで扱っています。
暦の計算、タイムゾーンの切り替わりをまたぐ計算をするか?
Bill Oneでは、請求書に関する暦計算(月末など)が登場します。 このため、タイムゾーンを意識して暦の計算をする場面があります。
「月末」などの日付のみで完結する暦の計算については、単純化のためLocalDateを使って計算している箇所が多く、サモアの2011年12月30日がなくなるようなケースでは正しい計算が行えない可能性がありますが、一旦そこは考慮していません。
LocalDateとOffsetDateTimeを変換する際は必ずタイムゾーンを意識する必要があるため、次のような変換関数を用意しました。 このように、必要な箇所だけJavaのZonedDateTimeを使って暦計算をして、LocalDateまたはOffsetDateTimeに変換しています。本稿のサンプルコードは特に断りがない限りKotlinを使っています。
/** * LocalDateをZoneIdにおける開始時刻を表すOffsetDateTimeに変換する。 */ fun startOfDay(localDate: LocalDate, zoneId: ZoneId): OffsetDateTime { return localDate.atStartOfDay(zoneId).toOffsetDateTime() } /** * OffsetDateTimeのZoneIdにおける日付 (LocalDate) を取得する。 */ fun localDateOf(offsetDateTime: OffsetDateTime, zoneId: ZoneId): LocalDate { return offsetDateTime.atZoneSameInstant(zoneId).toLocalDate() }
確定した過去の時刻か? 未来に予定された時刻か?
現在のところ、Bill Oneでは確定した過去の時刻を表現することがほとんどです。 このため、Unix timeに変換可能なオフセットつきの時刻形式である、JavaのOffsetDateTimeを使用することで足りています。
今後、未来に予定された時刻を保持する要件が出てきたときには、慎重に検討する必要があります。
データの出力と永続化
アプリケーションから外部にデータを出力したり永続化したりする際の考慮点です。
出力
Bill OneはSPAなので、サーバーサイドではAPIで日付や時刻を返します。JSONにシリアライズする際には、次のようなISO 8601の拡張形式の文字列を使っています。Unix timeも検討しましたが、ぱっと見で分かりやすく、誤認識が少ないと考えました。
- 日付:
2022-01-21
- 日時:
2022-01-21T09:30:00+09:00
Bill Oneで使用しているJacksonでは、次の設定をすることで、LocalDateやOffsetDateTimeをISO 8601の拡張形式の文字列としてシリアライズ・デシリアライズできます。
configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
クライアントサイドでは、date-fnsの parseISO
関数を使って、JSON内の文字列からDateに変換しています。
実際にユーザーに表示する際は、同じくdate-fnsの format
関数を使ってブラウザのローカル日時に変換します。
クライアントサイドでは複雑な暦の計算は行いません。
なお、一部のサーバーサイドの処理(CSVエクスポートなど)では、ユーザー向けに日付や時刻を表示する必要があるので、タイムゾーンを指定してZonedDateTimeに変換してから文字列化しています。
永続化
Bill OneではデータベースとしてPostgreSQLを使っており、データベースに保存する際に日時は timestamptz (timestamp with time zone) 型を、日付は date 型を使っています。 timestamptzは、タイムスタンプを内部的にUTCとして保存してくれるデータ形式で、JDBIを使ってOffsetDateTimeとマッピングしています。
永続化したデータをJDBIを使って取得すると、元のオフセット情報は失われ、オフセットはサーバーのタイムゾーンになります。 このため、永続化の前後でオフセットが変わる可能性もあります。
OffsetDateTimeを扱う際は、オフセットが何であっても、それが表すインスタントが同じであれば等しい日時であることを意識するのが大切です。 *2
val utcDateTime = OffsetDateTime.parse("2022-01-07T10:51:03+09:00") val jstDateTime = OffsetDateTime.parse("2022-01-07T01:51:03Z") utcDateTime.isEqual(jstDateTime) // true
なおBill Oneでは、テーブル定義で誤ってtimestamp (timestamp without time zone) 型を使ってしまわないよう、次のSQLでテーブル定義を確認して、timestamp型を使っていたら失敗するテストケースを用意しています。
SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = :schemaName AND data_type = 'timestamp without time zone' ORDER BY table_name
データの入力
外部からアプリケーションにデータを入力する際の考慮点です。
現在時刻
Bill Oneでは、現在時刻は基本的にサーバーサイドで OffsetDateTime.now を使って取得します。
意外と注意が必要なのが、今日の日付を取得する処理です。 Bill Oneではアプリケーション実行時のシステムタイムゾーンを明確に定めていません。 クラウド環境だとシステムタイムゾーンはUTCになるケースが多いですが、システムタイムゾーンがUTCであることに依存したコードを書くのは望ましくないと考えているためです。
LocalDate.now を引数なしで呼び出すと、システムタイムゾーンにおけるローカル日付が取得され、日本時間の0時〜9時だけ結果がおかしい処理が生まれてしまうことがあります。 必ず引数で目的のタイムゾーンを指定して取得します。間違えにくいよう、これだけの処理をする関数も用意しました。
/** * 指定したZoneIdにおける現在の日付を取得する。 */ fun today(zoneId: ZoneId): LocalDate { return LocalDate.now(zoneId) }
外部データ
外部データに関しては、現在のところそこまで酷い形式には巡り合っておらず、外部の形式に合わせて適切なタイムゾーン情報を付与しています。
ユーザー入力
ユーザーが入力する日付は、ユーザーにあらかじめ設定されたタイムゾーンを使って解釈しています。
タイムゾーンの設定値としては、 Asia/Tokyo
などのtzdb (Time Zone Database) における名前を使用しています。
その他の観点
本稿のメインテーマである3つの視点以外で、日付・時刻の扱いにおいて考慮した点を記載しておきます。
PostgreSQLではタイムゾーンの変換を行わない
Bill Oneの開発当初、一部PostgreSQLでタイムゾーンの変換を行うクエリを書いていたことがありましたが、現在はこのようなクエリは書かない方針としています。
PostgreSQLでは、at time zone という構文でタイムゾーンを指定して timestamptz と timestamp を相互に変換できます。実装の中で timestamptz に誤ってat time zoneを適用してしまい、意図せずタイムゾーン情報が失われてしまったことがあったため、使わないことになりました。
PostgreSQLでタイムゾーンの変換を行うと、PostgreSQLとJVMのtzdbの違いが問題を引き起こす可能性もあるため、その意味でも適切な判断だったと思います。
1日の終わりは23:59:59ではない
例えば作成日時が 2022-01-21
以前のデータをフィルタリングしようとして次のようなクエリを書くと、日時の精度によっては最後の1秒間のデータが抜けてしまいます。例えばPostgreSQLのtimestamptzではマイクロ秒の精度を、JavaのOffsetDateTimeではナノ秒の精度を持ちます。
created_at <= '2022-01-21T23:59:59+09:00'
実装ごとに秒数の精度を調べて、小数点以下を9で満たすのも一つの手ですが、実装に強く依存する処理になってしまうので、翌日の0:00より小さいと比較する方が良いでしょう。
created_at < '2022-01-22T00:00:00+09:00'
西暦以外の日付の表記
西暦以外の暦が広く使われている国や地域もあります。日本の和暦もその一例ですが、例えばタイ王国ではタイ太陽暦が使われています。Bill Oneのアプリケーション上では西暦のみを扱いますが、タイの請求書に支払期日が2565年と書かれていたら、請求書をデータ化する際に西暦2022年と認識してあげると親切です。
まとめ
本稿では、タイムゾーン呪いの書 (実装編)に沿って、Bill Oneにおける日付・時刻の扱い方を見てきました。まとめると次のような方針になっています。
- サーバーサイドの内部データ表現ではOffsetDateTime/LocalDateに統一し、暦の計算で一時的にZonedDateTimeを使う
- データベース (PostgreSQL) ではtimestamptz/date型に統一
- JSONでの表現はISO 8601の拡張形式の文字列に統一
- クライアントサイドでの時刻の表示にはブラウザのローカルタイムゾーンを使う
- ユーザーからの日付入力にはユーザーのタイムゾーン情報を使用する
- タイムゾーン情報はtzdb (Time Zone Database) の名前を使用する
タイムゾーン呪いの書と照らし合わせると、完璧ではないものの、基本は大きく外してないように思います。 Bill Oneのグローバル展開は昨年から始まっていますが、本格的なグローバル対応を行った時も、根本的な見直しはせずに済みました。 今後も利用される国や地域が増えるにつれて問題が出てくるかもしれませんが、しっかりと向き合っていきたいと思います。
日付・時刻の扱いは難しいと言われがちですが、この難しい概念に対して整理されたAPIを提供しているJavaのDate and Time API(や他のプログラミング言語の API)および、それらに対する分かりやすい解説には感謝するばかりです。 本稿がWebアプリケーションで日付や時刻を扱う際の助けになれば幸いです。