海の向こうのサーバーで日時の扱いをどうするのかは、いつも悩ましいところです。自分なりの答えを先に言うと、論理値以外のリアルな日時は UTC ベースの Unix タイムスタンプで正規化して扱い、画面表示するまでに別の形式に変換するのは禁止です。
以上。
というのは乱暴なので、Yii2 で日時がどう扱われるかを見ながら、その根拠を考えてみたいと思います。
Yii2 における日時
Yii2 のアプリケーションテンプレートでは、タイムスタンプの日時をデータベースに保存するさい、整数型で Unix タイムスタンプの整数値を保存するようになっています。
[
// ...
'created_at' => Schema::TYPE_INTEGER . ' NOT NULL',
'updated_at' => Schema::TYPE_INTEGER . ' NOT NULL',
]
これはなぜでしょう?
MySQL を例に考えます。MySQL では、日時を表すカラムの型として次の 3 つの方法が考えられます。
- TIMESTAMP
- DATETIME
- INTEGER
TIMESTAMP は MySQL 特有の型です。それを保存したときのタイムゾーンも同時に保存します。これは、データベースを変えることができなくなるため、できれば避けたい型です。いっぽう、 DATETIME はどのデータベースにもある型ですが、その値にはタイムゾーンを含みません。この違いをわかりやすく表したのが、次の結果です。
CREATE TABLE test (
day1 DATETIME,
day2 TIMESTAMP
);
SET SESSION time_zone = 'UTC';
INSERT INTO test VALUES ('2014-12-25 00:00:00', '2014-12-25 00:00:00');
SET SESSION time_zone = 'Asia/Tokyo';
INSERT INTO test VALUES ('2014-12-25 00:00:00', '2014-12-25 00:00:00');
SET SESSION time_zone = 'UTC';
SELECT * FROM test;
-- day1 day2
-- 2014-12-25 00:00:00 2014-12-25 00:00:00
-- 2014-12-25 00:00:00 2014-12-24 15:00:00
日本のクリスマスは UTC ではまだクリスマスイブなのです。タイムゾーンを無視する DATETIME が保存時の文字列をそのまま返すのに対して、タイムゾーンを持つ TIMESTAMP は、保存したときどのタイムゾーンだったかと、表示するときどのタイムゾーンなのかによって、その結果が変わります。
どちらが適切かという問題よりさらに大きな問題は、アプリケーションコードがクエリ結果だけを見たとき、型に由来する違いをまったく区別できないということです。アプリケーションで ORM が値を受け取ったとき、文字列の日時をどう解釈するかはアプリケーションの責任です。UTC で受け取っていると思い込んで実装していたら、もしかしたら Asia/Tokyo での日時文字列が混入しているかもしれません。
世界中がリアルタイムに時刻を共有しているインターネットでは、時刻の本質はひとつです。つまり、時刻を表すときはどの国での何時、ではなく、世界時計の指すある決まった値であるべきです。じっさい、Amazon の AWS では、データベースをホストするサービスの RDS で、UTC 以外のタイムゾーンをサポートしていません。バックアップのスケジュールが世界中で標準化されているからです。
そうなると、時刻を表す方法にぶれがあってはいけません。データベースのタイムゾーンを UTC に固定し、日時表現の意味を UTC でのそれであると縛れば、DATETIME と TIMESTAMP にギャップが生まれることはありません。
が、あらゆる仕事でそうできる確証があるとは言えません。これまで日本のタイムゾーンで運用し続けてきたデータベースに対して、新しいアプリケーションの都合で急にタイムゾーンを UTC にしなさいと言うのは無茶です。
さて、そこで最後の INTEGER です。いまどきのシステムでは 64 ビットが当たり前なので、 2038年問題 は気にしないものとします。そうすると、Unix タイムスタンプでも十分に長い時間を表すことができます。Unix タイムスタンプは必ず UTC であり、日時文字列表現によるブレがありません。どんな設定で動いているデータベースでも、同じ動作を保証できます。そして、その値を作る方法は、 PHP で time()
と関数コールするだけです。
この完全性があるため、Yii2 で日時を表すときは、整数による Unix タイムスタンプをデータベース置くのがデフォルトになっています。
証拠1
TimestampBehabior
ビヘイビアが ActiveRecord に自動的に入れてくれる日時は、まさに time()
そのままです。もちろんこれはユーザーがカスタマイズすることはできますが、都度カスタマイズするのは面倒なので、デフォルトに従うほうが楽です。その意味でも、単純なタイムスタンプの保存型は INTEGER にしておくのが好都合です。
証拠2
Yii には、 Formatter
という、ロケールを意識して値をテキスト表現に変換してくれるコンポーネントがあります。 DetailView
や GridView
での書式指定は、このコンポーネントをほぼそのまま使います。
<?= GridView::widgets([
// ...
'attributes' => [
// ...
'created_at:datetime',
'updated_at:datetime',
]
]) ?>
このとき、 datetime
は Formatter::asDateTime()
を呼び出すのですが、このメソッドが受け取る時刻は、次の3種類のうちのいずれかであるとされています。
- Unix タイムスタンプ整数
- UTC における "Y-m-d H:i:s" 形式の文字列
- タイムゾーンをともなった DateTime のインスタンス
これ以外のものを渡すと、 時差を計算してユーザーのタイムゾーンで表示する という機能が正しく動きません。
多くの場合、データベースに格納された日時を取り出すときは、整数か日時文字列かを受け取ることになります。PHP の DateTime に変換する手間を取るより、できれば取り出した生の値のまま扱えるほうが楽です。そうなると、
- Unix タイムスタンプ整数
- UTC における "Y-m-d H:i:s" 形式の文字列
以外の選択肢はありません。
Yii2 の Formatter に言わせれば、データベースに UTC 以外の時刻が保存されているなんてありえない、なのです。
つまり
Yii 2 が「インターネットアプリケーションにおける時間表現」として認めているのは、
- Unix タイムスタンプ整数
- UTC における "Y-m-d H:i:s" 形式の文字列
だけです。
どちらがよいか。タイムスタンプのように、いちど作ったら比較と表示以外の操作をしないのであれば、INTEGER が有利です。インデックスもソートも簡単で、タイムゾーン情報の影響を絶対に受けないため、間違いなく同じ基準で時刻を比較できます。
ただ、整数だとデータを見ただけで直感的に意味がわかるわけではありません。もしデータベースのダンプの可読性がどうしても... というのであれば、絶対に UTC 以外のタイムゾーンの日時を生成しない、と厳しい規約を設け、 DATETIME を使うのも仕方ないかもしれません。
個人的には、タイムスタンプは自動的に生成される、業務上は意味を持たない項目なので、読めるようになっている必然性はないと思います。が、ビジネスロジックで明示的に作成された日時は、なにか意味を持つので読めたほうがいいと思います。そこが、INTEGER と DATETIME を使い分ける境界線になるのではないでしょうか。あくまで、ユーザーごとに時差があることを忘れず、かならず UTC で。
SQL で NOW()
と CURRENT_TIMESTAMP
を使っていいのは、意味のおかしさを感覚で判断でき、都度セッションタイムゾーンを切り替えて使うことができる人だけにとどめましょう。
世界時計の時刻に対応しない日付や時間
と、ここまでで、UTC 以外を否定せよというのが常識だと、Yii2 の挙動を根拠に説明しました。が、それとは別に、同じ表現形式を持ちながら本質的に意味の異なる日付や時間があります。論理日時です。
通常、誕生日を祝うための「生年月日」は時差の影響を受けません。それは、純粋に論理的な DATE 型で、人間エンティティの厳密な created_at
とは用途が異なります。また、「私の毎朝の起床時間」を表すための 7:00AM も、時差の影響を受ける時刻とは別の TIME です。「経過時間」を表すために TIME で代用する場合も、時差とは関係のないものになります。
このような、純粋に論理的な日付や時間は、タイムゾーンとは無関係に DATE / TIME / DATETIME で型付けします。
そのとき、困ったことに Yii2 の標準の Formatter には、それらの値を画面表示するためのメソッドがありません。コンポーネントの目的が、あくまで、リアルな時刻をタイムゾーンごとに補正して見せることだからです。
それら論理日時がビジネスロジックで必要で、頻繁に画面表示するアプリケーションを開発するとき、個別にフォーマットしているとどうしても非効率な場合は、次のような拡張 Formatter を作り、アプリケーションの構成で実装を差し替えてもよいでしょう。
<?php
namespace app\utils\i18n;
class Formatter extends \yii\i18n\Formatter
{
public function asLogicalDate($value, $format=null)
{
// 実装例
$tz = $this->timeZone;
$this->timeZone = 'UTC';
$r = $this->asDate($value, $format);
$this->timeZone = $tz;
return $r;
}
// asLogicalTime, asLogicalDatetime も
}
return [
// ...
'components' => [
// ...
'formatter' => [
'class' => 'app\utils\i18n\Formatter',
]
],
]
使い方はこうです:
<?= GridView::widgets([
// ...
'attributes' => [
// ...
'birthday:logicalDate',
]
]) ?>
これで、キリストの誕生日も、タイムゾーンの事情によって 12/25 になったり 12/24 になったりしなくなります。
誕生日にかぎらず、決算書や報告書などの書類上の日時は、タイムスタンプとは別に論理日時を持つことが多いでしょう。それらは、ばらばらに各国のビジネス時間と照合されます。
逆に、カレンダー上のイベントはリアルな時刻です。日本で入力したイベントを海外から見たらおかしくなるのでは、Skypeミーティングもできません。Qiita でアドベントカレンダーを 25 日まで書くことになっているのは、サンタクロースの時計で 12/24 24:00 は日本ではもう 25 日の早朝だからかもしれませんね。
まとめ
- OS のタイムゾーンは UTC にする (できれば)
- php.ini のタイムゾーンも UTC にする (できれば)
- データベースにはタイムゾーンの影響を受ける値を格納しない
- プログラムには絶対に
NOW()
を書かない - ビジネスロジックと関係しない日時はなるべく整数で格納する
- ビジネスロジックに関係する日時は UTC に正規化して DATETIME で格納する
- 日時が実在する時刻なのか理論上の値なのかを見極める
- DATE や TIME などの場合はとくに論理日時の可能性が高い
- 論理日時とは、バラバラのタイムゾーンと照合されるものだ