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

2013/10/02

前回は、サービスオブジェクト(特定の仕事を専門に行うクラスのインスタンス)を作ることで、Customer モデルのクラスメソッド authenticate をスリム化する方法を説明しました。

今回は、それをさらに推し進めて Customer.authenticate メソッド自体を別のサービスオブジェクトに移します。

ReceptionDesk クラス

reception_desk

いま Customer.authenticate メソッドがどんな仕事をしているかと言えば、ユーザー認証とログインポイントの付与です。ふたつの仕事は関連しているかもしれませんが、概念的にはまったく別のものです。

これらの仕事全体を authenticate という名前で呼ぶのはあまり適当ではありません。また、Customer クラスも仕事の主体あるいは場として違和感があります。

メソッドやクラスの名前は、開発者たちの思考様式に大きな影響を与えますので、変な感じがしたら思い切って変更したり、メソッドを移動したりすべきです。

私はいろいろと考えた挙げ句、ReceptionDesk というクラス名にたどり着きました。「受付」という意味です。Web サイトを訪れた顧客を出迎える人や場所をイメージしています。そして、メソッド名は sign_in とします。

sessions#create アクションの変更

Outside-in の原則に従って外側から変更していきましょう。app/controllers/sessions_controller.rb を次のように修正してください:

class SessionsController < ApplicationController
  def create
    if customer = ReceptionDesk.new(params[:username], params[:password]).sign_in
      session[:customer_id] = customer.id
    else
      flash.alert = 'ユーザー名またはパスワードが正しくありません。'
    end
    redirect_to :root
  end
end

Customerauthenticate はクラスメソッドでしたが、ReceptionDesksign_in はインスタンスメソッドです。前回も書きましたが、クラスメソッドよりもインスタンスメソッドの方が道具が揃っていて、書きやすいからです。将来的に変更するのにも有利です。

ReceptionDesk#sign_in メソッドのテスト

ReceptionDesk#sign_in メソッドの呼び出し方が決まりましたので、次にテストを書きます。

といっても、ゼロから書くわけではありません。Customer.authenticate メソッドのテストをコピーして、書き換えます。

新規ファイル spec/services/reception_desk_spec.rb を作成し、spec/models/customer_spec.rb.authenticate メソッドに関するエグザンプルグループをコピーして貼り付け、以下のように修正してください:

require 'spec_helper'

describe ReceptionDesk, '#sign_in' do
  let(:customer) { create(:customer, username: 'taro', password: 'correct_password') }

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

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

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

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

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

Customer.authenticateReception.new で置換して、その行の末尾に .sign_in を付け加えれば修正完了です。

ReceptionDesk#sign_in メソッドの実装

続いて、ReceptionDesk#sign_in メソッドを実装します。新規ファイル app/services/receptio_desk.rb を次のように作成します:

class ReceptionDesk
  attr_accessor :username, :password

  def initialize(username, password)
    self.username = username
    self.password = password
  end

  def sign_in
    customer = 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
end

sign_in メソッドの中身は、Customer.authenticate のそれとほぼ同じです。

テストを実行し、正しくメソッドの中身を移設できていることを確かめてください。

Customer.authenticate の削除

もはや Customer.authenticate は不要となりましたので、削除しておきましょう。修正後の app/models/customer.rb は、次のようになります:

require 'nkf'
require 'bcrypt'

class Customer < ActiveRecord::Base
  has_many :rewards

  attr_accessor :password

  validates :family_name, :given_name, :family_name_kana, :given_name_kana,
    presence: true, length: { maximum: 40 }
  validates :family_name, :given_name,
    format: { with: /\A[\p{Han}\p{Hiragana}\p{Katakana}]+\z/, allow_blank: true }
  validates :family_name_kana, :given_name_kana,
    format: { with: /\A\p{Katakana}+\z/, allow_blank: true }

  before_validation do
    self.family_name = NKF.nkf('-w', family_name) if family_name
    self.given_name = NKF.nkf('-w', given_name) if given_name
    self.family_name_kana = NKF.nkf('-wh2', family_name_kana) if family_name_kana
    self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
  end

  before_save do
    self.password_digest = BCrypt::Password.create(password) if password.present?
  end

  def points
    rewards.sum(:points)
  end
end

そして、このメソッドに対応するエグザンプルグループも削除します。

残る課題

前回と今回で、Customer.authenticate クラスメソッドの処理を分割して、RewardManagerReceptionDesk という2つのサービスオブジェクトに移しました。それぞれのクラスの役割がより明確になったのではないかと思います。

しかし、まだ課題が残っています。spec/services/reception_desk_spec.rb にある次のエグザンプルを見てください:

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

このエグザンプルは、純粋に ReceptionDesk クラスの仕様を記述していませんね。ログイン成功時にユーザーにポイントを付与するのは RewardManager クラスの仕事です。しかし、このエグザンプルを消してしまえばいい、というわけでもありません。

ReceptionDesk#sign_in の3行目にある RewardManager.new(customer).grant_login_points という式を誤って削除した時のことを考えてみましょう。もし上記のエグザンプルが存在しないと、テストによってこの削除を検知できません。

とは言っても、このまま残しておくのもダメです。ポイントシステムの第3の仕様を思い出してください。土曜日にログインすると全部で3ポイント付与されることになっています。この仕様を実装した後では、金曜日にテストすれば成功し、土曜日にテストすれば失敗することになります。この課題に対処するには、まだ本連載で説明していないテクニックを用いる必要があります。

次回は

次回は、RSpec に比較的最近になって(2013年7月6日リリースのバージョン2.14.0で)加えられた非常に興味深い機能「スパイ」について書く予定です。では、また。