ポイントシステム(5) -- 続・サービスオブジェクト
2013/10/02
前回は、サービスオブジェクト(特定の仕事を専門に行うクラスのインスタンス)を作ることで、Customer
モデルのクラスメソッド authenticate
をスリム化する方法を説明しました。
今回は、それをさらに推し進めて Customer.authenticate
メソッド自体を別のサービスオブジェクトに移します。
ReceptionDesk
クラス
いま 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
Customer
の authenticate
はクラスメソッドでしたが、ReceptionDesk
の sign_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.authenticate
を Reception.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
クラスメソッドの処理を分割して、RewardManager
と ReceptionDesk
という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で)加えられた非常に興味深い機能「スパイ」について書く予定です。では、また。