Railsプロジェクトにおけるロジック処理
はじめに
株式会社ヴァージニアのエンジニアリング本部の津留です。前々回と前回で投稿しました記事ではヴァージニアのRailsプロジェクトで使用しているパラメータクラスとコントローラクラスについて書かせていただきました。その流れに続いて、今回はヴァージニアではロジック処理をどのように書いているのか、について触れた記事にしたいと思います!
記事の流れとしてはRailsプロジェクトでアンチパターンとして挙げられているロジックの実装処理について見て行き、次にヴァージニアで実際に行っているロジックの処理の方法と、その方法によって感じる個人的に感じたメリットについて書いていきます。
アンチパターン
ファットモデル
ファットモデルとは
前回の記事でも少し触れましたがRailsではスキニーコントローラーが良しとされ、コントローラではあくまで認証と認可、サービスクラスの呼び出し、テンプレートレンダリングの責務に集中すべきだと述べました。一方でスキニーコントローラを目指すがあまり、ロジックをモデルクラスに集中して書いていくことで、モデルクラスで複数のユースケースを考慮しなければならなくなり、複数の責務を持つようになってしまいます。これがファットモデルの状態です。
ファットモデルの解決策としての例
1. 他モジュールに移譲
ファットモデルの解決策として挙げられるのがモデルに記述された処理や他のモデルと共通して使用できる共通処理などを他のモジュールクラスに移譲することでモデルクラスのスリム化を図る方法です。ActiveSupport::Concern
を使用して共通処理等をモデルクラスにインクルードして使えるようにする例がよく見受けられます。
しかし、この方法ではモデルクラスのコード量を減らす目的でなら有効ではありますが、結局コードをモジュールに移譲させた後、インクルードしているので単一責任の原則に違反していることには変わりありません。DRYを実現すると言う観点からですと良い方法ではあると思いますが、ファットモデルの解決には向かないように思えます。
2. 単一テーブル継承
2つ目の例は単一テーブル継承(STI)を使用する方法です。Railsにおいてモデルとテーブルは原則、1対1の関係ですがSTIを使用すれば1つのテーブルに対して複数のモデルクラスを紐づけることが出来ます。STIはRailsガイドのシングルテーブル継承で実際の実装方法が述べられています。
この方法を用いれば、モデルクラスごとにバリデーションやコールバック処理を分けることができる為、抽象的なモデルを具象化するために分割するという観点ではスリム化は実現できそうです。しかし、この方法はテーブル設計にまで影響が及び、実装レベルの解決には至っていません。さらに継承クラスと継承元クラスの関係が親子の関係である必要があるので使用場面が限定的であるという点、テーブル設計の話なので運用途中から導入するのは困難である点、そもそもSTIを導入する事自体があまり好ましくないのではないか等、いくつかの問題点が挙げられます。
3. ユースケースごとにモデルクラスを継承
3つ目にユースケースごとにモデルクラスを継承する方法が考えられます。
具体的にはテーブルと1対1の関係にあるモデルクラスUser
クラスがあるとします。今まで複数のユースケース、責務が一つのモデルクラスに実装されてしまうことが問題でしたが、この方法では一つの責務をもつ別のクラスを作成し、User
モデルクラスを継承させます。
例えばUser
を更新(Update
)するケースがあった場合、User
クラスを継承したUesr::AttributeUpdator
クラスを作成し、User
がUpdate
する場合のみのバリデーションの制約やコールバックを実装するということです。こうすることでUesr::AttributeUpdator
クラスを更新時にのみ、使用すれば単一責任の原則に乗っ取ったクラスができるのでファットモデル問題を解消できると思われます。
しかし、本来の継承の使い方は親クラスと子クラスがis-a
の関係になっていることが好ましく、今回のようにユースケース毎にクラスを分けて親クラスを基底クラスの様に使うのは、保守性に問題があります。オブジェクト指向の思想から外れてしまっているので可読性が下がり、将来的なバグの原因にもなりうるでしょう。さらに、長い期間使われてきたアプリケーションの基底クラスを変更すると影響範囲も膨大になる恐れがありますので、できれば避けたいところです。
ヴァージニアのロジック処理
上記ではファットモデルを避ける為の方法について述べてきましたが、次はヴァージニアではファットモデルを避ける為に、如何にロジックを実装しているのか紹介させて頂きます。
1. サービスクラスの導入・特徴
ヴァージニアではロジックをモデルクラスに書くことはせず、サービスクラスを導入してユースケースごとにサービスクラスを生成しロジックを実装する方針をとっています。ここでモデルにロジックを書かないとお伝えしましたが、全く何も書かないと言うわけではなく、モデルのインスタンスが持つ自身の情報を成形するメソッドや、自身の情報からフラグを返却するメソッドなど、自身の性質や振る舞いを表現するメソッドは持たせています。あくまで複数の責務をもたせないようにすることが目的なので、ある特定のユースケースでしか使用しないようなロジックは持たないようにしている、と言う意味です。
具体的に実装例を見てみましょう。下記のコードをご覧ください。
class DetermineCustomerPlanService
include Service
def initialize(customer_id:, plan_grade:)
@customer_id = customer_id
@plan_grade = plan_grade
end
def call
@customer.update!(plan_grade: @plan_grade)
generate_service_plan(plan_grade: @plan_grade).call(resource: @customer.resource_for_plan)
end
private
def generate_service_plan(plan_grade:)
case plan_grade
when "premium"
CustomerPremiumPlanService #希望するプランのグレードがプレミアムの顧客に対し、プレミアムプランを設定するサービスクラス
when "basic"
CustomerBasicPlanService #希望するプランのグレードがベーシックの顧客に対し、ベーシックプランを設定するサービスクラス
else
NotImplementedError
end
end
end
顧客の希望するプランのグレードがpremium
なのかbasic
かによって顧客に紐づくプラン内容を決定しているクラスになります。クラス名から推測できる通り、顧客のプランを決定するユースケース限定のサービスクラスとなります。
なのでこのクラス自体、単一責任の原則に則っていますし、顧客(Customer
)クラスにも顧客に紐づくプラン(Plan
)クラスにも今回のユースケースのロジックを実装しなくて済むことになります。
単一責任の原則にも則っているので、ある処理を実装している途中で、顧客のプランを決定する処理を挟む必要が出てきた場合には今回例示したサービスクラスを呼び出せば実現できるので保守の観点からも良いです。generate_service_plan
メソッドの内部では、まさに顧客の希望するプランのグレードによってCustomerPremiumPlanService
かCustomerBasicPlanService
のいずれかが呼び出されるようになっていますね。これら二つのサービスクラスもプレミアムプランを決定するサービスクラスとベーシックプランを決定するサービスクラスと責務を一つに集中しているので、処理がしっかり分割できており、再利用性が高くなっています。
これらのロジックをモデルクラスを使って実現しようとすると、単一責任の原則から外れてしまっているのは勿論、ロジックをCustomer
モデルクラスに書くべきなのかPlan
モデルクラスに書くべきなのか迷ってしまいます。どちらに書くか迷うということは、モデルの振る舞いとして扱うべきではないと判断できると言えます。なので、サービスクラスにロジック処理を切り出して、一つのクラスの持つ役割をできるだけ、細分化した方が良いと思います。
2. サービスクラスを作成する際の約束事
上では一つのサービスクラスの実装例を例示させていただきましたが、ヴァージニアで実装されたサービスクラスにはいくつかの共通な特徴があるので、それらを紹介したいと思います。
まず一つ目にPublicなメソッドにはinitialize
メソッドとcall
メソッドのみを用意していると言う点です。上でも述べた通り、サービスクラスは一つのユースケースを実現するように作成しておりますので、外部からはクラスをインスタンス化して実行する挙動のみを行える様にしてあります。
二つ目には上のコード例にもあるとおり、Service
モジュールをインクルードしている点です。下記はService
モジュールのコードです。
module Service
extend ActiveSupport::Concern
class_methods do
def call(*args, **kwargs)
new(*args, **kwargs).call
end
end
end
Service
モジュールではActiveSupport::Concern
を使用しています。ここでActiveSupport::Concern
の細かい説明は省きますが、モジュール内でクラスメソッドのcall
が定義されています。
メソッドの内部を見ていただくとわかりますが、このモジュールをインクルードしているクラスをインスタンス化せずに直接call
メソッドを呼び出すと、メソッド内部でnew
メソッドを呼び出し、クラスをインスタンス化し、インスタンスメソッドのcall
メソッドを呼び出しています。
つまり、上のサービスクラスをコントローラから呼び出した場合下記のように、逐一インスタンス化せずに簡略化して呼び出すことが可能になります。
class CustomersController < ::Api::BaseController
def update
# ...中略
customer = DetermineCustomerPlanService.call(customer_id: customer_id, plan_grade: plan_grade)
# ...中略
end
end
3. モデルクラスに実装している処理
ロジックをモデルクラスに書かないのは上記まででたくさん述べてきましたが、ではモデルクラスには何を主に書いているのか述べていきたいと思います。
ヴァージニアのロジック実装を述べる最初の方でも軽く触れましたが、モデルクラスはエンティティとほぼ同義だと捉え、エンティティ自身を表現する属性や振る舞いをモデルクラスには定義しています。
例えば、先ほどコード例で示したDetermineCustomerPlanService
クラスの中で、下記部分がありました。
generate_service_plan(plan_grade: @plan_grade).call(resource: @customer.resource_for_plan)
generate_service_plan
メソッドで返したサービスクラスでcall
メソッドを実行する際に@customer
インスタンスでresource_for_plan
メソッドを実行していますが、このメソッドでプランを作成する際に必要な顧客の情報を返していると考えてください。
返す顧客の情報はCustomer
クラスにとって、自身が持つ性質を指し、プランを作成する際に必要な顧客の情報を返す
という振る舞いを持っている、と言うことになります。
まとめ
今回、ロジック処理のアンチパターンと解決策、最後にヴァージニアの実装方法について述べてきました。
ヴァージニアのロジック実装は、記事中にも書いてきましたが、各クラスがある特定のユースケースを想定して作られているので処理が追いやすく、可読性が高い点が個人的には気に入っています。またクラス名に関しても今回一例しか示せていませんが、何を実行するためのクラスなのかわかりやすく命名されており、実行処理が想像しやすいです。ある特定のユースケースを想定しているからこそ、クラス名もわかりやすく名付けることができると言っていいかもしれません。
さらに、処理を追加実装するクラスが明確であり、各サービスクラスで実装されているメソッドが同じ作りになっている(「2. サービスクラスを作成する際の約束事
」を参照)のでメンテナンス面で利点があります。用途が絞られているので一クラスあたりのコードの量がそこまで多くならないのも個人的に読みやすいです。
個人的な経験上、ヴァージニアのようにユースケースを意識して、クラス内部のメソッドの作りまで決まり事がある現場はなかったので、初見時は非常に新鮮でした。今まで経験してきた現場でのサービスクラスについては、どのクラスに、どんな実装を持たせるのか曖昧なケースが多かったのでレビュー時に議論したり、開発時に考えなくてはならない時間が多かったのですが、現在はあまり手を止まらせることなく開発出来たので、開発時のストレスが大きく解消されると思います。
ロジック導入する際に参考にしてみてはいかがでしょうか。
Discussion