はじめに
テキトーにタイトル考えたんですが「ぼくのかんがえたさいきょうの」って元ネタがキン肉マンの子供からの超人提案募集らしいですね。
子供たちが考えた超人を見るが如く、優しい目でご指摘頂けると助かります。
本題に入りまして、Railsは最初のうちは、Rails wayに乗ってれば結構サクサク書けるんですが、ある程度規模が大きくなってくると途端に管理が難しくなります。
ControllerとModelくらいしか使ってないプロジェクトをリファクタリングしていく方法を考えました。
そこまで大きくないプロジェクトなら、最初からこの方式でAPI組んでも良いかなと思います。
しっかりリファクタリングする時間があればいくらでもできると思うので、ある程度方針だけ決めて、それに沿って効果が高そうなものをまとめていきます。
基本方針
Controller
ControllerはAPIのinputを受け取るだけのレイヤーとして使います。
ロジックは全て後述するLogicにまとめて、返り値の整形はSerializerに任せてしまうのが良いと思います。
ただし、Controller内では入力のバリデーションを行います。入力のバリデーション用のクラスを準備しても良いと思いますが、ここでは省略します。
class SamplesController < ApplicationController
def create
# 必要な入力に関してのバリデーションを入れる
raise "some error" unless params[:name]
raise "some error" if params[:age].to_i < 18
# 処理を行う
data = Logic::Sample::CreateService.call(name: params[:name], age: params[:age].to_i)
# 返り値の整形
resp = Serializer::Sample::CreateSerializer.call(data)
# データを返す
render status: 200, json: resp
end
end
Model
Fat ControllerはダメだからロジックはModelに書けみたいな意見がありますが、Fat ModelはFat Modelで大変です。
この辺はチーム内で合議を取るのが良いかと思いますが、結構微妙なケースもあるので最低限のルールだけでも決めておくと良いと思います。
例えば、Modelに関係ないロジックも含めてしまえるので、ルールとしてクラスメソッドはモデルに関係ないロジックを含めないこと、インスタンスメソッドは受け取ったインスタンスに対してのみ処理(CRUD)を行うというルールが良いかなと思っています。
結局ルールによりますが、個人的にはモデル内で別のモデルの処理を入れるのは複雑化するので、反対ですね。
後述しますが、複数モデルにまたがるものはサービスクラス内に別メソッドとして切り出した方が良いです。
あとは他のモデルでも使えそうな一般的なメソッドはconcernに加えるなどするのが良いとは思いますが、ロジックが色々なところにあるとそれはそれで可読性下がるので、注意が必要です。
class User < ApplicationRecord
has_many :posts
# ダメなクラスメソッドの例(ユーザーに関係ないモデルを作成している)
def self.create_user_statistics
user_count = self.all.size
Statistics.create(user_count: user_count)
end
# 良くないインスタンスメソッドの例(最終的に変更が加えられるモデルがUserではない)
def create_post(content:)
self.posts.create!(content: content)
end
# 微妙なインスタンスメソッドの例(Postモデルにself.count_posted_by(user:)というクラスメソッド作ってもいいのかな)
def count_post
self.posts.size
end
end
正直、モデルのリファクタリングはかなり大変です。
管理されてないと色々なロジックが入ってて、どこに影響するかも分かりません。
テストカバレッジがかなり高ければ問題ないとは思いますが、まずは影響が少なそうなロジックや、あえて既存ロジックは変えずに新規のロジックだけ上記ルールをしっかり作ると良いかもしれません。
Logic
Logicというディレクトリ作っても良いんですが、サービスクラスにまとめてしまって良いかなと思ってます。
受け取るControllerのActionごと、つまりエンドポイントごとに1ファイルを作成します。
以下の例ではapp/services/logic/sample/create_service.rb
です。
# サービスクラスの大元をこのように定義しておくとnewする必要がなくなる
# Rubyのバージョンにもよるので以下参照のこと
# https://www.ruby-lang.org/ja/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
class ApplicationService
def self.call(*args, **kwargs, &block)
new(*args, **kwargs, &block).call
end
end
class Logic::Sample::CreateService < ApplicationService
attr_reader :name, :age
def initialize(name:, age:)
@name = name
@age = age
end
def call
user = User.create!(name: name, age: age)
{
user: user,
}
end
end
ちなみにサービスクラスはなんでもつっこめてしまうので責務がわからなくなるということがあります。
この記事の中では、下記の3つの要素の時使うイメージです。
- Logic(上記)
- 外部APIや独立した処理(Slack通知など)
- 複数モデルにまたがる処理(例えば、サインアップでアカウントモデルとユーザーモデルを同時に作成したいなど)
最後の複数モデルにまたがる処理に関しては、一番最後の応用編に貼ってあるリンクにあるInteractorとかに入れてしまっても良いかと思います。
Serializer
ここで返り値を良い感じに整形します。
ここではAlbaというgemを使います。
以下はドキュメントのサンプルコードですが、has_manyも定義ができて、まとめてSerializeすることができます。
class ArticleResource
include Alba::Resource
attributes :title
end
class UserResource
include Alba::Resource
attributes :id
many :articles, resource: ArticleResource
end
UserResource.new(user).serialize
# => '{"id":1,"articles":[{"title":"Hello World!"}]}'
APIとしてschemaがしっかり定義されていれば上記の方法で、必要なattributesを記述して良い感じにオブジェクト作れるのですが、APIごとにモデルのどのattributeを使うかは決まってない場合には以下のように書くことで、呼び出し時にattributesを指定することもできます。
class UserResource
attributes :id, :email, :name
def attributes
super.select { |key, _| params.dig(:user, :keys).include?(key) }
end
end
UserResource.new(user, params: {user: {keys: [:id, email]}}).serialize
# => '{"id":1,"email":"test@example.com"}'
has_manyに対しても拡張もできるので、色々使い勝手は良いかと思います。
応用編
Railsのデザインパターンですが、validationとか目的ごとにクラスを切り出すのはよくやられてるので、こちらを参考にすると良いかと思います。
Railsのデザインパターンまとめ
サービスクラスとapp下によくあるディレクトリについて
まとめ
リファクタリングの基本方針をまとめました。
本当に最低限の方針ですので、規模が大きくなってきたらモデルとは別のドメインモデル作ったりとか、色々なパターンが考えられますが、方針が何もないよりはこのくらいルールがあれば、実装スピードを落とさずに、そこそこ見通しよくコーディングできるのかなと思います。