やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

ActiveSupportのtravel_toの仕組みについて

ActiveSupportには、時間を固定することが可能な便利なtravel_toという関数があります(よく単体テストでお世話になっています)。travel_toを呼び出した後にTime.nowで現在時刻を取得してみると、何とtravel_toで指定した日時が取得されるではありませんか。

pry> Time.now
=> 2022-10-21 19:33:30.314909 +0900
pry> travel_to Time.local(2022, 10, 8, 15, 0, 0)
=> nil
pry> Time.now
=> 2022-10-08 15:00:00 +0900

一体、どのようにして現在日時を固定させているのでしょうか。気になったので調べてみました。
結論としては、現在日時を取得するメソッドの振る舞いを書き換えて、指定した値を固定で返すようにしていました。

travel_toのソースコード

ActiveSupportのコードがgithubに公開されているので、内部実装を見てみます。

def travel_to(date_or_time, with_usec: false)
  # 長いため省略

  if date_or_time.is_a?(Date) && !date_or_time.is_a?(DateTime)
    now = date_or_time.midnight.to_time
  elsif date_or_time.is_a?(String)
    now = Time.zone.parse(date_or_time)
  elsif with_usec
    now = date_or_time.to_time
  else
    now = date_or_time.to_time.change(usec: 0)
  end

  stubbed_time = Time.now if simple_stubs.stubbing(Time, :now)
  simple_stubs.stub_object(Time, :now) { at(now.to_f) }
  simple_stubs.stub_object(Date, :today) { jd(now.to_date.jd) }
  simple_stubs.stub_object(DateTime, :now) { jd(now.to_date.jd, now.hour, now.min, now.sec, Rational(now.utc_offset, 86400)) }

  # 長いため省略
end

github.com

まずにブロック変数が与えられたかどうかで処理を分岐させていますが、今回はブロック引数を使用しないので飛ばして、変数nowに値が束縛される箇所からです。与えられた引数の型によって処理が分岐しています。

if date_or_time.is_a?(Date) && !date_or_time.is_a?(DateTime)
  now = date_or_time.midnight.to_time
elsif date_or_time.is_a?(String)
  now = Time.zone.parse(date_or_time)
elsif with_usec
  now = date_or_time.to_time
else
  now = date_or_time.to_time.change(usec: 0)
end

今回、マッチするのは最後のelse句です。引数で与えた値に対して.to_time.change(usec: 0が実行されます。

date_or_time = Time.local(2022, 10, 8, 12, 0, 0) # 2022-10-08 12:00:00 +0900(Time)
now = date_or_time.to_time.change(usec: 0) # 2022-10-08 12:00:00 +0900(Time)

次に書かれているのは見慣れない処理です。simple_stubsというのはTimeHelpersに実装されている@simple_stubsというクラス変数に値が束縛されていなければSimpleStubsのインスタンスを作成して束縛させる関数です。

このSimpleStubsクラスは標準ライブラリのConcurrent::Mapを用いて、値を記録するキーバリューストアの役割を担っているようです。

SimleStubsの実装

SimleStubsのインスタンスにはstub_objectという関数が実装されています。この関数は先ほどのtravel_toの中で実行されています。

simple_stubs.stub_object(Time, :now) { at(now.to_f) }
simple_stubs.stub_object(Date, :today) { jd(now.to_date.jd) }
simple_stubs.stub_object(DateTime, :now) { jd(now.to_date.jd, now.hour, now.min, now.sec, Rational(now.utc_offset, 86400)) }z

以下のコードがstub_object関数の実装です。引数で指定された値がすでに、@stubsに登録されていれば、一度、unstub_objectを呼び出してリセットしています。その後、@stubsに新しくStub構造体を作成して登録しています。

def stub_object(object, method_name, &block)
  if stub = stubbing(object, method_name)
    unstub_object(stub)
  end

  new_name = "__simple_stub__#{method_name}"

  @stubs[object.object_id][method_name] = Stub.new(object, method_name, new_name)

  object.singleton_class.alias_method new_name, method_name
  object.define_singleton_method(method_name, &block)
end

登録後、引数で指定されたオブジェクトのシングルトンクラスに対してブロック引数で渡された値を用いて、メソッドの定義を行っています。ここはあまり見慣れない処理ですが、RubyではObjectクラスを継承しているクラスにはsingleton_classが提供されており、オブジェクト単位でシングルトンクラスが定義されています。

pry> Time.singleton_class
=> #<Class:Time>
pry> ActiveSupport.singleton_class
=> #<Class:ActiveSupport>

このシングルトンクラスに対してstub_objectは新しい関数の定義を行っています。「そんなことできるん?」と思ったのですが、試しに適当に作ったクラスに対して実行してみました。

class Sample
  def self.hello
    puts "hello from sample!"
  end
end

Sample.hello # hello from sample!

このSampleクラスのシングルトンクラスにメソッドを定義してみると...
何と既存のメソッドの振る舞いと別名でのメソッド呼び出しが出来るようになりました。

Sample.singleton_class.alias_method('new_hello', :hello) # :new_hello
Sample.define_singleton_method(:hello) { puts "new hello from sample!" } # :hello

Sample.new_hello # hello from sample!
# => nil

Sample.hello # new hello from sample!
# => nil

どうやらalias_methodは最新の関数定義ではなく、alias_methodが呼び出された時点での関数の定義を参照するようです。もう一度、alias_methodを呼び出すとnew_helloの実行結果が変化しました。

Sample.singleton_class.alias_method('new_hello', :hello) # :new_hello

Sample.new_hello # new hello from sample!
# => nil

まとめ

このように任意のオブジェクトに実装された関数の振る舞いをシングルトンクラスを用いて変更していたというのが、travel_toの正体でした。

Time.singleton_class.alias_method('now', :now)
Time.define_singleton_method(:now) { Time.local(2022, 10, 8, 15, 0, 0) }

Time.now # 2022-10-08 15:00:00 +0900

alias_methoddefine_singleton_methodを組み合わせることで、こんなことが出来るんですね。すごい。

www.okb-shelf.work

参考文献