時刻の扱いは難しい。タイムゾーンを跨ぐと格別に難しい。 Rails を使っていても難しさに変わりはない。むしろ時刻のやっかいな部分を隠蔽してくれるが故に余計にややこしくなることもある。
config.time_zone と config.active_record.default_timezone
Rails アプリケーションで時刻を司る代表的な設定値は config.time_zone と config.active_record.default_timezone だ。いずれも config/application.rb で設定できる。詳細は Ruby on Rails Guides: Configuring Rails Applications 参照。
config.time_zone でアプリケーションのタイムゾーンを設定する。デフォルトでは UTC になる。日本向けのウェブサイトであれば通常は Tokyo なり Osaka なりを指定するだろう。
config.time_zone を UTC 以外にしたとき、典型的には ActiveRecord::Base なクラスのインスタンスにおける日付型のカラムが返す値が変わる。
config.time_zone が UTC の場合、 INSERT されるのも UTC だし post.created_at のタイムゾーン情報も UTC のままだ。
https://gist.github.com/3361843#file_config_time_zone_is_utc.rb
config.time_zone が Tokyo の場合、 INSERT 文のデバッグ行には JST な時刻が記録されるが実際にデータベースに保存されているのは UTC における時刻だ。しかし post.created_at のタイムゾーンは JST になる。
https://gist.github.com/3361843#file_config_time_zone_is_tokyo.rb
config.active_record.default_timezone を :local にするとデータベースに保存される時刻もローカルタイムゾーンのものになる。
https://gist.github.com/3361843#file_config_active_record_default_timezone_is_local.rb
タイムゾーンを意識しなくて良いので楽にみえるけど、国際化対応をしようとすると破綻するし、ユーザーがどんなタイムゾーンからアクセスするかはわからない。なので :local はおすすめしない。デフォルトは UTC なのでそのまま頑張るほうがよい。
Date の罠
以下、 config.time_zone はローカルタイムゾーン、 config.active_record.default_timezone は UTC のまま、という設定を前提とする。
マイグレーションで date 型を指定したカラムを追加したモデルのインスタンスを save するとき、 date 型のカラムに Time のインスタンスを渡すとハマる。 UTC における時刻の日付部分が保存されるので JST の場合 9 時間ずれる。
Date のインスタンスを渡す場合は何も不思議な点はない。
https://gist.github.com/3361843#file_save_date_instance_to_date_column.rb
しかし Time のインスタンスを渡して save, reload すると昨日の日付になってしまう。
https://gist.github.com/3361843#file_save_time_instance_to_date_column.rb
reload ないし改めて find しなおさないと発覚しないのがハマりポイントで、テストを書いたつもりでも漏れてしまったりする。
config.time_zone でローカルタイムゾーンを指定するということは、アプリケーション内で時刻の扱いが完結しないと破綻する可能性をはらんでいる。 beginning_of_day や end_of_day を使って日付の範囲を指定するクエリは意図したのと違う値で発行されるし date_format や strftime で日付の計算をするクエリを書くと期待に反する結果になる。
https://gist.github.com/3361843#file_date_in_sql.rb
根本的にはすべての時刻を UTC なりローカルタイムゾーンなりに統一するべきなんだろうけど、実際のところは単に Date を避ける、がベターだと思う。そもそも date 型に対して Time のインスタンスを渡すほうが悪い、と言われそうだけど Time のほうが何かと便利で重宝するのでつい Time のまま渡してしまうことはよくある。
to_s(:db) の罠
[rails] Time#to_s(:db) と TimeWithZone#to_s(:db) は違うので注意 - memo_ruby に書いてあるまんま。 Time#to_formatted_s と TimeWithZone#to_s の実装は全然違うので挙動の差を把握しておかないとハマる。
https://gist.github.com/3361843#file_time_with_zone_to_s.rb
少し深追いしてみたところ、 to_s(:db) のあたりを変更しているコミットは 022d9f7ce6d1237c4103a2aed561220e1c0f4dcc と fa5d5e0d16686a69d3843c30c5b8ed3a1c54f160 だけで、022d9f7ce6d1237c4103a2aed561220e1c0f4dcc のほうで TimeWithZone が追加された時点から to_s(:db) が UTC な時刻を返す挙動は変化していなかった。 db のフォーマットだからデータベースに保存されている時刻を返すべきで config.active_record.default_timezone が UTC ならばデータベースには UTC の時刻が保存されているのだから UTC を返すので問題ない、と理屈は通りそうだが、 Time#to_s (実際は to_formatted_s が呼ばれる) とメソッドは同じなのに振る舞いが違うのは大変遺憾に思う。後方互換性を考えるといまさらどちらも変えられないだろうし、 to_s(:db) を避けて自前のフォーマットを定義して使う、で逃げるしかなさそう。