[Rails] コンパクトにサービス層を自作・導入する(ジェネレータ付き)

  • 4
    Like
  • 0
    Comment

TL;DR

  • Railsアプリにサービス層を導入したいんだけど、Trailblazerとか使うほどでもないんだよなぁ、という人(主に自分)向け
  • 素のRailsアプリに自作サービス層を導入してみた
  • ついでにrails generate service hogeみたいなジェネレータも作成してみた

経緯

既存のRailsアプリ(APIサーバ)にサービス層を導入しよう!と思い立ったものの、
Trailblazer等のgemを導入すると大掛かりな改修が必要になるため、少々敷居が高いと感じました。
そこで、既存プロジェクトにgemを追加せず、最低限の機能を持ったコンパクトなサービス層を自作して導入しようと考えました。

要件(サービス層に求めていたこと)

  • 既存のRailsプロジェクトにgemを追加せずに導入できること
  • rails generate service sampleのように、ジェネレータを用いてファイルを作成できること
  • 1機能 1クラスのコンパクトなサービスクラス
  • パラメータのバリデーションを行い、異常があれば例外を投げる(既存コードと同様のエラー処理を行うため)

方針

  • ActiveModel::Modelをincludeしたクラス(フォームオブジェクトのようなもの)を作成する
  • 処理実行前のバリデーション実行を強制し、パラメータが正常な場合のみ処理を行うようにする
  • バリデーションエラー時にはActiveRecord::RecordInvalid例外を投げる
  • rails generate generatorを使用し、ジェネレータを生成する
  • パラメータはコンストラクタ経由で渡す(個人的な好みです)

ソースコード

app/services/base_service.rb

すべてのサービスクラスの親クラスです。
ポイントは、

  • ActiveModel::Modelをincludeし、パラメータのバリデーション機能を使用可能にする
  • 処理実行前に必ずバリデーションを経由させる

の2点です。

app/services/base_service.rb
class BaseService
  include ActiveModel::Model

  # サービスを利用するクラスが呼び出すメソッド
  def provide()
    raise_validation_error if invalid?
    perform
  end

  private

  # サービスの処理(パラメータが正常な場合のみ実行されます)
  # 各サービスクラスではこのメソッドをoverrideして機能を実装します
  def perform()
    raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
  end

  # バリデーションエラー時、ActiveRecord::RecordInvalidを投げるためのメソッド
  def raise_validation_error()
    raise ActiveRecord::RecordInvalid.new(self)
  end
end

app/services/sample_service.rb

個々のサービスクラスは、上記のBaseServiceクラスを継承して作成します。
パラメータはコンストラクタ内でインスタンス変数にセットし、処理本体はperform()メソッド内に実装します。
また、必要に応じてバリデーションのためのコードを記述します。

(コードの雛形は後述するジェネレータで自動生成します)

app/services/sample_service.rb
class SampleService < BaseService
  # パラメータ一覧
  attr_accessor :username, :password

  # バリデーション
  validates :username, presence: true
  validates :password, presence: true

  # パラメータはコンストラクタで渡す
  def initialize(username, password)
    @username = username
    @password = password
  end

  private

  # 処理本体(パラメータが正常な場合のみ実行されます)
  def perform()
    # ここに処理を記述します
  end
end

ジェネレータ

ジェネレータは下記のコマンドで追加できます。
console
$ rails generate generator service

コマンド実行後、lib/generators/serviceが作成されるので、ジェネレータのコードを編集します。
ジェネレータクラスにあるすべてのインスタンスメソッドが(書いた順番に)実行されます。

lib/generators/service/service_generator.rb
class ServiceGenerator < Rails::Generators::NamedBase
  source_root File.expand_path('../templates', __FILE__)

  # パラメータを定義
  argument :name

  # サービスクラスを作成する
  def create_service_file
    destination = Rails.root.join("app/services/#{name.underscore}_service.rb")
    template('service.rb.erb', destination)
  end

  # サービスクラスのテストクラスを作成する
  def create_test_file
    destination = Rails.root.join("test/services/#{name.underscore}_service_test.rb")
    template('test.rb.erb', destination)
  end
end

また、サービスクラスとそのテストクラスのテンプレートは下記の通りです。

サービスクラスのテンプレート

lib/generators/service/template/service.rb.erb
class <%= name.camelize %>Service < BaseService
  def initialize()
    # Set parameters...
  end

  private

  def perform()
    # Run this method if parameters are valid
  end
end

テストクラスのテンプレート

lib/generators/service/template/test.rb.erb
require 'test_helper'

class <%= name.camelize %>ServiceTest < ActiveSupport::TestCase
  # test "the truth" do
  #   assert true
  # end
end

使い方

サービスクラス作成

下記のコマンドを実行します

$ rails generate service sample

実行後、app/services/sample_service.rbtest/services/sample_service_test.rbが作成されますので、
適宜修正してください。

controllerからの呼び出し

前述のように、パラメータはコンストラクタの引数として渡します。
また、provide()メソッドを呼び出すことで、処理実行前に自動でバリデーションが行われます。

service = SampleService.new('myuser', 'mypassword')
result = service.provide

まとめ

ActiveModel::Modelを利用し、コンパクトなサービス層の導入を実現しました。
また、ジェネレータも用意し、簡単にサービスクラスを追加できるようにしました。

今回作成したコードはかなり単純なものなので、もう少し改良してからgemにできたらと思います。

参考

trailblazer/trailblazer: A High-Level Architecture for Ruby.
Rails:Service層を運用して良かったところ、悪かったところ - Qiita
Rails のアーキテクチャ設計を考える - Qiita
rails でカスタム generator 作る話 - scramble cadenza
erikhuda/thor: Thor is a toolkit for building powerful command-line interfaces.