Railsサービスオブジェクト:包括的なガイド

公開: 2022-03-11

Ruby on Railsには、アプリケーションのプロトタイプをすばやく作成するために必要なすべてのものが付属していますが、コードベースが大きくなり始めると、従来のFat Model、SkinnyControllerのマントラが破られるシナリオに遭遇します。 ビジネスロジックがモデルにもコントローラーにも適合しない場合は、サービスオブジェクトが入り、すべてのビジネスアクションを独自のRubyオブジェクトに分離できるようにします。

Railsサービスオブジェクトを使用したリクエストサイクルの例

この記事では、サービスオブジェクトが必要な場合について説明します。 クリーンなサービスオブジェクトを作成し、貢献者の健全性のためにそれらをグループ化する方法。 サービスオブジェクトをビジネスロジックに直接結び付けるためにサービスオブジェクトに課す厳格なルール。 そして、あなたが何をすべきかわからないすべてのコードのためにあなたのサービスオブジェクトをゴミ捨て場に変えない方法。

なぜサービスオブジェクトが必要なのですか?

これを試してください:アプリケーションが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]

ドキュメンテーション

これで混乱する場合は、説明させてください。 proccallて、指定されたパラメーターでそれ自体を実行できます。 つまり、 TweetCreatorprocの場合、 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

私がそこで何をしたかわかりましたか? 渡した引数またはブロックを使用してクラスの新しいインスタンスを作成し、インスタンスでcallcallというクラスメソッドを追加しました。 まさに私たちが望んでいたことです! 最後に行うことは、 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

サービスオブジェクトを使用すべきでないのはいつですか?

これは簡単です。 私はこれらのルールを持っています:

  1. あなたのコードはルーティング、パラメータを処理しますか、それとも他のコントローラーのようなことをしますか?
    その場合は、サービスオブジェクトを使用しないでください。コードはコントローラーに属します。
  2. コードを別のコントローラーで共有しようとしていますか?
    この場合、サービスオブジェクトを使用しないでください。懸念事項を使用してください。
  3. あなたのコードは永続性を必要としないモデルのようなものですか?
    その場合は、サービスオブジェクトを使用しないでください。 代わりに、ActiveRecord以外のモデルを使用してください。
  4. あなたのコードは特定のビジネスアクションですか? (例:「ゴミを出す」、「このテキストを使用してPDFを生成する」、「これらの複雑なルールを使用して関税を計算する」)
    この場合、サービスオブジェクトを使用します。 そのコードは、おそらくコントローラーにもモデルにも論理的に適合しません。

もちろん、これらは私のルールなので、自分のユースケースに適応させることを歓迎します。 これらは私にとって非常にうまく機能しましたが、あなたのマイレージは異なる場合があります。

優れたサービスオブジェクトを作成するためのルール

サービスオブジェクトを作成するための4つのルールがあります。 これらは石で書かれているわけではなく、本当に壊したいのであれば可能ですが、あなたの推論が正しい場合を除いて、コードレビューで変更するようにお願いします。

ルール1:サービスオブジェクトごとに1つのパブリックメソッドのみ

サービスオブジェクトは単一のビジネスアクションです。 必要に応じて、パブリックメソッドの名前を変更できます。 私はcallを使用することを好みますが、Gitlab CEのコードベースはそれexecuteと呼び、他の人はperformを使用する可能性があります。 あなたが好きなものを使ってください—あなたはそれを私が気にするすべてのためにnerminと呼ぶことができます。 1つのサービスオブジェクトに対して2つのパブリックメソッドを作成しないでください。 必要に応じて、2つのオブジェクトに分割します。

ルール2:会社での愚かな役割のような名前のサービスオブジェクト

サービスオブジェクトは単一のビジネスアクションです。 あなたがその1つの仕事をするために会社で1人の人を雇ったとしたら、あなたは彼らを何と呼びますか? 彼らの仕事がツイートを作成することである場合は、 TweetCreatorと呼びます。 彼らの仕事が特定のツイートを読むことである場合は、 TweetReaderと呼んでください。

ルール3:複数のアクションを実行するためのジェネリックオブジェクトを作成しない

サービスオブジェクトは単一のビジネスアクションです。 機能をTweetReaderProfileFollowerの2つに分割しました。 私がしなかったことは、 TwitterHandlerと呼ばれる単一のジェネリックオブジェクトを作成し、そこにすべてのAPI機能をダンプすることです。 これをしないでください。 これは「ビジネスアクション」の考え方に反し、サービスオブジェクトをTwitterの妖精のように見せます。 ビジネスオブジェクト間でコードを共有する場合は、 BaseTwitterManagerオブジェクトまたはモジュールを作成し、それをサービスオブジェクトにミックスします。

ルール4:サービスオブジェクト内の例外を処理する

十二回目:サービスオブジェクトは単一のビジネスアクションです。 私はこれを十分に言うことはできません。 ツイートを読んでいる人がいる場合は、その人がツイートを返すか、「このツイートは存在しません」と言います。 同様に、サービスオブジェクトをパニックに陥らせたり、コントローラーのデスクにジャンプしたり、「エラー!」のためにすべての作業を停止するように指示したりしないでください。 falseを返し、コントローラーをそこから先に進めます。

クレジットと次のステップ

この記事は、ToptalのRuby開発者の素晴らしいコミュニティなしでは実現できなかったでしょう。 私が問題に遭遇した場合、コミュニティは私が今まで出会った中で最も役立つ才能のあるエンジニアのグループです。

サービスオブジェクトを使用している場合、テスト中に特定の回答を強制する方法に疑問を感じるかもしれません。 実際にサービスオブジェクトにアクセスすることなく、常に必要な結果を返すRspecでモックサービスオブジェクトを作成する方法について、この記事を読むことをお勧めします。

Rubyのトリックについてもっと知りたい場合は、Ruby DSLの作成:仲間のToptalerMateSolymosiによる高度なメタプログラミングのガイドをお勧めします。 彼は、 routes.rbファイルがRubyのように感じられないことを分析し、独自のDSLを構築するのに役立ちます。