ポイントシステム(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
ポイントの付与に関する仕様がひとつにまとまり、アプリケーション全体の見通しが良くなりましたね。
次回は
次回も「サービスオブジェクト」という考え方を用いて、設計の改善を続けます。では、また。