timecopはどのようにして時を止めているか
timecop というgemがどうやって時間を止めているかについて調べた。
※ timecopのバージョンは、v0.9.2
最初に結論
Time
や Date
などの組み込みクラスをオープンしてメソッドを追加し、#alias_method
で now
や today
といった本来のメソッド呼び出しを上書きしている
想定するケース
timecop にはいくつかの機能があるが、今回調べるのは Timecop.freeze
により時を止める方法。
# サンプルコード
Timecop.freeze('2021-01-03 10:30:00')
コードリーディング
さあコードを読んでいこう。
規模感の把握
まず最初にtimecopというライブラリの規模感を把握したかったので、全体感を調べる。git cloneして、libディレクトリ以下を眺める。
❯ tree lib
lib
├── timecop
│ ├── time_extensions.rb
│ ├── time_stack_item.rb
│ ├── timecop.rb
│ └── version.rb
└── timecop.rb
1 directory, 5 files
グレート。5filesのみ。これはすぐに結果に辿り着きそうだなという感触を得る。
続いて、それぞれのファイルのコード行数を見てみる。
❯ find ./lib -name '*.rb' | xargs -I {} wc -l {} | sort -r
240 ./lib/timecop/timecop.rb
140 ./lib/timecop/time_stack_item.rb
140 ./lib/timecop/time_extensions.rb
3 ./lib/timecop/version.rb
2 ./lib/timecop.rb
この結果から行数上位の3ファイルだけ見ればいいことが伺える。
.freeze
の定義元へ向かう
事前調査が終わったところで早速、調査対象の freeze
メソッドの定義元へ向かおう。
Timecop
クラスのクラスメソッドとして freeze
があることはわかっているので、 lib/timecop/timecop.rb
を開く。
さらっと眺めて、49行目に見つけた。
class Timecop
# 省略 ..
class << self
# 省略 ..
def freeze(*args, &block)
send_travel(:freeze, *args, &block)
end
..
send_travel
というクラスメソッドの第1引数を :freeze
にしてあとはそのまま引き渡しているだけということがわかった。
.send_travel
の定義元へ向かう
freezeはクラスメソッドなので、そこからレシーバ無しで呼び出している send_travel
も同じくクラスメソッドであることがわかる。
おそらく近い位置にあるという予想を立てつつ、定義元を探す。
すると128行目 で見つけた。
class Timecop
# 省略 ..
class << self
# 省略 ..
private
def send_travel(mock_type, *args, &block)
val = instance.send(:travel, mock_type, *args, &block)
block_given? ? val : Time.now
end
..
instance
というメソッドの返り値に対して travel
メソッドを send
で呼び出して結果を val
というローカル変数で受けて、ブロックが渡された場合は val
を、渡されていない場合は Time.now
を返しているようだ。
instance
を調べる
instance
は send_travel
クラスメソッドの中でレシーバ無しで呼ばれているのでこれもクラスメソッドであることがわかる。
しかしTimecopクラスの中に instance
クラスメソッドは見つからない。であるならば、moduleかなにかから取り込んでいる可能性が高い。
そう思って調べると 13行目 で include Singleton
をしていた。
class Timecop
include Singleton
# 省略 ..
Singletonは標準ライブラリなのでRubyのリファレンスを見に行こう。
案の定 instance
という特異メソッドが提供されていることがわかり、そのメソッドの説明を読んでみると次のように書かれていた。
そのクラスの唯一のインスタンスを返します。最初に呼ばれたときはそのインスタンスを生成します。 Singleton を include したクラスで定義されますので、正確には Singleton モジュールのメソッドではありません。
includeしたクラスの唯一のインスタンスを返すということから、instance
メソッドでTimecopのインスタンスが返ってきていることがわかった。
#travel
の定義元へ向かう
以下再掲となるが、instance
に対して #send で travel
メソッドを呼び出している。
val = instance.send(:travel, mock_type, *args, &block)
よって、Timecop
クラスのインスタンスメソッド travel
があるはずなのでそちらへ向かう。すると 189行目 に見つけた。
class Timecop
# 省略 ..
private
# 省略 ..
def travel(mock_type, *args, &block) #:nodoc:
raise SafeModeException if Timecop.safe_mode? && !block_given? && !@safe
stack_item = TimeStackItem.new(mock_type, *args)
stack_backup = stack.dup
stack << stack_item
if block_given?
safe_backup = @safe
@safe = true
begin
yield stack_item.time
ensure
@stack.replace stack_backup
@safe = safe_backup
end
end
end
..
これまで見てきたメソッドと比較すると少し長くなるが、関心がある部分だけ見ると実は少ない。
コードを読む前に事前にREADMEを見ていて、1行目の safe_mode は今回は関係ないことを認識しており、また後半の if block_given?
以下は今回のユースケースではブロックを使わないので無視できる。
よって、実質見るべきは以下の3行。
stack_item = TimeStackItem.new(mock_type, *args)
stack_backup = stack.dup
stack << stack_item
TimeStackItemという初登場のクラスのインスタンスを生成しており、それを stack
という何かにappendしているようだ。
.send_travel
を再確認
ここで一旦、send_travel
メソッドに戻ろう。このメソッドは次の2行で構成されていた。
def send_travel(mock_type, *args, &block)
val = instance.send(:travel, mock_type, *args, &block)
block_given? ? val : Time.now
end
travel
メソッドの返り値を val
で受け取っているが、blockが渡された場合のみ val
を返す。そうでない場合は Time.now
を返す。
今回は単純に Timecop.freeze ..
で時間を止めたときを考えているので後者にあたる。
あれ?このままだと Time.now
が普通の時間を返しそうだぞ?という気がしてくる。
TimeStackItem
という初登場のクラスは怪しいが、インスタンスを生成して stack
というものにappendしているだけで、そのタイミングで何かをやっているとは考えにくい。
🤔 :thinking_face
考えられるのは2通りある。
TimeStackItem
かそれをstackにappendするときに何かやっているTime.now
はこの時点ですでに偽装されている
最初に後者を疑ってあてもなくコードをサラサラと眺めていると、今まで見ていた lib/timecop/timecop.rb
の最後に以下の2行を見つけた。
# This must be done after TimeCop is available
require File.join(File.dirname(__FILE__), "time_extensions")
time_extensions
というファイルをrequireしていた。コメントによるとTimecopが利用可能になったあとにrequireする必要があるらしい。
そういえば time_extensions
というファイル、最初にtimecopの規模感を調べるときにチラッと見たなぁと思い出した。そちらへ行ってみよう。
time_extensions
lib/timecop/time_extensions.rb を開く。 Timeクラスをオープンして何かをやっているのが見て取れる。ここに探していたものがあったと確信する。
まず 17行目 の部分。
alias_method :now, :now_with_mock_time
Module#alias_method は第2引数のメソッドのaliasを第1引数のメソッドとして定義する。
つまり今回だと Time.now
が呼び出されたとき、実質 Time.now_with_mock_time
を返しているということになる。
では now_with_mock_time
はどうなっているか?
def now_with_mock_time
mock_time || now_without_mock_time
end
mock_time
というメソッドを呼び出して、その結果がfalseyの場合は now_without_mock_time
を返している。
mock_time
は次のようになっていて、Timecop.top_stack_item
を参照し、結果がtruthyであれば .time(self)
を呼び出している。
def mock_time
mocked_time_stack_item = Timecop.top_stack_item
mocked_time_stack_item.nil? ? nil : mocked_time_stack_item.time(self)
end
Timecop.top_stack_item
は、#travel
インスタンスメソッドでstackに追加した TimeStackItem
のインスタンスが返り、それに対して time
メソッドが呼び出されている。
TimeStackItemの詳細は細かい部分となるので省略するが、time
メソッドでは最初の Timecop.freeze
に渡した固定したい時間情報を、最終的に Time.at
で変換して返すようになっていた。
まとめ
Timecop.freeze
による時間の固定をどのようにして実現しているか追った。
Timecop.freeze(..)
の引数として渡した時間情報は、TimeStackItem
のインスタンスとして変換されtimecop内部のstackに保存される- 組み込みのTimeクラスはオープンクラスによりメソッドが追加されており、
Time.now
は追加されたメソッドnow_with_mock_time
に上書きされている now_with_mock_time
メソッドは、固定したい時間情報をもったTimeStackItem
を使って偽装した時間情報を返している