こんにちは! VOYAGE MARKETINGの @sayadroid です。
最近は、自社の長寿メディアを丸っとリニューアルするプロジェクトに携わっています。
元来PHP, symfony(1.x(小声))で書かれているそのメディアが、 Ruby on Railsで生まれ変わる予定です。
弊社では様々なメディアが、様々な言語で動いているため、 言語の組み込みクラスの仕様をうろ覚えで書くと、 細かな挙動を勘違いしてしまうことなどもあります。
そんな中で、最近ハマったRuby関連の、罠小ネタを一つ紹介します。
前提
Ruby on Rails 4.2.0 server timezone = JST MySQL5.6 timezone = UTC 「質問」テーブル * body: 質問文 * opened_at: 掲載開始日時 * closed_at: 掲載終了日時
要件
opened_at「今日の00:00:00」 closed_at「今日の23:59:59」
としてレコードをINSERTしたい。
「今日」を取得することはDateTime由来でもTime由来でも可能だが、 タイムゾーンを明示的に利用したいので、Time.zoneを使うことが望ましい。
比較として、DateTimeを利用した時の挙動も確認しておきたい。
挙動確認
[1] pry(main)> b = Time.zone.now.beginning_of_day => Wed, 07 Apr 2015 00:00:00 JST +09:00 [2] pry(main)> e = Time.zone.now.end_of_day => Wed, 07 Apr 2015 23:59:59 JST +09:00 [3] pry(main)> b2 = DateTime.now.beginning_of_day => Wed, 07 Apr 2015 00:00:00 +0900 [4] pry(main)> e2 = DateTime.now.end_of_day => Wed, 07 Apr 2015 23:59:59 +0900 [5] pry(main)> require 'factory_girl_rails' => true [6] pry(main)> FactoryGirl.create(:question, body: '現在の天気は?', opened_at: b, closed_at: e) (0.1ms) BEGIN SQL (0.4ms) INSERT INTO `questions` (`body`, `opened_at`, `closed_at`, `created_at`, `updated_at`) VALUES ('現在の天気は?', '2015-04-01 15:00:00.000000', '2015-04-02 14:59:59.999999', '2015-04-02 08:55:05.394558', '2015-04-02 08:55:05.394558') [7] pry(main)> FactoryGirl.create(:question, body: '現在の天気は?', opened_at: b2, closed_at: e2) (0.1ms) BEGIN SQL (0.2ms) INSERT INTO `questions` (`body`, `opened_at`, `closed_at`, `created_at`, `updated_at`) VALUES ('現在の天気は?', '2015-04-01 15:00:00.000000', '2015-04-02 14:59:59.000000’, '2015-04-02 08:55:24.628060', '2015-04-02 08:55:24.628060')
結果
mysql> SELECT * FROM questions; +----+--------------------------------+---------------------+---------------------+-------------+---------------------+ | id | body | opened_at | closed_at | | created_at | updated_at | +----+--------------------------------+---------------------+---------------------+-------------+---------------------+ | 1 | 現在の天気は? | 2015-04-07 15:00:00 | 2015-04-07 15:00:00 | 2015-04-07 09:31:54 | 2015-04-07 09:31:54 | | 2 | 現在の天気は? | 2015-04-07 15:00:00 | 2015-04-07 14:59:59 | 2015-04-07 09:32:44 | 2015-04-07 09:32:44 | +----+--------------------------------+---------------------+---------------------+-------------+---------------------+
Time.zone.now.end_of_day
で発行された変数がSQLに流れた時
→ closed_at: 2015-04-07 14:59:59.999999
→ DB(MySQL)に入った時には 2015-04-09 15:00:00
になってる(罠)
DateTime.now.end_of_day
で発行された変数がSQLに流れた時
→ closed_at: 2015-04-07 14:59:59.000000
→ DB(MySQL)に入った時には 2015-04-07 14:59:59
さて、どうしてこのようなことが起きたのでしょうか。 問題は2点。
1点目(Ruby側)
Time classとDateTime classは、必ずしも挙動が同じとは限りません。 ざっくり言うならば、Time classの方が比較的精度が高いです。
そのため、「end_of_day」の23:59:59より細かいマイクロ秒の世界で Time classを利用すれば.99999....となり、 DateTime classを利用すれば .0000…となります。
2点目(MySQL側)
MySQL5.5まで切り捨てだったマイクロ秒の扱いですが、 MySQL5.6以上ではマイクロ秒は丸め込まれます。
MySQL :: MySQL 5.6 Reference Manual :: 11.2.7 Fractional Seconds in Time Values
なので.9999…は翌00分とみなされてしまったということです。
対処
- 根本的対処
→ 「今日の23:59:59まで」の扱い方を「翌日00:00:00 未満」とする。
マイクロ秒を気にしたくない場合は、アプリケーション側で制御するといいですね。
- 要件は見直さずに対処
Rails経由でActiveRecordでINSERTしようとするとき、
この問題(丸め込みが問題になる場合)を 解決するべく開発されているgemがあります。
なお、Ruby on Rails4.2.1以上からはこの問題は気にしなくて良くなります。
上記gemの作者さんが、RailsにPull Requestを出してこの問題に対応してくださいました。
https://github.com/rails/rails/compare/v4.2.0...v4.2.1
フレームワークも、日々ミドルウェアや言語自体のアップデートに伴い 更新されていくというのがリアルタイムで見れた良い例でした。
時間や日付の扱いについては、
どういった用途で、どちらのクラスを使うのが望ましい、 といった基準をチームで認識合わせしておけると安心ですね。
おまけ:timecop
ちなみに、こういった日付関係のアプリケーションを実装するにあたっては、 timecopというgemを利用してテストを書いています。
timecop.freeze
で時を止めたり、
timecop.travel
で過去や未来を行き来できる、
ちょっぴり心躍るgemです。