この記事は Akatsuki Advent Calendar 2017 の2日目の記事です。
1日目 Pull Request の指摘修正でコミットログが汚れていく問題への対処法の一案

timecop とは

timecop とは Ruby のライブラリで、ブロックの中で

  • 時間を指定して固定する
  • 時間を過去の時点に戻す
    • 時間は止まらない
  • 時間が経つのを早く・遅くする

することができます。また、ブロックのネストにも対応しており

t = Time.local(2008, 10, 10, 10, 10, 10)
Timecop.freeze(2008, 10, 10, 10, 10, 10) do
  # Time.now == t
  t2 = Time.local(2008, 9, 9, 9, 9, 9)
  Timecop.freeze(2008, 9, 9, 9, 9, 9) do
    # Time.now == t2
  end
end

のように使用することができます。

今回はこのライブラリの

  • 時間を止める(Timecop.freeze)
  • ブロックのネストに対応する

の2つの機能に焦点をあてて実装を解説していきます。

時間を止める

時間を止めるだけであればオープンクラスを使い Time.now を保存している時間を返すように変更することででき、それをやっている部分が time_extensions.rb です。この後に解説しますが、 timecop は内部に止めている時間を保存しておくためのスタックを持っていて、Time.now を、保存している時間を返すように変更しています。

time_extensions.rb
class Time #:nodoc:
  class << 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

    # 実際の時間は Time.now_without_mock_time でとれるようにしておく
    alias_method :now_without_mock_time, :now

    def now_with_mock_time
      mock_time || now_without_mock_time
    end

    # Time.now を保存していた時間を返すように変える
    alias_method :now, :now_with_mock_time

  ......
  end
end

ブロックのネストに対応する

ブロックのネストに対応するためにはスタックを使います。最初に入ったブロックからは最後に出るので、それぞれのブロックの中で固定したい時間をスタックに積んでおき、ブロック内で Time.now が呼ばれたらスタックの一番上にある時間を返します。
スタックが空であれば Timecop.freeze のブロック内ではないので、 now_without_mock_time メソッドで時間を返します。

time_extensions.rb
class Time #:nodoc:
  class << 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

    # 実際の時間は Time.now_without_mock_time でとれるようにしておく
    alias_method :now_without_mock_time, :now

    # 保存している時間を返す
    # スタックが空であればブロック内ではないので、通常の Time.now を返す
    def now_with_mock_time
      mock_time || now_without_mock_time
    end

    # 保存していた時間を返すように Time.now を変える
    alias_method :now, :now_with_mock_time

  ...
  end
end

そして、その時間を保存しておくスタックが timecop.rb のTimecopクラスがもつ @stack です。固定された時間を返すため、Time クラスのラッパーのような TimeStackItem クラスを定義していて、これがスタックに積まれています。

timecop.rb
class Timecop
  include Singleton

  ...

  class << self
    ...

    def top_stack_item #:nodoc:
      instance.send(:stack).last
    end
  end

  private

  ...

  def stack
    if @thread_safe
      Thread.current[:timecop_stack] ||= []
      Thread.current[:timecop_stack]
    else
      @stack
    end
  end
end

最後に、 Timecop.freeze を呼ぶと travel メソッドが呼ばれ、ブロックを実行するまえに渡した Time がスタックに積まれます。

timecop.rb
class Timecop
  include Singleton

  class << self
    def freeze(*args, &block)
      send_travel(:freeze, *args, &block)
    end

    ...

    private
    def send_travel(mock_type, *args, &block)
      val = instance.send(:travel, mock_type, *args, &block)
      block_given? ? val : Time.now
    end
  end

  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
        # スタックをもとに戻す。Time が一つ上のブロック内のものに戻る。
        @stack.replace stack_backup
        @safe = safe_backup
      end
    end
  end

  ...
end

以上が Timecop.freeze の動きになります。

残りの2つの機能について

説明しなかったのこりの 2 つの機能についてです。

  • 時間を過去の時点に戻す(Timecop.travel)
  • 時間が経つのを早く・遅くする(Timecop.scale)
  • ですが、これらもブロックのネストの対応部分は Timecop.freeze と同じです。
    異なるのは時間計算の部分で、どのように実装されているかですが TimeStackItem クラスが重要になってきます。
    TimeStackItem クラスはいくつかの情報を持っていて

  • 今のブロックが freeze, trabel, scale のどれかを表す mock_type

  • ブロックに渡された時間を保存する time

  • スケールさせる倍率 scaling_factor

  • これらの情報と、 Time.now_without_mock_time でとれる実際の時間を組み合わせて計算をしています。それをやっているのが TimeStackItem クラスの time メソッドになります。

time_stack_item.rb
class Timecop
    # A data class for carrying around "time movement" objects.  Makes it easy to keep track of the time
    # movements on a simple stack.
    class TimeStackItem #:nodoc:
      attr_reader :mock_type

      def initialize(mock_type, *args)
        raise "Unknown mock_type #{mock_type}" unless [:freeze, :travel, :scale].include?(mock_type)
        @travel_offset  = @scaling_factor = nil
        @scaling_factor = args.shift if mock_type == :scale
        @mock_type      = mock_type
        @time           = parse_time(*args)
        @time_was       = Time.now_without_mock_time
        @travel_offset  = compute_travel_offset
      end

      ...

      def scaled_time
        (@time + (Time.now_without_mock_time - @time_was) * scaling_factor).to_f
      end

      ...

      def time(time_klass = Time) #:nodoc:
        if @time.respond_to?(:in_time_zone)
          time = time_klass.at(@time.dup.localtime)
        else
          time = time_klass.at(@time)
        end

        if travel_offset.nil?
          # freeze の場合は time をそのまま返す
          time
        elsif scaling_factor.nil?
          # travel の場合は実際の時間から戻った分の時間を引く
          time_klass.at(Time.now_without_mock_time + travel_offset)
        else
          # scale の場合も頑張って計算する
          time_klass.at(scaled_time)
        end
      end
    end
end

まとめ

Ruby のライブラリである timecop を Crystal に移植する際に勉強したことを書きました。ブロックのネストに対応するためにスタックを使う部分などが面白く、しかしコードは小さいので勉強の題材にはちょうど良さそうだなと思いました。