Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Railsアプリをサービスオブジェクトでリファクタリングする方法

サービスオブジェクト(Service Object)とは、単一のアクションだけを担うRubyオブジェクトのことです。ドメインやビジネスロジックにおける一連の処理を、ひとつのクラスにカプセル化します。

例として、架空の図書館管理アプリケーションで本(Book)を作成する場面を考えてみましょう。素のRailsアプリでは、コントローラに次のように記述します。

class BookController < ApplicationController
  def create
    Book.new(*args)
  end
end

シンプルな処理であればこれでも十分です。しかし、アプリが成長するにつれて、コントローラには次第に定型コードが積み重なっていきます。

class BookController < ApplicationController
  def create
    default_args = { genre: find_genre, author: find_author }
    Book.new(book_params.merge(default_args))
  end

  private

  def find_genre
    # ...
  end

  def find_author
    # ...
  end
end

そこで役立つのがサービスオブジェクトです。このような振る舞いを独立したクラスに切り出せば、コードは再びシンプルになります。

class BookController < ApplicationController
  def create
    BookCreator.call(...)
  end
end

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

RailsはMVC(モデル・ビュー・コントローラ・ヘルパー)という構成をネイティブにサポートするよう設計されています。この構造はシンプルなアプリケーションには十分ですが、アプリが大規模になるにつれ、ドメインロジックやビジネスロジックがモデルとコントローラのあちこちに散らばり始めます。こうしたロジックはどちらにも本来属さないため、コードの再利用性や保守性が下がってしまうのです。

Railsのサービスオブジェクトは、ビジネスロジックをコントローラやモデルから分離するためのデザインパターンです。これにより、モデルは純粋なデータ層として、コントローラはAPIへのエントリーポイントとして、それぞれの責務に集中できるようになります。

ビジネスロジックをサービスとしてカプセル化すると、次のような多くのメリットが得られます。

  • スリムなコントローラ — コントローラは「リクエストの解釈」と「params・セッション・Cookieをサービスオブジェクトへ渡す引数への変換」だけを担います。その後、サービスの戻り値に応じてリダイレクトやレンダリングを行うだけです。大規模アプリでも、アクションは10行以内に収まるのが一般的です。

  • テストしやすいコントローラ — コントローラがスリムになり、サービスとの協調者として振る舞うためテストが容易になります。「あるアクション発生時に特定のメソッドが呼ばれたか」だけを検証すればよくなります。

  • ビジネスプロセスの独立したテスト — サービスは環境から分離された小さなRubyオブジェクトなので、テストが簡単かつ高速です。依存先はすべてスタブ化し、サービス内の特定ステップが実行されたかどうかだけを確認できます。

  • 再利用可能なサービス — サービスオブジェクトは、コントローラだけでなく、他のサービスオブジェクトやバックグラウンドジョブなどからも呼び出せます。

  • フレームワークとビジネスドメインの分離 — コントローラはサービスを経由してのみドメインオブジェクトとやり取りします。結合度が下がるためスケールしやすくなり、モノリスからマイクロサービスへの移行時にも、最小限の修正でサービスを抽出・移動できます。

サービスオブジェクトの作成

まず、架空の図書館管理アプリケーション向けに、app/services ディレクトリを作成し、その中に BookCreator クラスのファイルを用意しましょう。

$ mkdir app/services && touch app/services/book_creator.rb

続いて、ロジックを新しいRubyクラスにまとめて記述します。

# app/services/book_creator.rb
class BookCreator
  def initialize(title:, description:, author_id:, genre_id:)
    @title = title
    @description = description
    @author_id = author_id
    @genre_id = genre_id
  end

  def create_book
    Book.create!(
      title: @title,
      description: @description,
      author_id: @author_id,
      genre_id: @genre_id
    )
  rescue ActiveRecord::RecordNotUnique => e
    # 重複登録時のハンドリング
  end
end

あとは、コントローラやアプリ内の任意の場所から、このサービスオブジェクトを呼び出せるようになります。

class BookController < ApplicationController
  def create
    BookCreator.new(
      title: params[:title],
      description: params[:description],
      author_id: params[:author_id],
      genre_id: params[:genre_id]
    ).create_book
  end
end

呼び出しを簡潔にするシンタックスシュガー

BookCreator.new(引数).create_book のような長いチェーンは、インスタンス生成とメソッド呼び出しを一度に行うクラスメソッドを追加すれば、より簡潔にできます。

# app/services/book_creator.rb
class BookCreator
  def initialize(title:, description:, author_id:, genre_id:)
    @title = title
    @description = description
    @author_id = author_id
    @genre_id = genre_id
  end

  def self.call(*args)
    new(*args).create_book
  end

  private

  def create_book
    Book.create!(
      title: @title,
      description: @description,
      author_id: @author_id,
      genre_id: @genre_id
    )
  rescue ActiveRecord::RecordNotUnique => e
    # 重複登録時のハンドリング
  end
end

これで、コントローラ側の呼び出しは次のように書けます。

class BookController < ApplicationController
  def create
    BookCreator.call(
      title: params[:title],
      description: params[:description],
      author_id: params[:author_id],
      genre_id: params[:genre_id]
    )
  end
end

