やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

mastdonからServiceクラスの作り方を学ぶ

みなさんはRailsの開発にて、サービスクラス(ServiceObject)を使っているでしょうか。
モデルやコントローラーがファットになるのを防ぐために導入される設計方法で、ドメイン知識におけるサービス(eg: 商品の購入処理)の一連の処理をまとめたクラスを作成することで、可用性が高くテストがしやすいコードを書くことが可能です。

techracho.bpsinc.jp

特にRailsではActiveRecordの便利さゆえにコントローラーやモデルに処理をゴリゴリと書いてしまいがち。しかし、何も考えずにサービスクラスを無秩序に作ってしまうと逆に保守性が悪い状態になってしまいます。

自分が書いたクソコード

偉そうに言っていますが、先日、自分も良くないサービスクラスを書いてしまいました。
商品情報の登録・更新・削除を担当するサービスクラスであるUserProductクラスです。このクラスを対応するコントローラーのCRUDのエンドポイントから、対応する関数を呼び出していました。

class Services::UserProduct
  def initialize(user_id)
    raise 'user_id is not specified' if user_id.nil?
    @user_id = user_id
  end

  def regist(name:, need_points:, stock_ammount:)
    product = Product.new(name: name, need_points: need_points, stock_ammount: stock_ammount, user_ids: @user_id)
    { successed: product.save, errors: product.errors.full_messages }
  end

  def update(product_id, name:, need_points:, stock_ammount:)
    product = Product.find(product_id)
    product.update!(name: name, need_points: need_points, stock_ammount: stock_ammount)
  end

  def destroy(product_id)
    product = Product.find(product_id)
    product.destroy!
  end
end

しかしながら、このサービスクラスは無秩序で好き放題書かれています。
サービスクラスを導入したことで、コントローラーとモデルのコードはかなりスッキリして恩恵を感じましたが、このクラスは単一責任の原則に違反しています。サービスクラスにするのであれば、商品情報の登録・更新・削除は別々のクラスにするべきでした。
また、ここでは紹介しませんが他のサービスクラスではexecというメソッドが定義されていたりとメソッドの命名規則も特にありませんでした。

「どのようにサービスクラスを作るのが良いかな」と調べていたところ「有名なオープンソースのmastdonにサービスクラスがあるから参考にしてみると良いよ!」という素晴らしい記事を見つけました。

fuyu.hatenablog.com

ということで、mastdonに実装されたサービスクラスを見ながら、自分のクソコードをリファクタリングしてみようと思います。

mastdonのサービスクラス

github.com

mastdonはサービスクラスをappディレクトリの直下に作成したservicesディレクトリに格納しています。
その中で基本的にはサブディレクトリを作ることはしないようです。どのサービスクラスにも__Serviceというクラス名が宣言されています。

いくつかクラスを見た感じ、どのサービスクラスもBaseServiceクラスを継承しており、外部に公開されるパブリックなメソッドはcallのみのようです。BaseServiceではモジュールの読み込みと、可変長な引数を持つcallメソッドを定義するようにインターフェース的な役割を持たせています。

base_service.rb

# frozen_string_literal: true

class BaseService
  include ActionView::Helpers::TextHelper
  include ActionView::Helpers::SanitizeHelper

  include RoutingHelper

  def call(*)
    raise NotImplementedError
  end
end

mastodon/base_service.rb at main · mastodon/mastodon · GitHub

実際のサービスクラスではcallメソッドを定義しています。
call以外のメソッドはプライベートとなっています。興味深いのはinitializeに引数を渡してインスタンス変数を定義するよくある書き方をせずに、callメソッドが呼び出された際にインスタンス変数を定義しているという点です。initializeメソッドを定義しなくて良いというメリットはありますが、自分的にはインスタンス変数を使いたいのであればinitializeメソッドを定義する方が個人的には好みです。

after_block_service.rb(ブロック後の処理)

# frozen_string_literal: true

class AfterBlockService < BaseService
  def call(account, target_account)
    @account        = account
    @target_account = target_account

    clear_home_feed!
    :
  end

  private

  def clear_home_feed!
    FeedManager.instance.clear_from_home(@account, @target_account)
  end
  :
end

mastodon/after_block_service.rb at main · mastodon/mastodon · GitHub

クソコードのリファクタリング

mastdonの実装を踏まえて僕が書いたクソコードをリファクタリングしてみます。
まずは基底クラスの作成しますが、今回は特に読み込みが必要なモジュールなどはないので、callメソッドを定義しただけのクラスとなりました。

# frozen_string_literal: true

class BaseService
  def call(*)
    raise NotImplementedError
  end
end

次に商品情報の登録・更新・削除の処理を単一責任の原則に従って、それぞれのクラスに分解してcallメソッドのみをパブリックにします。

登録のサービスクラス

# frozen_string_literal: true

class CreateProductService < BaseService
  def call(user_id:, name:, need_points:, stock_ammount:)
    product = Product.new(name: name, need_points: need_points, stock_ammount: stock_ammount, user_ids: user_id)
    { successed: product.save, errors: product.errors.full_messages }
  end
end

コントローラーからは以下のように呼び出します。

def create
  res = CreateProductService.new.call(
    user_id: params[:user_id],
    name: params[:name],
    need_points: params[:need_points],
    stock_ammount: params[:stock_ammount],
  )
  render json: res, status: :ok
end

このやり方でも良いのですが、インスタンス変数を使わないのであればクラスメソッドとして定義してしまった方が良いかもしれません。
現時点ではただ、データを登録するだけなのでサービスクラスの恩恵を感じませんが、今後、メールの送信や合わせて作成しないといけないデータなどが追加された際にはこのサービスクラスのみに手を加えれば良いです。

サービスクラス大量発生?

とはいえ、コントローラーの全てのエンドポイントでサービスクラスを作るのは非常に面倒です。
このままだと大量のサービスクラスが作られていくことになります。mastdonではどうなっているでしょうか。
どうやらmastdonでは全ての箇所でサービスクラスを使っているわけではないようです。

# frozen_string_literal: true

class Api::V1::Statuses::PinsController < Api::BaseController
  include Authorization

  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
  before_action :require_user!
  before_action :set_status

  # このエンドポイントではサービスクラスを使わず処理がベタ書きされている
  def create
    StatusPin.create!(account: current_account, status: @status)
    distribute_add_activity!
    render json: @status, serializer: REST::StatusSerializer
  end
end

mastodon/pins_controller.rb at main · mastodon/mastodon · GitHub

どういった基準でサービスクラスの作成を検討しているのか分かりませんが、推測するに比較的長い処理、関連する処理が多い箇所ではサービスクラスを作成しているようです。 ただ、こんなサービスクラスもあるので真相は謎のままです。

class AfterUnallowDomainService < BaseService
  def call(domain)
    Account.where(domain: domain).find_each do |account|
      DeleteAccountService.new.call(account, reserve_username: false)
    end
  end
end

mastodon/after_unallow_domain_service.rb at main · mastodon/mastodon · GitHub