Serviceオブジェクトとは
肥大化したActiveRecordモデルを分割し、コントローラをスリムかつ読みやすくするうえで非常に有用な、Ruby on Rails開発における一種の開発パターンです。
どんな時に利用する?
-
複数のモデルに対するコールバックなどを含み、多くの手順で構成された複雑なアクションで、適切な定義箇所が見つからない場合
-
アクションから外部サービスとやりとりする場合
メリット
-
ModelやControllerがシンプルになり、可読性が向上する
-
処理がservice層に切り出されている為、Modelのテストが書きやすい
-
Webのインタフェースからビジネスロジックを切り離すことで、責任範囲が明確になる
デメリット
-
明確なルールが存在しない
-
チームでの認識を合わせるのが難しく、運用が難しい
-
本質的にServiceオブジェクトパターンそのものには、コードベースを読みやすくする力も、メンテしやすくする能力も、concernをうまく分割する手腕もない
実装例
Serviceオブジェクト
class Staff::Authenticator
def initialize(staff_member)
@staff_member = staff_member
end
def authenticate(raw_password)
@staff_member &&
@staff_member.hashed_password &&
@staff_member.start_date <= Date.today &&
(@staff_member.end_date.nil? || @staff_member.end_date > Date.today) &&
BCrypt::Password.new(@staff_member.hashed_password) == raw_password
end
end
コントローラー
class Staff::SessionsController < Staff::StaffBaseController
.
.
.
def create
@form = Staff::LoginForm.new(login_form_params)
if @form.email.present?
staff_member = StaffMember.find_by("LOWER(email) = ?", @form.email.downcase)
end
if Staff::Authenticator.new(staff_member).authenticate(@form.password)
if staff_member.suspended?
staff_member.events.create!(type: 'rejected')
flash.now.alert = 'アカウントが停止されています。'
render :new
else
session[:staff_member_id] = staff_member.id
session[:last_access_time] = Time.current
staff_member.events.create!(type: 'logged_in')
flash.notice = 'ログインしました'
redirect_to :staff_root
end
else
flash.now.alert = 'メールアドレスまたはパスワードが正しくありません'
render action: 'new'
end
end
.
.
.
end
利用する上での注意点
1. 命名規則を1つに定める
Serviceオブジェクトの場合、UserCreatorやUserAuthenticatorなどのように「〜or」で終わる名前を付ける方法が広く採用されています。この命名規則に従おうとすると、少し無理やり命名しなければいけない場合があります。
そのため、CreateUserやAuthenticateUserのように、コマンドやアクションを先に書く命名方法
が責務が明確になりおすすめです。
どんな方法にしろ、命名規則は守りましょう。
2.Serviceオブジェクトを直接インスタンス化しない
Serviceオブジェクトをインスタンス化しても、単にcallメソッドを実行する以外に実はあまり使い道がありません。
callメソッドを実行するのであれば、次のように抽象化してみましょう。
module Service
extend ActiveSupport::Concern
class_methods do
def call(*args)
new(*args).call
end
end
end
3. Serviceオブジェクトの呼び出し方法を1つに統一する
個人的にはcallメソッドを使うのが好みですが、呼び出し方法を統一しておけば、新しいServiceオブジェクトを実装するたびに名前を考える面倒がなくなりますし、他のプログラマーは実装の詳細をチェックしなくてもServiceオブジェクトの使い方をすぐに理解できるという効用もあります。
4. Serviceオブジェクトの責務は1つとする
Serviceオブジェクトにさまざまなアクションをまとめることもできますが、アクションのセットは1種類に限定することで、コードも読みやすくなり、より自然になります。
5. callメソッドの引数はシンプルに
Serviceオブジェクトに2つ以上の引数が与えられる場合、引数をわかりやすくするためにキーワード引数の導入を検討するとよいでしょう。引数が1つの場合であっても、キーワード引数にしておくことで読みやすさが向上するでしょう。
UpdateUser.call(attributes: params[:user], send_notification: false)
6. Serviceオブジェクトが増えたら名前空間でグループ化する
コードをうまく編成するために、名前空間で共通のServiceオブジェクトをグループ化することをおすすめします。グループ化する名前空間は、「外部サービス」や「高レベルの機能」など考えられるどんな基準で決めてもかまいません。ただし、Service Objectの命名規則や配置を読みやすい素直なものにするのが名前空間の主要な目的であることをお忘れなく。規則を1つにしていれば、適切な配置は自然に定まります。不要な選択肢を増やさないようにするのがコツです。
Serviceオブジェクトはアンチパターンなのか?
Serviceオブジェクトをググってみると、「利用しない方がいい」などといった意見が意外と多かったりします。
そういった人たちの言い分は、デメリットに書いた通りです。
Serviceオブジェクトを使わないようにするには?
ServiceオブジェクトではなくconcernとPOROを使ってみましょう。
インターフェイスが改善され、concernが正しく分離され、OOP原則が健全に使われるようになり、コードを把握しやすくなります。
まとめ
自由度が高く、便利な開発パターンであると同時に、デメリットも併せ持っていることで批判的な意見もありました。
実際に採用している現場もあるので、一概に「使っちゃダメ!」とは言えませんが、チーム内で相談しながら運用していけ場いいのかなと思います。
参考
[Railsで重要なパターンpart 1: Service Object(翻訳)]
(https://techracho.bpsinc.jp/hachi8833/2017_10_16/46482)
[Rails:Service層を運用して良かったところ、悪かったところ]
(https://qiita.com/joooee0000/items/369fd4676cd9dfb1f6eb)
[Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)]
(https://techracho.bpsinc.jp/hachi8833/2018_04_16/55130)