さらに、コードをDRY(Don't Repeat Yourself)に保ち、この挙動を他のサービスオブジェクトでも使い回すために、call メソッドをすべてのサービスが継承する基底クラス ApplicationService に抽象化しておきましょう。

# app/services/application_service.rb
class ApplicationService
  def self.call(*args)
    new(*args).call
  end
end

この基底クラスを用意すれば、BookCreatorApplicationService を継承するだけで済みます。

# app/services/book_creator.rb
class BookCreator < ApplicationService
  def initialize(title:, description:, author_id:, genre_id:)
    @title = title
    @description = description
    @author_id = author_id
    @genre_id = genre_id
  end

  def call
    create_book
  end

  private

  def create_book
    # ...
  end
end

BusinessProcess gemを使ったサービスオブジェクトの作成

business_process gemを使えば、基底クラスの ApplicationServiceinitialize メソッドを自分で定義する必要はありません。これらの設定はすべてgemに組み込まれているため、サービスオブジェクトは BusinessProcess::Base を継承するだけでよいのです。

Gemfileに以下を追加します。

gem 'business_process'

そして、ターミナルで bundle install を実行してください。サービスオブジェクトは次のように書けます。

class BookCreator < BusinessProcess::Base
  # 必須パラメータの宣言
  needs :title
  needs :description
  needs :author_id
  needs :genre_id

  # 処理(アクション)の定義
  def call
    create_book
  end

  private

  def create_book
    # ...
  end
end

良いサービスオブジェクトを作るためのガイドライン

公開メソッドは1つに絞る

サービスオブジェクトは「ひとつのビジネスアクションを、きちんと実行すること」が使命です。そのため、外部に公開するメソッドは1つだけにすべきで、その他のメソッドはprivateにして公開メソッドから呼び出します。公開メソッドの名前は自由に決められますが、すべてのサービスオブジェクト間で命名を揃えることが重要です。本記事の例では call としています。慣習的には performexecute もよく使われます。

役割を表す名前をつける

サービスオブジェクトの名前は、それが何をするのかを示すべきです。よく使われる命名規則として、「〜or」「〜er」で終わる単語があります。例えば、本を作成する役割なら BookCreator、本を読み取る役割なら BookReader のように命名します。

直接インスタンス化しない

シンタックスシュガーのパターンやBusinessProcessのようなgemを活用し、サービスオブジェクトの呼び出し表記を短くしましょう。このアプローチにより、BookCreator.new(*args).callBookCreator.new.call(*args)BookCreator.call(*args) へと簡略化でき、より短く読みやすいコードになります。

名前空間でグループ化する

特に大規模なアプリケーションでは、サービスオブジェクトは1つから数十個へと増えていきます。コードの整理を良くするために、関連するサービス同士を名前空間でグループ化するのが良いプラクティスです。図書館アプリの例なら、本に関連するサービスをひとつのグループに、著者に関連するサービスを別の名前空間にまとめます。フォルダ構成は次のようになります。

services
├── application_service.rb
└── book
    ├── book_creator.rb
    └── book_reader.rb

各サービスオブジェクトは次のように定義します。

# services/book/book_creator.rb
module Book
  class BookCreator < ApplicationService
    # ...
  end
end
# services/book/book_reader.rb
module Book
  class BookReader < ApplicationService
    # ...
  end
end

呼び出し方は Book::BookCreator.call(*args) および Book::BookReader.call(*args) となります。

1つのサービスオブジェクトに1つの責務

複数のことを行うサービスオブジェクトは、「ビジネスアクション」を担うというサービスオブジェクトの考え方に反します。慣習として、複数のアクションを実行する汎用的なサービスオブジェクトは避けるべきとされています。複数のサービス間でコードを共有したい場合は、基底クラスやヘルパーモジュールを作成し、mixinとしてサービスオブジェクトにincludeしましょう。

例外をrescueし、独自の例外をraiseする

サービスオブジェクトの目的は、サードパーティサービスやライブラリとのやり取り、Rails ActiveRecordによるデータベース操作といった実装の詳細を内部にカプセル化することにあります。ActiveRecordとのやり取り中に ActiveRecord::RecordNotUnique のようなエラーが発生した場合は、サービス側で適切にrescueしなければなりません。エラーがコールスタックを上へ伝播していくのは避けるべきです。rescueブロック内で対処できない場合は、そのサービス固有のカスタム例外を定義してraiseします。

まとめ

サービスオブジェクトパターンは、アプリケーションに新機能を追加していく際に、全体の設計を大きく改善してくれる強力な手法です。コードベースの意図が明確になり、保守が容易になり、テストの負担も大幅に軽減されます。ぜひあなたのRailsアプリにも取り入れてみてください。

  1. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた

  2. スマホのカメラで物体を識別!おすすめ画像認識アプリ7選

    スマートフォンのカメラは、もはや単に写真を撮るためだけの従来のパーツではありません。ARステッカーを使って画像を面白く演出するなど、今ではさまざまな魅力的な用途に活用されています。顔やジェスチャーを認識してシャッターを切る機能を使っている方もいるでしょう。同様に、最近ではスマホのカメラで「物体そのもの」を識別することも可能になっています。これは、カメラで撮影した被写体に関する情報を調べる際に大いに役立つ便利なテクニックです。ここでは、カメラで物体を識別できるおすすめアプリをご紹介します。 Google Lens(グーグルレンズ) Google Lensは、対応技術を備えたすべてのAndroid