Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)
近年、RailsアプリにService Objectを追加するメリットを説く記事が次から次へと量産されています。私は本記事において、それがなぜ正しくないかを述べたいと思う次第であります。もっとよい方法はあるのです。
私はこれまで、Service Objectに関するネット上の議論にときおり参加しては、問題に対するまっとうな解決方法としてService Objectが正しくない理由について繰り返し見解を述べてきました。実際、私は多くの場合においてService Objectよりもっとよい解決方法があると考えるのみならず、Service Objectはオブジェクト指向設計原則への配慮が損なわれている兆候を示すアンチパターンとして取り扱っています。
あんたのRailsコードベースが今より良くなることはねえと言ったらどうするよ?
このような深遠なポイントを細切れのツイートやコメント欄を追って理解するのは大変です。そこで私は、私の見解を正確に表すいくつかの現実的なコードを詳しく追って記事にすることにしました。
私が使う「アンチパターン」という用語については、StackOverflowにうまい説明があったので引用します。
「アンチパターン」とは、ソフトウェア開発においてプログラミング上の悪手と考えられる特定のパターンのことです。デザインパターンが、よい開発手法と一般的に考えられている定型化された共通のアプローチであるのと対照的に、アンチパターンは望ましくないものです。
https://stackoverflow.com/a/980616より
私がService Objectを好きになれない理由を説明するために、昔ある顧客のプロジェクトで当時の開発チームが書いたコードを私が継承した中から、いくつかを詳しく見ていくことにします。そのアプリは未だにpublic betaなので当時の状況についてはあまり立ち入ったことは書けませんが、メディア(画像や動画)にレーティングするソーシャルプラットフォームであり、レーティングは特定のコールバックスタイルの操作(データ処理のアルゴリズム更新や、さまざまなユーザーのタイムラインへのアクティビティの追加など)でトリガされる、とだけ言っておきましょう。
比較的シンプルなデータモデルが1つあり、User
オブジェクトとMedia
オブジェクトにbelongs_to
で属しているRating
オブジェクトをそこに作成できます。以下のコード例はいずれもproductionファイルそのままではなく、抜粋です。
class Rating < ActiveRecord::Base
belongs_to :user
belongs_to :media
end
class Media < ActiveRecord::Base
has_many :ratings
end
以下を見ればおわかりのように、前任の開発者は、ユーザーから届くレーティングを扱うのにMediaRating
というService Objectをこしらえたのです。これはコントローラから呼び出されます。
class MediaRating
def self.rate(user, media, rating)
mr = MediaRating.new(user)
rating_record = mr.update_rating(media, rating)
end
def initialize(user)
@user = user
end
def update_rating(media, rating)
rating_record = @user.ratings.where(media: media).first
if rating_record.nil?
# 何か作成する
else
# 何か更新する
end
# データのアルゴリズム処理の実行やら
# タイムラインでのソーシャル活動の追加やらを行う
end
end
対応するコントローラのコードは次のとおりです。
media = Media.find(params[:media_id])
rating = params[:rating].to_i
MediaRating.rate(current_user, media, rating)
このコードが最初に書かれたのはかなり前であることを念頭に置いてください。最近のService Object愛好者たちなら、提示するAPIをもう少し形式的に書くので、このService Objectを私が書き直してもよいのであれば、きっとこう直すことでしょう。
# これはGemfileに追加
gem 'smart_init'
class UserMediaRater
extend SmartInit
initialize_with :user, :media, :rating
is_callable
def call
rating_record = @user.ratings.where(media: @media).first
# etc.
end
end
# コントローラからの更新済みコマンド
UserMediaRater.call(current_user, media, rating)
問題が始まるとき
私のコードは満更でもありませんよね?そこそこクリーンですし、よく構造化され、楽にテストできます。私がService Objectを書き直したコードを今皆さんが見ているわけですが、問題はこの洗練されたコードが実際より相当簡略化されたものだという点です。コードベース内の実際のコードは74行ものスパゲッティコードになっていて、メソッドが別のメソッドを呼び、それがまた別のメソッドを呼んでいるという具合です。というのも、データのアルゴリズム処理だのタイムライン更新だのが全部1個のService Objectに押し込められていたからです。実際のフローは以下のような具合でした。
コントローラ > Service Object > Rateメソッド > Ratingの更新 > 何か別の更新メソッド + (アルゴリズムの実行 > 関連データの更新)、そしてキャッシュの無効化 + アクティビティをタイムラインに追加
そんなわけで、私がコードベースをエディタで新たに開いて、ユーザーがレーティングしたメディアオブジェクトの作成や更新を行っているコードのブロックをちょっと見ようとするたびに、本質的でない大量の付加機能をかき分けて基本のコードパスにたどり着かなければなりませんでした。
「ちょっとちょっとぉ、さすがにその開発者のService Objectの書き方はいくらなんでもマズいでしょ!他のオブジェクトに(おそらく他のService Objectであっても)そうやって処理を押し込めるんじゃなくて、もっとシンプルかつ対象を絞り込んで書けばよかったのに」とおっしゃる方もいるでしょう。
まあまあ、もうしばらくお付き合いください。標準的なRails MVCパターンに含まれるコードを抽出してService Objectに置く必要があると言われていた本来の理由は、要するに複雑なコードフローを単独の関数に分割しやすくなるからだということでした。しかしここで問題なのは、ルールを強制するものが何もないということです。これっぽっちもないんです!シンプルなService Objectを書くことは間違いなく誰でもできます。しかし、メソッドをたらふく抱え込んであっという間にスパゲッティコード化してしまう複雑なService Objectも、同じぐらい間違いなく誰にでも書けるのです。
私が何が言いたいかおわかりでしょうか?つまり本質的にService Objectパターンそのものには、コードベースを読みやすくする力も、メンテしやすくする能力も、関心を適切に分離する手腕もありはしないということです。
あるパターンが、シンプルなものから複雑なものまでほぼ無限に、ほぼあらゆる種類のプログラミングスタイルを無節操に許容して促進するのであれば、そんなものは開発者にとって最早有用なパターンでも何でもありません。
ではどうすればいいんでしょうかね?
私がそこそこの量のコードを書き始めるときは(データを受け取って、他の機能に配慮しながら作成なりレコード更新なりを行わなければならないぐらいの規模感であれば)、大抵の場合最も適切なモデル上に(訳注: わざと)クラスメソッドを1つ書くところから始めます。まま、そう慌てないで。これがService Objectの上を行く優秀なパターンと言いたいのではありません。「ここにうまくはまるパターンがないかなぁ」と私があれこれ考えるよりも前にこれを行っている点にご注目いただきたいのです。
もし仮に、Rating
自身のクラスメソッドを用いてメディアのレーティングを行っていたとしたら果たしてどうなっていたか、見てみましょう。
class Rating < ActiveRecord::Base
belongs_to :user
belongs_to :media
def self.rate(user, media, rating)
rating_record = Rating.find_or_initialize_by(user: user, media: media)
rating_record.rating = rating
rating_record.save
# データのアルゴリズム処理の実行やら
# タイムラインでのソーシャル活動の追加やらを行う
end
end
コントローラのコードはこんな感じに書き直します。
media = Media.find(params[:media_id])
rating = params[:rating].to_i
Rating.rate(current_user, media, rating)
私はこのコードを目にして、やっと安堵の息をつくことができました。というのも、レーティングのコードが直接Rate
モデルに置かれていることで、この機能がコードの影響を最も強く受けるデータ構造に近い場所に配置されているからです。このコードベースをエディタで開いてレートの処理を見たければ、Rate
モデルを見ればよいのです。実に素直なコードです。
とはいうものの、このコードにはまだ1か所、非常にうれしくない点があります。要は、私は(クラスメソッドではなく)インスタンスメソッドを呼ぶことと、可能な限りRailsの関連付けを使うのが好きなのです。今のコードは私にとって、あたり一面クラスメソッドだらけになる「コードの匂い」と、本来意図されている関連付けや標準的なOOP原則を回避したがる「コードの匂い」がプンプン漂ってきます。この場合、コントローラで@media.rate
などと書けないなんて残念です。要するに私はメディアのオブジェクトを立ち上げてそれをレーティングしたいのです。こんな明確なインターフェイスをなぜ使えないんだと思うわけです。
真の友はconcernとPORO
次はモデルのクラスメソッドから複雑なコードを切り出す必要があると確信できたら、ガラクタをモデルのインスタンスメソッドに押し込めるようなパターンなんかではない、もっとよいパターンを見つけたいと思います。いわゆる「ファットモデル」に伴う問題とは、突き詰めれば、分割したコードの置き場所としてみんなが真っ先にService Objectを推奨するのは一体なぜか、ということです。
しかし、現実にはファットモデルのデメリットは、1個のオブジェクトに大量のメソッドを書くことのデメリットに比べればたかが知れています。後者は、そうした大量のメソッドが(そしておそらく関連する単体テスト)1つのファイルでひしめき合うことです。しかし、現実には単一のオブジェクトが多くのメソッドを持つ「ファットモデル」のデメリットはさほどではなく、そうした多くのメソッドは(そしておそらく関連する単体テストも)すべて1つのファイルにまとまります。本当に必要なのは、ある重要な機能のコード片が、別の重要な機能のコード片に干渉しないようにしてコードを理解しやすくするための手法であり、そしてどのコード片を別のオブジェクトに再配置してまとめるかという何らかの経験則なのです。
さて、このメディアレーティング業務で何ができそうかちょっと見てみましょう。私なら最初に、立ち向かうべきコードのひと塊をconcernに切り出すでしょう(concernとは、標準的なRubyのmixinをRailsで少々拡張しただけのものです)。このconcernにRatable
という名前を付けましょう。
module Ratable
extend ActiveSupport::Concern
included do
has_many :ratings
end
def create_or_update_user_rating(user:, rating:)
rating_record = ratings.find_or_initialize_by(user: user)
rating_record.rating = rating
rating_record.save
# データのアルゴリズム処理の実行やら
# タイムラインでのソーシャル活動の追加やらを行う
rating_record
end
end
このMedia
クラスにも嬉しい点があります。has_many :ratings
ディレクティブを取り出してこの新しいconcernに置いておけるからです。
class Media < ActiveRecord::Base
include Ratable
end
コントローラのコードはこんな感じに書き直します。
rating = params[:rating].to_i
Media.find(params[:media_id]).create_or_update_user_rating(
user: current_user,
rating: rating
)
既にかなり改善された感がありますね。後はコントローラでメディアのオブジェクトを検索して、わかりやすい名前のインスタンスメソッドを1つ呼び出せばおしまいです。可能な限り最善の手法によってRailsらしさにあふれた、親しみやすいインターフェイスです。
しかしまだ問題が1つ残っています。
このcreate_or_update_user_rating
メソッドが頑張りすぎています。ここではデータベースアクセスを扱うのが自然ですが、データのアルゴリズム処理だのタイムラインの更新だのは、結果が出てからトリガされるべきですし、別の場所で定義されるべきアクションのように思えます。
標準のRails wayなら、このコードをActiveRecordのコールバックに置くところでしょう。コールバックに置くのは別に問題ありませんし、うまくはまりそうなら私は喜んで使います。しかしこの場合は、主に行うべき2つの機能が、それと隣合わせになっている特定のメディア、レーティング、ユーザーオブジェクトと関連しているだけで、それ以外に何の関連もないように思えます。
そこで、この機会にドメインモデルを少々適用し、余分な機能をconcernからさらに切り出して別のPORO(pure old Ruby object)に切り出してみましょう。create_or_update_user_rating
メソッドは、これらの新しいオブジェクトを指すようにして小ざっぱりかつシンプルにします。
def create_or_update_user_rating(user:, rating:)
rating_record = ratings.find_or_initialize_by(user: user)
rating_record.rating = rating
rating_record.save
# 追加機能をPOROまたは関連するモデルに切り出しましょう
# バックグラウンドジョブにカプセル化すれば
# さらに改善できますね
# 読者の練習用に取っておきます
Rating::Processor.run(rating_record)
Timeline::Activities.add_for_rating(rating_record)
rating_record
end
もはや考えるまでもなく、Rating::Processor
もTimeline:: Activities
もService Objectではありません。これらはPOROであり、OOPパラダイムに注意深く配慮しながらモデリングされています。オブジェクトの1つは私が「Processorパターン」と呼んでいるもので、入力を取って処理し、どこかに出力を保存します。もう1つは「Collectionパターン」で、項目の追加や削除と、それらのアクションの結果を管理します。ここでは特別なことや独自の技は何ひとつ使われていません。しかしそこが重要なのです。
ここで代わりにService Objectパターンの利用を試みることもやろうとおもえばやれました。その場合おそらく、UserMediaRater
をリファクタリングしてProcessNewRating
やAddTimelineActivityForRating
などのService Objectを呼び出す形になるでしょう。しかし、concernとPOROの読みやすさと良い構造化にどれだけ太刀打ちできるでしょうか?本質的には関数であるものがぎっしり詰まったapp/services
フォルダを見て心が折れるくらいなら、クラス名とデータ構造と、そして読みやすく使いやすい設計のオブジェクトメソッドを備えた本物のドメインモデリングを手にすればよいのです。
最後に申し上げたいポイントは「Service ObjectではなくconcernとPOROを使うことで、インターフェイスが改善され、関心(concern)が正しく分離され、OOP原則が健全に使われるようになり、コードを把握しやすくなる」です。
テストの戦略について語る余地がなくなってしまいましたが、concernやより高度なPOROを使うと、テストでService Objectとはまた別の問題が持ち上がるのではないかとご心配な方のために、有用なリソースをいくつかご紹介します。
- On Writing Software Well---Video by David Heinemeier Hansson(YouTube動画)
- Speed up Rails tests 10x by using PORO domain models
Railsのconcernをモデルレベルやコントローラレベルで有用なPOROパターンと組み合わせることが、こんなときに使われがちなService Objectと比べていかに優れているかについてはお話したいことがいくらでもありますので、今後の記事をぜひお楽しみに。
忙しい方向けのまとめ: Service Objectはアカンやつであり、ほとんどの場合もっとよいソリューションがあります。どうかそちらをお使いください。お読みいただきありがとうございました!
(罵倒でない、十分吟味した)ご意見・ご感想は@jaredcwhiteまでどうぞ 😊
お知らせ
Intersectは、Whitefusion社が提供するJekyllベースのオープンなWebブログです。弊社についての詳細や弊社の目指すものについては会社概要をご覧ください。
付録: 社内Slackより
ServiceObjectに複雑なロジックを委譲(切り取り&ペースト)だけしてもアカン。PORO設計などのモデリングをしないとキレイにならないよと言っていそう。
私がよくやるのはこれかな: 「標準のRails wayなら、このコードをActiveRecordのコールバックに置くところでしょう。コールバックに置くのは別に問題ありませんし、うまくはまりそうなら私は喜んで使います。」
よくオブジェクトAのafter_save
でオブジェクトBのデータを更新してオブジェクトBのafter_save
にオブジェクトAのデータ更新が書いてあって、無限ループとかあるので、このあたりがうまくはまる/はまらないってところなのかなあ。
Service Objectをcomplex facadeとして使う場合、facadeの先がある程度パッケージとして固まっていてくれるとここまでごちゃらない気がしますが、Rubyだと苦しいかなあ。
Javaで言うprotectedみたいなのでうまくServiceの先がカプセル化されていて、不用意に直接呼び出しされないようになっていればいいんですが、Rubyだとオープンクラスだし、強制しづらそう。
service objectだけがその先のオブジェクトを触れる、みたいな強制の仕方をしようとすると、マイクロサービスみたいな話になるのかなあ。
ツイートより
ファットモデルの本当の問題はコードの行数やメソッドの個数 *ではなく* 適切な抽象化が妨げられることによって管理不能な複雑さが生まれてしまうことやで… : Service Objectがアンチパターンである理由とよりよい代替手段 https://t.co/UiAbtnX6bJ
— 腰弱 (@jwhaco) April 16, 2018
Service ObjectはアンチパターンだからconcernでModelのロジックを切りだそうって話、僕はfat modelじゃね、と思うのですがどうなんでしょうか?個人的にはconcernでファイル構造をバラしただけのものよりは、DIとかcompositeを使ってちゃんと機能分割するのが好みです https://t.co/YxGoNG3vYL
— Masato Mori (@morimorihoge) April 16, 2018
あと「Service Object」ってRails周りではGoFにおけるCommandパターンの実装を多く見ますが、Facadeの実装例もありますよね(僕はこっちの方がしっくりくる派)。サービスファサードとかセッションファサードとか言われているものがそれだと思うのですが、その辺もたまにかみ合ってない議論を見る
— Masato Mori (@morimorihoge) April 16, 2018
わかりみはあるんだけど個人的にconcernは嫌いだからそこだけは同意できない
Service Objectがアンチパターンである理由とよりよい代替手段(翻訳) https://t.co/6AeSz4vCUF
— わかば (@wakaba260yen) April 16, 2018
Service Objectは責務分割ではなく機能分割を助長するので同意。ここで紹介されているconcernも割とそうなりやすいので、最近はロールをPOROに抽出して限定的なスコープでDCIするのがよいと思っている。 https://t.co/42xqcyytBx
— Hideki Igarashi (@ganta0087) April 17, 2018
代替手段がconcernでずっこけた。視野と想定されるケースが狭すぎないか / Service Objectがアンチパターンである理由とよりよい代替手段(翻訳) https://t.co/g1zIY9xQYp
— sheepland (@sheepland) April 17, 2018
Service Objectが多発する原因はmodels配下のドメインモデル貧血症。
ConcernsとPOROが解決策だとか言われても、やれfat modelだPOROの方がカオスだ言われ
disの無限ループに陥るだけだと思います...!!Service Objectがアンチパターンである理由とよりよい代替手段(翻訳) https://t.co/8xhQoNgYF9
— シンセ👨💻Webマーケ(toC)勉強中 (@_sizer) April 17, 2018
service object だろうが concern だろうがドメインに目を向けずシンタックス上のツールをひねり回している限り本当の問題解決には至らないと思うhttps://t.co/BeKphXdTZ0
— tomokazu imamura (@pacuum) April 17, 2018
概要
原著者の許諾を得て翻訳・公開いたします。
画像は元記事からの引用です。