2021-01-03

timecopはどのようにして時を止めているか

timecop というgemがどうやって時間を止めているかについて調べた。

※ timecopのバージョンは、v0.9.2

最初に結論

TimeDate などの組み込みクラスをオープンしてメソッドを追加し、#alias_methodnowtoday といった本来のメソッド呼び出しを上書きしている

想定するケース

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 を調べる

instancesend_travel クラスメソッドの中でレシーバ無しで呼ばれているのでこれもクラスメソッドであることがわかる。

しかしTimecopクラスの中に instance クラスメソッドは見つからない。であるならば、moduleかなにかから取り込んでいる可能性が高い。 そう思って調べると 13行目include Singleton をしていた。

class Timecop
  include Singleton

  # 省略 ..

Singletonは標準ライブラリなのでRubyのリファレンスを見に行こう。

案の定 instance という特異メソッドが提供されていることがわかり、そのメソッドの説明を読んでみると次のように書かれていた。

そのクラスの唯一のインスタンスを返します。最初に呼ばれたときはそのインスタンスを生成します。 Singleton を include したクラスで定義されますので、正確には Singleton モジュールのメソッドではありません。

includeしたクラスの唯一のインスタンスを返すということから、instance メソッドでTimecopのインスタンスが返ってきていることがわかった。

#travel の定義元へ向かう

以下再掲となるが、instance に対して #sendtravel メソッドを呼び出している。

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 を使って偽装した時間情報を返している