Ruby on Rails には、アプリケーションのプロトタイプをすばやく作成するために必要なものがすべて同梱されていますが、コードベースが大きくなり始めると、従来の Fat Model, Skinny Controller というマントラが破綻するシナリオに出くわします。 ビジネスロジックがモデルにもコントローラにも収まらないとき、サービスオブジェクトが登場し、すべてのビジネスアクションをそれ自身のRubyオブジェクトに分離することができます。
この記事では、サービス オブジェクトが必要な場合、クリーンなサービス オブジェクトを書き、それらをグループ化して投稿者を正気に戻す方法、サービス オブジェクトをビジネス ロジックに直接結びつけるために私が課す厳しい規則、サービス オブジェクトを何をすべきかわからないコードすべての捨て場所にしない方法、について説明します。
- Why Do I Need Service Objects?
- サービスオブジェクトとは何ですか?
- Creating a Service Object
- Adding Syntactic Sugar to Make Rails Service Objects Suck Less
- Grouping similar service objects for sanity
- データベース操作を扱うサービスオブジェクト
- What Do I Return from My Service Object?
- Return true or false
- Return a Value
- Respond with an Enum
- When Should I Not Use a Service Object?
- Rules for Writing Good Service Objects
- Rule 2: サービス オブジェクトを会社での馬鹿な役割のように名付ける
- ルール 3: 複数のアクションを実行する汎用オブジェクトを作成しない
- Rule 4: Handle Exceptions Inside the Service Object
Why Do I Need Service Objects?
Try this: アプリケーションがparams
からテキストをツイートする必要があるときはどうしますか?
これまでvanilla Railsを使ってきたのであれば、おそらく次のようなことをしてきたでしょう。 また、同じ機能を別のコントローラで使用したい場合はどうすればよいでしょうか。 これをconcernに移動させますか? しかし、このコードはコントローラにはまったくふさわしくありません。 なぜ Twitter API には、私が呼び出すための単一の準備されたオブジェクトが付属していないのでしょうか。
これを初めて行ったとき、私は何か汚いことをしたような気がしました。 私の、以前は美しく無駄のない Rails コントローラが太り始め、どうしたらよいのかわかりませんでした。
この記事を読み始める前に、次のように仮定してみましょう。
- このアプリケーションは Twitter アカウントを処理します。
- The Rails Way とは「従来の Ruby on Rails のやりかた」という意味で、この本は存在しません。
- 私はRailsエキスパートです…と毎日言われていますが、信じるのは難しいので、本当にそうであることにしましょう。
サービスオブジェクトとは何ですか?
サービスオブジェクトはPlain Old Ruby Objects (PORO) で、ドメインロジックで単一のアクションをうまく実行するために設計されたものです。 上記の例を考えてみましょう。 このメソッドにはすでに 1 つのことを行うロジックがあり、それはツイートを作成することです。 このロジックが一つのRubyクラス内にカプセル化されており、インスタンス化してメソッドを呼び出すことができるとしたらどうでしょうか?
tweet_creator = TweetCreator.new(params)tweet_creator.send_tweet# Later on in the article, we'll add syntactic sugar and shorten the above to:TweetCreator.call(params)
これはまさにそれです。TweetCreator
サービス オブジェクトは一度作成されると、どこからでも呼び出すことができ、この 1 つのことを非常にうまく行うことができるのです。
Creating a Service Object
まず、app/services
という新しいフォルダーに新しい TweetCreator
を作成しましょう。
$ mkdir app/services && touch app/services/tweet_creator.rb
そして、新しい Ruby クラスの中にすべてのロジックをダンプしましょう。 Railsはすべてをapp/
の下にオートロードするので、このオブジェクトは魔法のように読み込まれます。
$ rails cRunning via Spring preloader in process 12417Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths.../Users/gilani/Sandbox/nazdeeq/app/services
autoload
の仕組みについてもっと知りたいですか? Autoloading and Reloading Constants Guideをお読みください。
Adding Syntactic Sugar to Make Rails Service Objects Suck Less
見てください、これは理論的には素晴らしいことですが、TweetCreator.new(params).send_tweet
はただ口先が悪いだけです。 冗長な単語であまりにも冗長です…HTML (ba-dum tiss!) のようなものです。 真面目に言うと、HAMLがあるのに、なぜみんなHTMLを使うのでしょう? あるいはSlimでさえも。 それはまた別の機会に。
TweetCreator
は素敵な短いクラス名ですが、オブジェクトのインスタンス化とメソッドの呼び出しにまつわる余計なものが長すぎます!
TweetCreator
のようなクラス名では、オブジェクトのインスタンス化とメソッドの呼び出しにまつわる余計なものが長すぎるのです。 もし、Ruby で何かを呼び出して、与えられたパラメータですぐに実行されるような優先順位があれば…ああ、ありました! Proc#call
.
Proccall
はブロックを呼び出し、ブロックのパラメータを params の値に設定し、メソッド呼び出しに近いセマンティクスを使用しています。 これは、ブロック内で評価された最後の式の値を返します。aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } }aproc.call(9, 1, 2, 3) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=>
Documentation
これがあなたを混乱させるなら、説明させてください。 proc
は、与えられたパラメータで自分自身を実行するようにcall
することができる。 つまり、もし TweetCreator
が proc
ならば、TweetCreator.call(message)
で呼び出し、結果は TweetCreator.new(params).call
と同等で、扱いにくい古い TweetCreator.new(params).send_tweet
と非常によく似ています。
では、サービスオブジェクトをもっと proc
的に動作させましょう!
最初に、おそらくすべてのサービス オブジェクトでこの動作を再利用したいので、Rails の方法を借りて ApplicationService
:
# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend
というクラスを作成しましょう。 引数やブロックを渡してクラスの新しいインスタンスを作成し、そのインスタンスに対して call
を呼び出す call
というクラスメソッドを追加しています。 まさに私たちが欲しかったものです! 最後に、TweetCreator
クラスのメソッドを call
にリネームし、クラスが ApplicationService
を継承するようにします:
# app/services/tweet_creator.rbclass TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend
最後に、コントローラでサービスオブジェクトを呼び出してこれを終了します:
class TweetController < ApplicationController def create TweetCreator.call(params) endend
Grouping similar service objects for sanity
上の例ではサービスオブジェクトは一つだけでしたが、実世界ではより複雑になることがあります。 たとえば、何百ものサービスがあり、そのうちの半分が関連するビジネス アクションであったとしたらどうでしょう (たとえば、別の Twitter アカウントをフォローする Follower
サービスがあった場合など)。 正直なところ、1つのフォルダに200個のユニークなファイルが含まれていたら、私は気が狂ってしまいます。そこで、Rails Wayの別のパターンをコピーして、つまりインスピレーションとして使用するとよいでしょう。 TweetCreator
. これは、人、または、少なくとも組織内の役割のように聞こえます。 ツイートを作成する人です。 私は、サービス・オブジェクトの名前を、あたかも組織の役割であるかのように命名するのが好きです。 この慣習に従って、新しいオブジェクトをこう呼ぶことにします。 ProfileFollower
.
さて、私はこのアプリの最高責任者なので、サービス階層に管理職を作成し、この2つのサービスの責任をその職に委任するつもりです。 この新しい管理職を TwitterManager
.
と呼ぶことにします。この管理職は管理しかしないので、これをモジュールにして、このモジュールの下にサービス オブジェクトをネストさせましょう。 これでフォルダ構造は次のようになります:
services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
そしてサービスオブジェクトは:
# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend
そして呼び出しはTwitterManager::TweetCreator.call(arg)
とTwitterManager::ProfileManager.call(arg)
になります。
データベース操作を扱うサービスオブジェクト
上記の例はAPIの呼び出しでしたが、サービスオブジェクトも呼び出しがすべてAPIの代わりにあなたのデータベースにあるときに使うことができます。 これは、いくつかのビジネス アクションがトランザクションに包まれた複数のデータベース更新を必要とする場合に、特に便利です。
module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... endend
What Do I Return from My Service Object?
call
サービス オブジェクトを作成する方法について説明しましたが、オブジェクトは何を返すべきでしょうか。
- Return
true
orfalse
- Return a value
- Return an Enum
Return true
or false
この1つは簡単な方法です。 アクションが意図したとおりに動作した場合は true
を返し、そうでない場合は false
を返す:
def call ... return true if client.update(@message) false end
Return a Value
サービス オブジェクトがどこかからデータを取得する場合、おそらくその値を返したいことでしょう。
def call ... return false unless exchange_rate exchange_rate end
Respond with an Enum
サービス オブジェクトがもう少し複雑で、異なるシナリオを処理したい場合は、サービスの流れを制御するために enum を追加できます。
これは主観的なものです。 サービス オブジェクトをどこに置くかについては、人によって意見が異なります。 ある人はそれらを lib/services
に置き、ある人は app/services
を作成します。 私は後者の陣営に属します。 Railsの入門ガイドでは、lib/
フォルダを「アプリケーションの拡張モジュール」を置く場所として説明しています。
私の謙虚な意見では、「拡張モジュール」とは、コア ドメイン ロジックをカプセル化せず、一般にプロジェクト間で使用できるモジュールを意味します。 Stack Overflow のランダムな回答の賢明な言葉を借りれば、「潜在的にそれ自身の宝石になることができる」コードをそこに置くことです。 今、この記事を読んでいるということは、モデルやコントローラーに必ずしも属さないコードを書こうとしているのでしょう。 私は最近、サービスオブジェクトがいかにアンチパターンであるかについてのこの記事を読みました。 著者は自分の意見を持っていますが、私は敬意を表して同意しません。
他の人がサービス オブジェクトを酷使したからといって、それが本質的に悪いというわけではありません。 私のスタートアップである Nazdeeq では、サービス オブジェクトを非 ActiveRecord モデルと同様に使用しています。 しかし、何がどこに行くかの違いは、私には常に明らかでした。 私は、すべてのビジネス・アクションをサービス・オブジェクトで管理し、永続性を必要としないリソースは非ActiveRecordモデルで管理しています。 結局のところ、どのパターンが自分にとって良いかを決めるのはあなたです。
しかし、私は一般的にサービス オブジェクトは良いアイデアだと思いますか。 もちろんです! そして、私が PORO の使用に自信を持っているのは、Ruby がオブジェクトを愛しているからです。 いや、マジで、Rubyはオブジェクトが大好きなんです。 正気の沙汰とは思えませんし、完全にイカれていますが、私は大好きです 例えば、
> 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"
See? 5
は文字通りオブジェクトです。
多くの言語では、数字や他のプリミティブな型はオブジェクトではありません。 RubyはSmalltalk言語の影響を受けて、すべての型にメソッドとインスタンス変数を与えています。 Ruby-lang.org
When Should I Not Use a Service Object?
これは簡単です。
- あなたのコードはルーティングやパラメータを処理したり、他のコントローラ的なことを行っていますか?
もしそうなら、サービスオブジェクトを使わないでください。 - 異なるコントローラでコードを共有しようとしていますか?
この場合、サービスオブジェクトを使用しないでください。 代わりに非 ActiveRecord モデルを使用します。 - あなたのコードは、特定のビジネス アクションですか? (例えば、「ゴミを出す」、「このテキストを使ってPDFを生成する」、「この複雑な規則を使って関税を計算する」)
この場合、サービス・オブジェクトを使用してください。
もちろん、これは私のルールですので、あなた自身のユースケースに適応させることは歓迎されます。
Rules for Writing Good Service Objects
私には、サービス オブジェクトを作成するための 4 つのルールがあります。 これらは石に書かれているわけではありませんし、本当に破りたいのであれば破ればよいのですが、私はおそらくコードレビューで、あなたの理由が適切でない限り、変更するよう求めるでしょう。 サービス オブジェクトごとに 1 つのパブリック メソッドのみ
サービス オブジェクトは単一のビジネス アクションです。 必要に応じてパブリック メソッドの名前を変更することができます。 私は call
を好んで使っていますが、Gitlab CEのコードベースでは execute
と呼んでいますし、他の人は perform
を使っているかもしれません。 好きなように使ってください。nermin
と呼ぶこともできます。 ただ、ひとつのサービスオブジェクトに対してふたつのパブリックメソッドを作らないようにしましょう。
Rule 2: サービス オブジェクトを会社での馬鹿な役割のように名付ける
サービス オブジェクトは単一のビジネス アクションです。 その 1 つの仕事をするために会社で 1 人の人を雇ったと想像して、その人を何と呼ぶでしょうか。 その人の仕事がツイートを作成することであれば、TweetCreator
と呼びます。
ルール 3: 複数のアクションを実行する汎用オブジェクトを作成しない
サービス オブジェクトは単一のビジネス アクションです。 機能を2つに分割しました。 TweetReader
と ProfileFollower
です。 私がしなかったことは、TwitterHandler
という単一の汎用オブジェクトを作成し、そこにすべての API 機能をダンプすることです。 これはやめてください。 これは「ビジネスアクション」の考え方に反しており、サービスオブジェクトがTwitterの妖精のように見えてしまいます。 ビジネス オブジェクト間でコードを共有したい場合は、BaseTwitterManager
オブジェクトまたはモジュールを作成し、それをサービス オブジェクトに混ぜるだけです。
Rule 4: Handle Exceptions Inside the Service Object
何度も言いますが、サービス オブジェクトの内部で例外を処理することです。 サービス オブジェクトは単一のビジネス アクションです。 これは何度も言うことではありません。 ツイートを読む人がいれば、そのツイートを渡すか、”このツイートは存在しません “と言うでしょう。 同様に、サービスオブジェクトがパニックに陥り、コントローラの机の上に飛び乗って、”Error!” という理由ですべての作業を停止するように言ってはいけません。
この記事は、Toptalの素晴らしいRuby開発者コミュニティなしには実現できませんでした。
サービス オブジェクトを使用している場合、テスト中に特定の回答を強制する方法について疑問に思うことがあるかもしれません。 実際にサービス オブジェクトを叩くことなく、常に望む結果を返す Rspec でモック サービス オブジェクトを作成する方法についてのこの記事を読むことをお勧めします!
Ruby のトリックについてもっと学びたいなら、同じ Toptaler の Máté Solymosi の Creating a Ruby DSL: A Guide to Advanced Metaprogramming をお勧めします。 彼は、routes.rb
ファイルがいかにRubyらしくないかを分解し、あなた自身のDSLを構築するのを助けてくれます。