みなさんはRailsの開発にて、サービスクラス(ServiceObject)を使っているでしょうか。
モデルやコントローラーがファットになるのを防ぐために導入される設計方法で、ドメイン知識におけるサービス(eg: 商品の購入処理)の一連の処理をまとめたクラスを作成することで、可用性が高くテストがしやすいコードを書くことが可能です。
特に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にサービスクラスがあるから参考にしてみると良いよ!」という素晴らしい記事を見つけました。
ということで、mastdonに実装されたサービスクラスを見ながら、自分のクソコードをリファクタリングしてみようと思います。
mastdonのサービスクラス
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