Rubyコードの開発中に、時間を変更するときには、timecop
gem が便利である。
timecop - Github
https://github.com/travisjeffery/timecop
例えば、 Timecop.travel(t)
を実行した時点で、そのRubyインスタンスの現在時刻が変わり、かつ、t
の時点から時間が進む。
Timecop.freeze(t)
を実行すると、現在時刻がt
で固定となる。
などなど、いくつかのメソッドが用意されている。
(t
はTimeクラスのインスタンス)
Railsと組み合わせると、開発チームが共用で使っている開発サーバの時間を変更・偽装・シミュレート出来て便利である。ちょっとしたサービスなら、時間をからめたテストが必要だったり、イベントやキャンペーンの確認に使えるだろう。
Timecop.travel
を活用するためのちょっとしたスニペットをご紹介。
ページを作ろう
timecop
単体では、UIは提供していない。
ちょっとした規模のサービスやアプリケーションなら、開発用のデバッグページがあったり、管理用のページがあるだろう。
Viewから時間を変更できるような画面を作っておけば、開発者以外のチームメンバーでも簡単に時間を変えられる。
当然ながら、本番環境では、本記事のスニペットは全部無効になるように実装しておくのが良い。
class DebugController < ApplicationController
def update_time
if params[:time].present?
Timecop.travel(Time.parse(params[:time]))
else
Timecop.return
end
end
end
例外に備えよう
とは言え、時間をごまかしたくない例外もある。
例えば、aws-sdk
だ。aws-sdk を使ってS3にアクセスするとき、ほんとうの時間とリクエスト時のタイムスタンプのズレが大きいと、下記のようなエラーが出る。
The difference between the request time and the current time is too large. (Aws::S3::Errors::RequestTimeTooSkewed)
他にも、なにか外部サービスを利用していて時間をごまかしたくない時にどうするか・・・。aws-sdkにモンキーパッチを当てようにも、Time.now
を参照している箇所が多すぎる。
下記のような黒魔術で、呼び出し元によってtimecop
を無効化させることができる。
class Time
class << self
def now_wrap
if (caller || []).first.match('aws-sdk')
now_without_mock_time # return real time
else
mock_time || now_without_mock_time # return mock time or real time
end
end
alias_method :now, :now_wrap
end
end
- 現時点の
timecop
のバージョンでは、非公開APIだが内部的にmock_time
メソッドとnow_without_mock_time
メソッドをTimeクラスに生やしているのでこれを利用して現実世界の正しい時刻を得る。 - Rubyの
Kernel#caller
を使ってコールスタック(呼び出し元)にaws-sdk
が含まれるか判定する。呼び出し元によって暗黙的に動作を変えるのは、極悪と言っても良いプログラミングスタイルである。通常の状況でヤッてはいけない。
再起動に備えよう
timecop
単体では、永続化機能は提供していない。ページを作るだけでは不十分で、デプロイ契機で揮発する。
ファイルシステムでも、DBでも、なんでも良いので、永続化させちゃえば良い。
設定するところ
# 入力値を保存
redis.set("timecop:travel_time", params[:time])
# 現在のリアル時刻も一緒に保存
redis.set("timecop:travel_started_time", Time.now_without_mock_time.to_s)
# アプリケーションサーバを再起動させる。下記コマンドは Unicornな想定。
system 'kill -s USR2 `cat /var/www/hogehoge-pj/shared/tmp/pids/unicorn.pid` &'
- 現代のアプリケーションは、マルチプロセスで動くことは当たり前である。Unicornもワーカープロセスの数を自由に設定できるし、開発環境でもマルチで動作検証しておくべきだろう。複数のプロセスに
timecop
を一気に適用させるには、再起動させるのが手っ取り早い。再起動の方法は環境に依存する。
Rails起動時(application.rbとか)にやること
tt = redis.get("timecop:travel_time")
tst = redis.get("timecop:travel_started_time")
if tt && tst
a = Time.parse(tt)
b = Time.parse(tst)
# ここで時間変更。設定した日次に、経過時間を加算する
Timecop.travel(a + (Time.now_without_mock_time - b).to_i.seconds)
end
起動時に毎回同じ時刻に .travel するのではなく、差分計算をして経過時間をシミュレートしたほうが、非エンジニア的にはうれしい。
まとめ
Ruby は標準のメソッドもばんばん書き換えられるので、たまに苦しめられるけど、楽しい。
今回は timecop gem
を紹介したが、これを使わずにDIYするのも容易いと思う。