Railsで重要なパターンpart 1: Service Object(翻訳)

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: Essential RubyOnRails patterns — part 1: Service Objects 公開日: 2017/06/13 著者: Błażej Kosmowski サイト: selleo.com パターンの種別は原則として英語表記にしました。 Railsで重要なパターンpart 1: Service Object(翻訳) Service Object(単にServiceとも呼ばれます)は、肥大化したActiveRecordモデルを分割(訳注: TechRacho翻訳記事にリンクしました↓)し、コントローラをスリムかつ読みやすくするうえで非常に有用な、Ruby on Rails開発における一種の聖杯とも呼ぶべきパターンです。 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳) どんなときにService Objectを使うか このパターンは非常にシンプルかつ強力なので、つい使いすぎてしまうほどです。特に、多数のモデルに対するコールバックやアクセスを含み、多くの手順で構成される複雑なアクションを定義する場所が必要で、かつ他に適切な置き場所がない場合に有用です。Service Objectは、モデルで外部クラスとやりとりするコールバックによって生じる問題(関連記事)を軽減するのにも用いられます。 Service Objectパターンを最大限に利用するための注意点 1. 命名規則を1つに定める プログラミングで難易度が高いのは、適切かつ意味のわかる名前を割り当てることです。Service Objectの場合、UserCreatorやTwitterAuthenticatorやCodeObfuscatorなどのように「〜or」で終わる名前を付ける方法が広く採用されています。この命名規則に従おうとすると、OrderCompleterのように英語的に少し無理が生じることがあります。 そして私は、CreateUserやAuthenticateUsingTwitterやObfuscateCodeやCompleteOrderのように、コマンドやアクションを先に書く命名方法ならややわかりやすくなることに気づきました。この方がService Objectの責務が明確になります。 どの方法を選ぶにせよ、一度命名規則を決めたらそこから外れないようにすることが重要です。 2. Service Objectを直接インスタンス化しないこと Service Objectをインスタンス化しても、単にcallメソッドを実行する以外にあまり使いみちがありません。callメソッドを実行するのであれば、次のように抽象化してService Objectへの呼び出しをより簡潔にすることを検討しましょう。 module Service extend ActiveSupport::Concern class_methods do def call(*args) new(*args).call end end end このモジュールをincludeすることで、UserCreator.new(params).callやUserCreator.new.call(params)をUserCreator.call(params)のように簡潔に記述でき、読みやすさも向上します。このService Objectはインスタンス化も可能なので、内部のステートを取り出す必要が生じた場合にも有用です。 3. Service Objectの呼び出し方法を1つに定める 個人的にはcallメソッドを使うのが好みですが、他の方法を使わない特別な理由があるわけではなく、performやrunやexecuteも候補として優れています。重要なのは呼び出し方法を常に統一することです。というのも、クラスの責務は既にクラス名に明示されており、これ以上明確にする必要はないからです。 呼び出し方法を統一しておけば、新しいService Objectを実装するたびに名前を考える面倒がなくなりますし、他のプログラマーは実装の詳細をチェックしなくてもService Objectの使い方をすぐに理解できるという効用もあります。 4. Service Objectの責務を1つに絞り込む これはService Objectの呼び出し方法を1つに統一しておけばある程度実現できますが、それだけではService Objectに複数の責務が混入する可能性が残ります。Service Objectにさまざまなアクションをまとめることもできますが、アクションのセットは1種類に限定するべきです。 Service Objectでありがちなアンチパターンとしては、たとえばManageUserというserviceがユーザーの作成と削除の両方を引き受けてしまうというものがあります。そもそもmanageという言葉だけでは責務があいまいになってしまい、オブジェクトがどんなアクションを実行すべきかを制御する方法も明確になりません。代わりにDeleteUserとCreateUserという2つのserviceに分けて導入すれば、コードも読みやすくなり、より自然になります。 5. Service Objectのコンストラクタを複雑にしない 一般に、実装するほとんどのクラスではコンストラクタをシンプルにしておくのがよい考えです。Serviceの主な呼び出し方法はクラスメソッドを呼び出すことですが、コンストラクタの責務を「引数をserviceのインスタンス変数に保存すること」に限定する方がずっとメリットが多くなります。 class DeleteUser def initialize(user_id:) @user = User.find(user_id) end def call #… end end 上と下を見比べてみましょう。 class DeleteUser def initialize(user_id:) @user_id = user_id end def call #… end private attr_reader :user_id def user @user ||= User.find(user_id) end end 下のようにすれば、テストのときにコンストラクタではなくcallメソッドに集中できるようになりますし、コンストラクタに何を置くことができ、何を置けないかという線引きも明確になります。開発者が日々決定しなければならないことはたくさんあるので、こういう点を標準化して決めごとをひとつ減らしましょう。 6. callメソッドの引数をシンプルにする Service Objectに2つ以上の引数が与えられる場合、引数をわかりやすくするためにキーワード引数の導入を検討するとよいでしょう。Service Objectの引数が1つの場合であっても、キーワード引数にしておくことで読みやすさが向上するでしょう。 UpdateUser.call(params[:user], false) 上と下を見比べてみましょう。 UpdateUser.call(attributes: params[:user], send_notification: false) 7. 結果はステートリーダー経由で返す Service Objectから何らかの情報を取り出さなければならないような状況はめったにありませんが、その必要が生じた場合に取りうるアプローチはいくつか考えられます。 Service Objectはcallメソッドの結果をたとえば次のように返すことができます。実行が成功した場合はtrue、失敗した場合はfalseを返す、という具合です。 しかし、callメソッドがService Object自身を返すようにすればより柔軟になります。この方法にすると、Service Objectインスタンスのステートを読み出せるようになります。 update_user = UpdateUser.call(attributes: params[:user]) unless update_user.success? puts update_user.errors.inspect end この方法は、例外のraiseなど、めったに起きない事象を扱うような場合にも効果的にエッジケースとやりとりできます。 begin UpdateUser.call(attributes: params[:user]) rescue UpdateUser::UserDoesNotExistException puts “そのユーザーは存在しません” end 8. callメソッドの可読性を下げないようにする callメソッドはService Objectの中心となるメソッドです。Service Objectでは、callメソッドをできるだけ読みやすく保つことをおすすめします。callメソッドには関連する手順だけを記述し、それ以外のロジックは最小限に抑えるようにします。andやorなどを使って、特定の手順のフローを制御してもよいでしょう。 class DeleteUser #… def call delete_user_comments delete_user and send_user_deletion_notification end private #… end 9. callメソッドをトランザクションでラップすることを検討する Service Objectが自らの責務を果たすために複数の手順が必要になった場合、手順をトランザクションでラップするとよいでしょう。こうしておけば、その手順に失敗した場合にも常にロールバックできます。 10. Service … Continue reading Railsで重要なパターンpart 1: Service Object(翻訳)