読者です 読者をやめる 読者になる 読者になる

「現在時刻」を外部入力とする設計と、その実装のこと

こんにちは。技術部 開発基盤グループの諸橋です。

クックパッドでは昨今の多くのWeb企業と同じように、GitHub EnterpriseのPull Requestを使ったコードレビューを広範に実施しています。わたしたちのコードレビューでは、ソースコードの字面にとどまらず、サービスの機能として魅力的かどうかや、保守性を含めた設計が適切かといった議論に発展することも良くあります。

きょうはそんななかで話題に上がった「現在時刻」の扱いかたに関する設計の話を書きます。

背景

サービスを開発・運営している我々には、時間帯によって出し分けたり、特定の期間のみに表示したいコンテンツがたくさんあります。 そのたびにデプロイし直すというのはつらいので(特に24:00に出なくなるコンテンツなど)なんとかしたくなりますが、一方で時限式のコンテンツはその時になるまでちゃんと動いているか確証が取れないので怖いです。

このつらさをなんとか軽減できないものかと考えました。

つらさの整理

たいへん身近な概念なので私たちは忘れがちですが、 現在時刻というのはプログラマが制御出来ない外部からの入力です。そのため、プログラムのいろいろなところで自由に入力を受け入れると外部環境への依存度が上がってしまい、自由に動かしたりテストしたりするのが難しくなります。

さらに、前述のような時限式コンテンツの判定をする場合、その取得した日時がある基準時刻の以前/以降であるか、あるいは時間帯にかぶっているかなどの判定をすることが多いはずです。こういった判定は、それ自体はけして難しいものではありませんが、他のロジックと混在すると煩雑になりがちです。

言い換えると、時限機能の作りづらさは、このような問題をひとまとめに解こうとしてしまうことに由来します。

またこういった時限機能は、自動テストを書く場合にも考えるべきことが増えてしまいます。たとえば、自動テスト時に日時をスタブするする定番ライブラリとしてTimecopというgemがあります。このgemは、Time.nowの振る舞いを書き換えることで日時をスタブしますが、capybara-webkitを使ったEnd-to-Endテストではうまく動きません。これは、テスト対象のRubyコードの日時はスタブできても、capybara-webkitが起動するブラウザプロセスの日時はスタブ出来ず、齟齬が生まれるためです。このように、外部プロセスとのやり取りが発生することになると、単純に「言語レベルで現在日時をスタブ」という方法では行き詰まってしまいます。

解法

この問題との向き合いかたには特別なことはありません。外部からの入力に対しては、読み込む箇所を局所化し、いったん読み込んだ値を各所で使っていきます。また時間帯にかぶるかどうかといった判定も抽出していきます。

日時の判定処理を抽出する

例えばビューにこういった処理があったとします。

- if @start_at <= Time.now && Time.now < @end_at && current_user.target? && some_condition?(current_user)
  = render(:special_event) # 時間になると現れるコンテンツ

まずは時刻がかぶっているかどうかの判定を、クラスやメソッドに抽出します。

def enabled_now?
  @start_at <= Time.now && Time.now < @end_at
end
- if enabled_now? && current_user.target? && some_condition?(current_user)
  = render(:special_event) # 時間になると現れるコンテンツ

さらに、抽出したenabled_now?の中も、もっと整理できそうです。 2回.newされているTimeは同一オブジェクトであるべきです。また、前述のように日時のカバーの判定をしている処理と外部入力の読み込みであるTime.nowはわけたほうがメソッドの責務は少なくなります。

修正範囲を最小にしたい場合、Rubyであればデフォルト引数などにするのがもっとも簡単でしょう。

def enabled?(at: Time.now)
  @start_at <= at && at < @end_at
  # あるいは (@start_at ... @end_at).cover?(at) など
end

こうしておくと、ビュー全体のテストではなく、この判定に関心事を絞ってテストもできるようになります。例えば自動テストにて検証したい場合でも、Timecopを使う必要がなくなります。

下記ではヘルパーメソッドからさらに、判定を行う小さなクラスに抽出しています。

context 'while being enabled' do
  let(:policy) { TimePeriodPolicy.new(start_at: 1.second.ago(at), end_at: 1.second.since(at)) }
  let(:at) { Time.now }

  it { expect(policy).to be_enabled(at) }
end

describe 'Xmas period' do
  let(:xmas_policy) do
    TimePeriodPolicy.new(
      start_at: Time.zone.parse('2015/12/20'),
      end_at:   Time.zone.parse('2015/12/25').end_of_day
    )
  end

  context 'in 12/19' do
    it { expect(xmas_policy).not_to be_enabled(Time.zone.parse('2015/12/19 00:00:00')) }
  end

  context 'in 12/20' do
    it { expect(xmas_policy).to be_enabled(Time.zone.parse('2015/12/20 00:00:00')) }
  end

  context 'in 12/26' do
    it { expect(xmas_policy).not_to be_enabled(Time.zone.parse('2015/12/26 00:00:00')) }
  end
