LoginSignup
2
2

More than 5 years have passed since last update.

ひとりTDDBC(LRUCache)仕様変更その2

Last updated at Posted at 2014-09-13

前回までのあらすじ

仕様変更その2

一定時間経ったデータは消えて欲しいとのことです。

cache = LRUCache.new(2, timeout: 60) # タイムアウトを60秒に設定
cache[:a] = 'alpha'
# 59秒以内にデータを取り出す
cache[:a] # => 'alpha'
# 60秒以上経った後でデータを取り出す
cache[:a] # => nil

まずLRUCacheインスタンスを作る時に、timeoutオプションで秒数を渡せるようにします。

  • データを入れてから、まだtimeout秒経っていない場合は取り出せる
  • データを入れてから、timeout秒以上経った場合は取り出せない(nilを返す)

というふうにしてみます。

レビュー

仕様変更その2に対応したコード

テスト

今回は時間が絡む動作をテストしないといけないので
テストコードでは時間をコントロールする必要があります。

例えば、60秒以上経ったデータが消えていることをテストしたい場合
簡単に書くと、

cache[:a] = 'alpha'
sleep(60)
expect(cache[:a]).to be_nil

cache[:z] = 'zulu'
expect(cache[:z]).to eq('zulu')

こういうふうに書けます。

が、このテストを実行すると必ず60秒かかってしまいます。
同じようなテストが複数あると、どんどんテストの実行が遅くなります。

TDDでは、Red => Green => Refactor
のサイクルをテンポよく回すことがとても重要で
テストはできる限り速く実行したいので、この方法はちょっと使えません。

反対に、タイムアウトを0.1秒に設定し、

cache[:a] = 'alpha'
sleep(0.1)
expect(cache[:a]).to be_nil

cache[:z] = 'zulu'
expect(cache[:z]).to eq('zulu')

こういうテストを書いたとします。

テストを速く実行する点では問題ありませんが
:zのデータを扱うところが微妙です。

cache[:z] = 'zulu' # 0.1秒以内に処理が完了すれば...
expect(cache[:z]).to eq('zulu') # => 'zulu' OK!

cache[:z] = 'zulu' # 何らかの理由で0.1秒以内に処理が完了しないと...
expect(cache[:z]).to eq('zulu') # => nil NG!

このように、テストを実行する度に結果が変わってしまう可能性があります。
プロダクションコードとテストが同じであれば、テスト結果も常に同じでないといけないので
この方法も使えません。

という訳で

RSpec.describe LRUCache, 'タイムアウトを60秒に設定' do
  let(:cache) { LRUCache.new(2, timeout: 60) }

  it '60秒以上経ったデータは消える' do
    stub_now(Time.now - 60) do # 60秒前にデータを入れたことにする
      cache[:a] = 'alpha'
    end
    cache[:b] = 'bravo'
    expect(cache[:a]).to be_nil
    expect(cache[:b]).to eq('bravo')
  end

  it '60秒経っていないデータは残る' do
    stub_now(Time.now - 59) do # 60秒経っていないことを保証する
      cache[:x] = 'x-ray'
      cache[:y] = 'yankee'
      cache[:x]
      cache[:z] = 'zulu'
      expect(cache[:x]).to eq('x-ray')
      expect(cache[:y]).to be_nil
      expect(cache[:z]).to eq('zulu')
    end
  end
end

テストはこのようにしました。

時間が絡むテストをしたい時はtimecopなどを使って、時間を制御すると便利なのですが
今回はそんなに凝ったことをしないので、ヘルパーメソッドを自作しました。

def stub_now(fake_time)
  allow(Time).to receive(:now) { fake_time }
  yield
  allow(Time).to receive(:now).and_call_original
end

stub_nowメソッドに渡したブロック内で、Time.nowを呼び出すと
必ずfake_timeを返すようにし
ブロックを抜けるとTime.nowは本来の時間を返すようになっています。

(このへんのスタブ周りもRSpec3.0で大きく変わりましたね)

プロダクションコード

class History
  class Line < Struct.new(:key)

    def initialize(key)
      super(key)
      @created_at = Time.now
    end

    def expired?(expire_at)
      @created_at <= expire_at
    end
  end
end

前のバージョンでは、Historyが保持するキーはキーそのものでしたが
今回はタイムアウトを考慮する必要があるので、キーを入れておくLineクラスを作り
キーとなるオブジェクトと一緒に、Lineオブジェクトが生成された日時も記録できるようにしました。

class Line < Struct.new(:key, :created_at)

としていないのは、
Line#==メソッドでkeyだけを同値性の基準としたいからです。

class History

  def initialize(timeout)
    @lines = []
    @timeout = timeout
  end

  def record(key)
    line = Line.new(key)
    @lines.tap {|x| x.delete(line) } << line
    line.key
  end

  def keys(capacity)
    @lines.slice!(0, @lines.size - capacity)
    timeout
    @lines.map {|l| l.key }
  end

private

  def timeout
    return unless @timeout
    @lines.reject! {|l| l.expired?(Time.now - @timeout) }
  end
end

Historyクラスの方は、キャッシュすべきキーを取得するkeysメソッドの中で
@timeout秒以上前に作成されたLine(キー)も削除するようにしています。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2