Railsサービスオブジェクト:包括的なガイド
公開: 2022-03-11Ruby on Railsには、アプリケーションのプロトタイプをすばやく作成するために必要なすべてのものが付属していますが、コードベースが大きくなり始めると、従来のFat Model、SkinnyControllerのマントラが破られるシナリオに遭遇します。 ビジネスロジックがモデルにもコントローラーにも適合しない場合は、サービスオブジェクトが入り、すべてのビジネスアクションを独自のRubyオブジェクトに分離できるようにします。
この記事では、サービスオブジェクトが必要な場合について説明します。 クリーンなサービスオブジェクトを作成し、貢献者の健全性のためにそれらをグループ化する方法。 サービスオブジェクトをビジネスロジックに直接結び付けるためにサービスオブジェクトに課す厳格なルール。 そして、あなたが何をすべきかわからないすべてのコードのためにあなたのサービスオブジェクトをゴミ捨て場に変えない方法。
なぜサービスオブジェクトが必要なのですか?
これを試してください:アプリケーションがparams[:message]
からのテキストをツイートする必要がある場合はどうしますか?
これまでバニラレールを使用している場合は、おそらく次のようなことを行ったことがあるでしょう。
class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end
ここでの問題は、コントローラーに少なくとも10行を追加したが、実際にはそこに属していないことです。 また、別のコントローラーで同じ機能を使用したい場合はどうなりますか? これを懸念事項に移しますか? 待ってください、しかしこのコードは実際にはコントローラーにまったく属していません。 Twitter APIに、私が呼び出すための単一の準備されたオブジェクトが付属していないのはなぜですか?
初めてこれをしたとき、何か汚いことをしたような気がしました。 以前は、美しくスリムなRailsコントローラーが太り始めていて、どうしたらよいかわかりませんでした。 最終的に、コントローラーをサービスオブジェクトで修正しました。
この記事を読み始める前に、ふりをしましょう。
- このアプリケーションはTwitterアカウントを処理します。
- Rails Wayは、「従来のRuby on Railsのやり方」を意味し、この本は存在しません。
- 私はRailsの専門家です…私は毎日そう言われていますが、それを信じるのに苦労しているので、私が本当に1人であると偽ってみましょう。
サービスオブジェクトとは何ですか?
サービスオブジェクトは、ドメインロジックで1つのアクションを実行し、それを適切に実行するように設計されたプレーンオールドRubyオブジェクト(PORO)です。 上記の例を考えてみましょう。私たちのメソッドには、1つのことを実行するロジックがすでにあります。それは、ツイートを作成することです。 このロジックが、インスタンス化してメソッドを呼び出すことができる単一のRubyクラス内にカプセル化されている場合はどうなりますか? 何かのようなもの:
tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])
これはほとんどそれです。 TweetCreator
サービスオブジェクトは、作成されるとどこからでも呼び出すことができ、これは非常にうまく機能します。
サービスオブジェクトの作成
まず、 app/services
という名前の新しいフォルダーに新しいTweetCreator
を作成しましょう。
$ mkdir app/services && touch app/services/tweet_creator.rb
そして、すべてのロジックを新しいRubyクラス内にダンプしましょう。
# app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end
次に、アプリ内の任意の場所でTweetCreator.new(params[:message]).send_tweet
を呼び出すことができ、それが機能します。 Railsは、 app/
の下にあるすべてのものを自動ロードするため、このオブジェクトを魔法のようにロードします。 次のコマンドを実行して、これを確認します。
$ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services
autoload
がどのように機能するかについてもっと知りたいですか? Autoloading and ReloadingConstantsGuideをお読みください。
Railsサービスオブジェクトの吸い込みを減らすためのシンタックスシュガーの追加
ほら、これは理論的には素晴らしいと思いますが、 TweetCreator.new(params[:message]).send_tweet
は一口です。 HTML(ba-dum tiss! )のように、冗長な単語を使用すると冗長すぎます。 しかし、真面目な話ですが、HAMLが存在するのになぜ人々はHTMLを使用するのでしょうか。 またはスリムですら。 それはまた別の記事だと思います。 手元のタスクに戻ります。
TweetCreator
は短いクラス名ですが、オブジェクトのインスタンス化とメソッドの呼び出しに関する余分な作業は長すぎます。 Rubyで何かを呼び出し、指定されたパラメーターを使用してすぐに実行することが優先された場合のみ…ああ、待ってください! Proc#call
です。
Proccall
はブロックを呼び出し、メソッド呼び出しセマンティクスに近いものを使用して、ブロックのパラメーターをparamsの値に設定します。 ブロックで評価された最後の式の値を返します。aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]
ドキュメンテーション
これで混乱する場合は、説明させてください。 proc
をcall
て、指定されたパラメーターでそれ自体を実行できます。 つまり、 TweetCreator
がproc
の場合、 TweetCreator.call(message)
を使用して呼び出すことができ、結果はTweetCreator.new(params[:message]).call
と同等になります。これは、扱いにくい古いTweetCreator.new(params[:message]).send_tweet
と非常によく似ています。 TweetCreator.new(params[:message]).send_tweet
。
それでは、サービスオブジェクトをproc
のように動作させましょう!
まず、おそらくすべてのサービスオブジェクトでこの動作を再利用したいので、Rails Wayから借用して、 ApplicationService
というクラスを作成しましょう。
# app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end
私がそこで何をしたかわかりましたか? 渡した引数またはブロックを使用してクラスの新しいインスタンスを作成し、インスタンスでcall
をcall
というクラスメソッドを追加しました。 まさに私たちが望んでいたことです! 最後に行うことは、 TweetCreator
クラスからメソッドの名前を変更してcall
、クラスにApplicationService
から継承させることです。
# app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end
最後に、コントローラーでサービスオブジェクトを呼び出して、これをまとめましょう。
class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end
正気のための同様のサービスオブジェクトのグループ化
上記の例にはサービスオブジェクトが1つしかありませんが、現実の世界では、事態はさらに複雑になる可能性があります。 たとえば、何百ものサービスがあり、それらの半分が関連するビジネスアクションであった場合、たとえば、別のTwitterアカウントをフォローするFollower
サービスがある場合はどうでしょうか。 正直なところ、フォルダに200個のユニークなファイルが含まれているとしたら、気が狂ってしまいます。RailsWayからコピーできる別のパターンがあります。つまり、インスピレーションとして使用します。名前空間です。
他のTwitterプロファイルに従うサービスオブジェクトを作成するように任命されたとしましょう。
以前のサービスオブジェクトの名前であるTweetCreator
を見てみましょう。 それは人のように聞こえるか、少なくとも組織における役割のように聞こえます。 ツイートを作成する人。 サービスオブジェクトに、組織内の役割という名前を付けるのが好きです。 この規則に従って、新しいオブジェクトをProfileFollower
と呼びます。
今、私はこのアプリの最高の支配者なので、サービス階層に管理職を作成し、これら両方のサービスの責任をその職に委任します。 この新しい管理職をTwitterManager
と呼びます。
このマネージャーは管理するだけなので、モジュールにして、このモジュールの下にサービスオブジェクトをネストしましょう。 フォルダ構造は次のようになります。
services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
そして私たちのサービスオブジェクト:
# services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
# services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end
そして、呼び出しは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 ... end end
サービスオブジェクトから何を返しますか?
サービスオブジェクトをcall
方法について説明しましたが、オブジェクトは何を返す必要がありますか? これにアプローチする3つの方法があります:
-
true
またはfalse
返します - 値を返す
- 列挙型を返す
true
またはfalse
返します
これは単純です。アクションが意図したとおりに機能する場合は、 true
を返します。 それ以外の場合は、 false
を返します:
def call ... return true if client.update(@message) false end
値を返す
サービスオブジェクトがどこかからデータをフェッチする場合は、おそらくその値を返したいと思うでしょう。
def call ... return false unless exchange_rate exchange_rate end
列挙型で応答する
サービスオブジェクトがもう少し複雑で、さまざまなシナリオを処理したい場合は、列挙型を追加してサービスのフローを制御できます。
class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end
そして、アプリで次を使用できます。
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
app/services
servicesではなくlib/services
にサービスオブジェクトを配置するべきではありませんか?
これは主観的なものです。 サービスオブジェクトをどこに置くかについては、人々の意見が異なります。 それらをlib/services
に配置する人もいれば、 app/services
を作成する人もいます。 私は後者の陣営に落ちます。 Railsのスタートガイドでは、 lib/
フォルダーを「アプリケーション用の拡張モジュール」を配置する場所として説明しています。
私の謙虚な意見では、「拡張モジュール」とは、コアドメインロジックをカプセル化せず、一般的にプロジェクト全体で使用できるモジュールを意味します。 ランダムなStackOverflowの答えの賢明な言葉で、「潜在的にそれ自身の宝石になる可能性がある」コードをそこに入れてください。
サービスオブジェクトは良いアイデアですか?
ユースケースによって異なります。 ほら、今この記事を読んでいるという事実は、モデルやコントローラーに正確に属していないコードを書き込もうとしていることを示唆しています。 最近、サービスオブジェクトがアンチパターンである方法についてこの記事を読みました。 著者は彼の意見を持っていますが、私は敬意を表して反対します。
他の人がサービスオブジェクトを使いすぎたからといって、それらが本質的に悪いというわけではありません。 私のスタートアップである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"
見る? 5
は文字通りオブジェクトです。
多くの言語では、数値やその他のプリミティブ型はオブジェクトではありません。 Rubyは、そのすべてのタイプにメソッドとインスタンス変数を与えることにより、Smalltalk言語の影響に従います。 オブジェクトに適用されるルールはすべてのRubyに適用されるため、これによりRubyの使用が容易になります。 Ruby-lang.org
サービスオブジェクトを使用すべきでないのはいつですか?
これは簡単です。 私はこれらのルールを持っています:
- あなたのコードはルーティング、パラメータを処理しますか、それとも他のコントローラーのようなことをしますか?
その場合は、サービスオブジェクトを使用しないでください。コードはコントローラーに属します。 - コードを別のコントローラーで共有しようとしていますか?
この場合、サービスオブジェクトを使用しないでください。懸念事項を使用してください。 - あなたのコードは永続性を必要としないモデルのようなものですか?
その場合は、サービスオブジェクトを使用しないでください。 代わりに、ActiveRecord以外のモデルを使用してください。 - あなたのコードは特定のビジネスアクションですか? (例:「ゴミを出す」、「このテキストを使用してPDFを生成する」、「これらの複雑なルールを使用して関税を計算する」)
この場合、サービスオブジェクトを使用します。 そのコードは、おそらくコントローラーにもモデルにも論理的に適合しません。
もちろん、これらは私のルールなので、自分のユースケースに適応させることを歓迎します。 これらは私にとって非常にうまく機能しましたが、あなたのマイレージは異なる場合があります。
優れたサービスオブジェクトを作成するためのルール
サービスオブジェクトを作成するための4つのルールがあります。 これらは石で書かれているわけではなく、本当に壊したいのであれば可能ですが、あなたの推論が正しい場合を除いて、コードレビューで変更するようにお願いします。
ルール1:サービスオブジェクトごとに1つのパブリックメソッドのみ
サービスオブジェクトは単一のビジネスアクションです。 必要に応じて、パブリックメソッドの名前を変更できます。 私はcall
を使用することを好みますが、Gitlab CEのコードベースはそれexecute
と呼び、他の人はperform
を使用する可能性があります。 あなたが好きなものを使ってください—あなたはそれを私が気にするすべてのためにnermin
と呼ぶことができます。 1つのサービスオブジェクトに対して2つのパブリックメソッドを作成しないでください。 必要に応じて、2つのオブジェクトに分割します。
ルール2:会社での愚かな役割のような名前のサービスオブジェクト
サービスオブジェクトは単一のビジネスアクションです。 あなたがその1つの仕事をするために会社で1人の人を雇ったとしたら、あなたは彼らを何と呼びますか? 彼らの仕事がツイートを作成することである場合は、 TweetCreator
と呼びます。 彼らの仕事が特定のツイートを読むことである場合は、 TweetReader
と呼んでください。
ルール3:複数のアクションを実行するためのジェネリックオブジェクトを作成しない
サービスオブジェクトは単一のビジネスアクションです。 機能をTweetReader
とProfileFollower
の2つに分割しました。 私がしなかったことは、 TwitterHandler
と呼ばれる単一のジェネリックオブジェクトを作成し、そこにすべてのAPI機能をダンプすることです。 これをしないでください。 これは「ビジネスアクション」の考え方に反し、サービスオブジェクトをTwitterの妖精のように見せます。 ビジネスオブジェクト間でコードを共有する場合は、 BaseTwitterManager
オブジェクトまたはモジュールを作成し、それをサービスオブジェクトにミックスします。
ルール4:サービスオブジェクト内の例外を処理する
十二回目:サービスオブジェクトは単一のビジネスアクションです。 私はこれを十分に言うことはできません。 ツイートを読んでいる人がいる場合は、その人がツイートを返すか、「このツイートは存在しません」と言います。 同様に、サービスオブジェクトをパニックに陥らせたり、コントローラーのデスクにジャンプしたり、「エラー!」のためにすべての作業を停止するように指示したりしないでください。 false
を返し、コントローラーをそこから先に進めます。
クレジットと次のステップ
この記事は、ToptalのRuby開発者の素晴らしいコミュニティなしでは実現できなかったでしょう。 私が問題に遭遇した場合、コミュニティは私が今まで出会った中で最も役立つ才能のあるエンジニアのグループです。
サービスオブジェクトを使用している場合、テスト中に特定の回答を強制する方法に疑問を感じるかもしれません。 実際にサービスオブジェクトにアクセスすることなく、常に必要な結果を返すRspecでモックサービスオブジェクトを作成する方法について、この記事を読むことをお勧めします。
Rubyのトリックについてもっと知りたい場合は、Ruby DSLの作成:仲間のToptalerMateSolymosiによる高度なメタプログラミングのガイドをお勧めします。 彼は、 routes.rb
ファイルがRubyのように感じられないことを分析し、独自のDSLを構築するのに役立ちます。