end

実際のサービスでのコンテンツ出し分けは、時間帯だけでなくユーザの状態やその他データも勘案する必要があるケースが多いでしょう。それでも、日時を外部化しておくことでテストを完全にコントロールできるメリットは大きいはずです。

日時を取得する処理を局所化する

さて、日時を元に条件を判定する箇所は抽出できました。では外部入力の局所化、つまり現在日時を取得する箇所はどのようにすればよいでしょうか。こちらも定石通り進めていきましょう。すなわち

  • 外部環境を読み込む箇所を一箇所にする
  • ロジック内からは、そこで読み込んだ局所化したインターフェースから中身を参照する

というアプローチです。

今回の例で言えば「現在時刻」として欲しかったものは、実は厳密な意味でのコード実行時点の現在、ではなく「リクエストされた時間」で十分です。そのため、それを取得するインターフェースを一箇所に限定します。

現在クックパッドでは、そのインターフェースを統一するためにTriceというgemを作り、使っています。

このgemは、Railsのコントローラにリクエストが到達した時間を表すrequested_atメソッドを提供します。

class ApplicationController < ActionController::Base
  include Trice::ControllerMethods
end
class SpecialEventsController < Applicationcontroller
  def show
    ...
    if @event.policy.enabled?(requested_at)
      do_something
    else
      head :not_found
    end
  end
end

モデルの処理でこの時刻を使うには、コントローラからその時刻を渡してあげます。 なぜなら、現在を知るのは「外部からの入力を適切にモデルに渡す」というコントローラの責務の範囲であり、モデル側はそのTimeオブジェクトの由来を関知すべきでないからです。

ビューでも同様に、Time.nowを直接呼ぶのではなく、requested_atから取得できる値を基準として時限機能を判定します。

- @event.policy.enabled?(requested_at)
  = render(:special_event) # 時間になると現れるコンテンツ

さて、このように現在時刻を取得するインターフェースを抽出すると、自動テストの中から参照する現在時刻を変更するのも簡単になります。

Timecopなどのようにプロセス全体で共有されるTime.nowをスタブするのではなく、controller#requested_atのみをスタブすればよくなるため、スタブが影響する範囲をコントロールしやすくなります。

before do
  controller.stub(:requested_at) { Time.zone.parse('2015/12/20 00:00:00') }

  get :show, id: xmas_event.id
end

システム時刻以外の入力も受け付けられるようにする

さらに、ここまでリファクタリングを進めた結果、当初は「現在時刻によって挙動が変わる」機能であったものが、実は「リクエスト時刻とみなしたTimeオブジェクトに基づいて挙動が変わる」という機能だったことに気付きます。 ということは、Time.nowを呼んでシステム時刻を取得する以外の方法でリクエスト時刻を設定できれば、自動テストや動作確認がとてもやりやすくなります。

Triceでは実際に、リクエストのHTTPヘッダを使って外部から、基準日時を設定できるようになっています。 自動テスト内からこのリクエストヘッダを利用して基準日時を自由に設定するためのテストヘルパーもあります。

このような実装があれば、本番に近い構成の手動テスト環境でも時限機能の動作を無理なく確認できます。また、CapybaraでのEnd-to-Endテストのような高レベルな自動テストからも同じように基準日時を設定してテストできるようになります。前述のように、Timecopなど処理系のTime.nowをまるごとスタブするライブラリは動かないことがありますが、Triceの方式であれば問題なく動作します。

まとめ

現在時刻の取得(Time.now)は外部入力の読み取りにほかなりません。現在時刻に基づいて分岐するような処理は、一見簡単に見えますが、少しずつアプリケーションを複雑にしていきます。

それを、

  • 入力取得と判定部分を分離する
  • 入力取得する箇所を統一・局所化する
  • 必要に応じて、外部の値で上書きできるようにする

とリファクタリングしていくことで、動作確認や自動テストでの扱いやすさを取り戻しました。

こういった方法は、決して特別で難しいことをしているわけではありません。どちらかといえばオブジェクト指向だったりプログラミング一般だったりの基本的な考え方を実際のアプリケーションに適用し、そのために必要な小さなライブラリをつくっただけです。それでも、実際のサービスでよく見るつらさを軽減できているのではないでしょうか。

あわせて読みたい

前述のように、この現在日時を適切に抽出してコードを整理する方法は、決して目新しいものではなく、多くの書籍やサイトで語られている考え方です。先達の多くの情報のうち、特によくまとまっていると感じたURLも示しますので、よかったらこちらも合わせてどうぞ。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/