みなさんは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
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(ブロック後の処理)
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
メソッドを定義しただけのクラスとなりました。
class BaseService
def call(*)
raise NotImplementedError
end
end
次に商品情報の登録・更新・削除の処理を単一責任の原則に従って、それぞれのクラスに分解してcall
メソッドのみをパブリックにします。
登録のサービスクラス
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では全ての箇所でサービスクラスを使っているわけではないようです。
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