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
まずにブロック変数が与えられたかどうかで処理を分岐させていますが、今回はブロック引数を使用しないので飛ばして、変数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_method
とdefine_singleton_method
を組み合わせることで、こんなことが出来るんですね。すごい。