ポイントシステム(4) -- サービスオブジェクト

2013/10/01

前回までに「ポイントシステム」の実装が進みましたが、Customer.authenticate メソッドが肥大化してきました。今後の開発のことを考えて、今回はリファクタリングによるソースコードの整理整頓を行います。

サービスオブジェクト

現在の Customer.authenticate メソッドのコードは次の通りです:

    def authenticate(username, password)
      customer = find_by_username(username)
      if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
        Time.zone = 'Tokyo'
        now = Time.current
        if now.hour < 5
          time0 = now.yesterday.midnight.advance(hours: 5)
          time1 = now.midnight.advance(hours: 5)
        else
          time0 = now.midnight.advance(hours: 5)
          time1 = now.tomorrow.midnight.advance(hours: 5)
        end

        unless customer.rewards.where(created_at: time0...time1).exists?
          customer.rewards.create(points: 1)
        end
        customer
      else
        nil
      end
    end

まず思いつくのは、顧客にログインポイントを発行している部分をプライベートメソッドとして抜き出すことです:

    def authenticate(username, password)
      customer = find_by_username(username)
      if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
        grant_reward_to(customer)
        customer
      else
        nil
      end
    end

    private
    def grant_login_points_to(customer)
      Time.zone = 'Tokyo'
      now = Time.current
      if now.hour < 5
        time0 = now.yesterday.midnight.advance(hours: 5)
        time1 = now.midnight.advance(hours: 5)
      else
        time0 = now.midnight.advance(hours: 5)
        time1 = now.tomorrow.midnight.advance(hours: 5)
      end

      unless customer.rewards.where(created_at: time0...time1).exists?
        customer.rewards.create(points: 1)
      end
    end

しかし、もっといい方法があります。ポイント発行処理を専門に担うクラス RewardManager を新たに作るのです。

まず、app ディレクトリの下に services というサブディレクトリを作ります。

$ mkdir app/services

そして、新規ファイル app/services/reward_manager.rb を次のような内容で作成します。

class RewardManager
  attr_accessor :customer

  def initialize(customer)
    self.customer = customer
  end

  def grant_login_points
    Time.zone = 'Tokyo'
    now = Time.current
    if now.hour < 5
      time0 = now.yesterday.midnight.advance(hours: 5)
      time1 = now.midnight.advance(hours: 5)
    else
      time0 = now.midnight.advance(hours: 5)
      time1 = now.tomorrow.midnight.advance(hours: 5)
    end

    unless customer.rewards.where(created_at: time0...time1).exists?
      customer.rewards.create(points: 1)
    end
  end
end

続いて、Customer.authenticate メソッドを次のように書き換えます:

    def authenticate(username, password)
      customer = find_by_username(username)
      if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
        RewardManager.new(customer).grant_login_points
        customer
      else
        nil
      end
    end

書き換えが終わったら、テストを実行してエラーや失敗が起きないことを確認します。

さて、RewardManager のような、特定の仕事を専門に処理するクラスのインスタンスをサービスオブジェクトと呼びます。ファイルはどこに置いてもいいのですが、本連載では app/services ディレクトリに配置することにします。

今回と次回の内容の基本的なアイデアは、Code Climate Blog の 7 Patterns to Refactor Fat ActiveRecord Models という記事から拝借しています。

サービスオブジェクトを使用する上でのコツは、処理をインスタンスメソッドとして実装するということです。そうすれば、インスタンス変数や属性という強力な武器が手に入ります。

プライベートなクラスメソッドとして処理を分離したときには、ローカル変数 customer をメソッドの引数として渡す必要がありました。いまはまだ1個の引数で済んでいますが、将来的にはもっと多くの引数が必要になるかもしれません。インスタンス変数や属性が利用できれば、引数の増加に悩まされずに済むのです。

テストの整理

Customer クラスから RewardManager クラスにコードが移動したので、テストも合わせて変更しておきましょう。

現在、spec/models/customer_spec.rb.authenticate メソッドのエグザンプルグループは次のような形をしています:

describe Customer, '.authenticate' do
  let(:customer) { create(:customer, username: 'taro', password: 'correct_password') }

  specify 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do
    result = Customer.authenticate(customer.username, 'correct_password')
    expect(result).to eq(customer)
  end

  specify '該当するユーザー名が存在しない場合はnilを返す' do
    result = Customer.authenticate('hanako', 'any_string')
    expect(result).to be_nil
  end

  specify 'パスワードが一致しない場合はnilを返す' do
    result = Customer.authenticate(customer.username, 'wrong_password')
    expect(result).to be_nil
  end

  specify 'パスワード未設定のユーザーを拒絶する' do
    customer.update_column(:password_digest, nil)
    result = Customer.authenticate(customer.username, '')
    expect(result).to be_nil
  end

  specify 'ログインに成功すると、ユーザーの保有ポイントが1増える' do
    expect {
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(1)
  end

  specify '日付変更時刻をまたいで2回ログインすると、ユーザーの保有ポイントが2増える' do
    Time.zone = 'Tokyo'
    date_boundary = Time.zone.local(2013, 1, 1, 5, 0, 0)
    expect {
      Timecop.freeze(date_boundary.advance(seconds: -1))
      Customer.authenticate(customer.username, 'correct_password')
      Timecop.freeze(date_boundary)
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(2)
  end

  specify '日付変更時刻をまたがずに2回ログインしても、ユーザーの保有ポイントは1しか増えない' do
    Time.zone = 'Tokyo'
    date_boundary = Time.zone.local(2013, 1, 1, 5, 0, 0)
    expect {
      Timecop.freeze(date_boundary)
      Customer.authenticate(customer.username, 'correct_password')
      Timecop.freeze(date_boundary.advance(hours: 24, seconds: -1))
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(1)
  end
end

末尾の2つのエグザンプルを RewardManager クラスのテストに移動しましょう。

まず spec/services ディレクトリを作ります。

$ mkdir spec/services

そして、新規ファイル spec/services/reward_manager_spec.rb を次のような内容で作成します。

require 'spec_helper'

describe RewardManager, '#grant_login_points' do
  let(:customer) { create(:customer) }

  specify '日付変更時刻をまたいで2回ログインすると、ユーザーの保有ポイントが2増える' do
    Time.zone = 'Tokyo'
    date_boundary = Time.zone.local(2013, 1, 1, 5, 0, 0)
    expect {
      Timecop.freeze(date_boundary.advance(seconds: -1))
      RewardManager.new(customer).grant_login_points
      Timecop.freeze(date_boundary)
      RewardManager.new(customer).grant_login_points
    }.to change { customer.points }.by(2)
  end

  specify '日付変更時刻をまたがずに2回ログインしても、ユーザーの保有ポイントは1しか増えない' do
    Time.zone = 'Tokyo'
    date_boundary = Time.zone.local(2013, 1, 1, 5, 0, 0)
    expect {
      Timecop.freeze(date_boundary)
      RewardManager.new(customer).grant_login_points
      Timecop.freeze(date_boundary.advance(hours: 24, seconds: -1))
      RewardManager.new(customer).grant_login_points
    }.to change { customer.points }.by(1)
  end
end

ポイントの付与に関する仕様がひとつにまとまり、アプリケーション全体の見通しが良くなりましたね。

次回は

次回も「サービスオブジェクト」という考え方を用いて、設計の改善を続けます。では、また。