今年の8月頃に自社プロダクトにサービスクラスを導入したのでその振り返りを書く。
書いてあること
- サービスクラスの利点と欠点の整理
- 採用したルールと実装例
- 導入後4ヶ月経過しての振り返り
サービスクラスの利点
サービスクラスを導入することで以下の効果が期待できる。
- Webのインタフェースからビジネスロジックを切り離すことで、責任範囲が明確になる
- 副次的な効果としてコントローラーのコードの重複を防げる
- 複数のモデルへの操作や副作用の適切な置き場所を確保し、あらゆる処理がモデルのコールバックに押し込められることを防ぐ
弊社の事情と絡めてもうちょっと詳しく説明する。
弊社ではブラウザとネイティブアプリに対して同等の機能を提供しており、WebとアプリAPIでエンドポイントが分かれている。よって単純にコントローラに処理を書くとコードが重複してしまう。
この重複を軽減するため、モデルにコードを寄せることを意識していたが、それが行き過ぎて本来コールバックに書くべきではない他のモデルの操作や副作用までコールバックで記述されていた(いわゆるコールバック地獄)。
サービスクラスを導入することで複数のモデルへの操作や副作用の置き場所を確保し、コールバック地獄とコントローラーのコードの重複が防げるようになる。
※ ちなみにコールバック地獄については以下の記事が分かりやすい(英語だけど)。
The Problem with Rails Callbacks
サービスクラスの欠点と対策
サービスクラスについては否定的な意見も多く、欠点が指摘されている。
指摘されている欠点に対し何らかの対策を講じることで、可能な限りデメリットを抑え、メリットだけを享受したい。
サービスクラスに対する否定的な意見の中で、特に私が参考にしたのは以下の2つの記事だ。
俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ - Qiita
Service Objectがアンチパターンである理由とよりよい代替手段
(前者の記事はタイトルほどにはサービスクラスを否定してないし、使う前にちゃんと勉強しろよ、という話なので否定的な意見として取り上げるべきではないかも。でもまあ当時バズってたので触れておく。)
詳細はリンク先を読んでもらうとして、ここでは重要な指摘を抜粋する。
明確に責任の境界線を引かないと、ただモデルにあるべきコードが散らばって見辛いだけのコードになる。
しかしここで問題なのは、ルールを強制するものが何もないということです。これっぽっちもないんです!
つまり、サービスクラスの欠点を端的に言うと「ルールが無い」ことである。
ルールが無い、自由度が高すぎる故に何でもサービスクラスに書けてしまう。それにより本来モデルにあるべきコードまでサービスクラスに漏れ出し、結果的に「モデルにあるべきコードがサービスに散逸しただけの読みにくいコード」になるというわけだ。
ということで「明確なルールを作り、それをチームに定着させる」ことを対策としたい。
採用したルールと実装例
どのようなルールが必要かを深掘りすると以下のようになる。
- サービスクラスを使うべき状況を明確にする
- なんでもかんでもサービスクラスにさせないことが重要
- サービスクラスの命名規則やインタフェースを統一する
- 自由度が高いほどデメリットが目立つようになる
※ ここから説明するルールは以下の記事で示されたものを下敷きにしている。サービスクラス(Service Object)についてより深く知りたい場合はこちらも一読して欲しい。
Railsで重要なパターンpart 1: Service Object(翻訳)
サービスクラスを使うべき状況
- 複数のモデルに対する操作や副作用を含み、多くの手順で構成される複雑なアクションを定義する場所が必要で、かつ他に適切な置き場所がない場合
サービスクラスの命名規則とインタフェース
例 | ||
---|---|---|
命名規則 | 〇〇er, 〇〇or | ArticleDestroyer, CommentCreator |
責務 | 1つのビジネスロジックを責務にもつ | 記事を削除する, コメントを作成する |
呼び出し方 | クラスメソッドのcallを呼ぶ | CommentCreator.call(params) |
配置場所 |
app/models 以下 |
|
その他 | 以下のServiceモジュールをincludeすること |
module Service
extend ActiveSupport::Concern
class_methods do
def call(*args)
service = new(*args)
service.call
service
end
end
end
callの戻り値はサービスクラスのインスタンスを返すことを強制している。
サービスの実行結果をコントローラー側で受け取りたい場合はサービスインスタンスのアクセサ経由で取得する。
実装例
実際のコードではないが、5chライクなスレッドベースの掲示板で以下のような要件があるとする。
- スレッドにコメントを投稿すると、スレッドが持つコメント数が加算され、スレッドの閲覧者(Watcher)に通知が飛ぶ
サービス
class CommentCreator
include Service
attr_accessor :comment, :successful
def initialize(comment_attributes)
self.comment = Comment.new(comment_attributes)
end
def call
if self.successful = comment.save
comment.increment_thread_count
comment.notify_to_thread_watchers
end
end
end
コントローラー
class CommentsController < ApplicationController
def create
comment_creator = CommentCreator.call(comment_params)
@comment = comment_creator.comment
if comment_creator.successful
redirect_to thread_url
else
render :new
end
end
end
どうだろうか?
コールバックに書きがちな副作用をサービスに置き、うまく責務分割できているように見える。
その他の工夫
ルールと考え方がチームに定着していることが重要なので、以下を実施した。
- ドキュメントの共有
- 講習会
- リファレンス実装の提供
振り返り
導入後4ヶ月経ってリファレンス実装を除いて8つのサービスクラスが作られた。
それぞれ振り返って見ての内訳は以下の通り。
A. 当初のターゲット通り、他モデルへの操作や副作用をコールバックから除去したもの ... 1つ
B. 権限チェック、それも複数のモデルを参照してアクションの実行可否を判断するもの… 4つ
C. 超肥大化したアクションメソッドをただただサービスに移動したもの ... 1つ
D. サービスクラスではなくモデルに定義すべきだったもの ... 2つ
Aについては当初の目論見通りなのでもちろんGoodなのだが、コールバックに定義すべき処理(モデルの内部に対する処理)までサービスに記述されていた。半分Bad。
Bについては導入時に想定していたものではなかったが、現状サービスで実装するのが最も良さげなのでGoodとする。バリデーションでチェックされるよりはずっと良い。ただし権限チェックをより分かりやすく表現する設計概念やらgemを導入するという方向性はありそう。
Cについてはうーん、、、まあ改善するための段階としてサービスに移動するのは悪くはないので判断保留。
DについてはもちろんBad。
ルールの整備や啓蒙活動に力を入れたつもりだったが、それでもモデルの責務がサービスに漏れ出すという事象は発生した。というかモデルの責務をサービスに漏れ出さない事が重要、ということがルール策定時点で言語化できてなかった。反省。
とはいえコールバック地獄やコードの重複から救われる筋道をつけられたので、メリットがデメリットを上回っていると考えている。
その他の所感
- 数が増えてきたので配置場所を
app/models
からapp/services
に移しても良いかも。 - 啓蒙活動+人間のレビューではどうしてもすり抜けてしまうので、独自のreekルールなどで駄目なサービスクラスを自動検出できるようになるとクールだと思った。
- この振り返りを実施して、サービスクラスについて私自身理解が深まり、さらに現状の課題も認識できた。導入しっぱなしにせずに振り返りを実施してPDSを回すことが重要。