はじめに
私のボスが「Trailblazerを使い、Railsのモデルの肥大化問題からサヨナラする」の記事をSlackで共有してきました。それを読んだエンジニア歴が浅い新人がプロジェクトに導入すべきかどうか悩んでいます。
私は直感的に思いました。「ダメだ。私たちにはまだ早い! いきなりTrailblazerでサービスクラスなんか書いていたら、WEBアプリケーションの設計の美しさに気づくことなく一生を終えてしまう。」
なお、ここでいうサービスクラスというのは、「俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ」の記事にあるような、すべてのビジネスロジックを手続き型プログラミングのように書く実装を意味しています。そして、私にとっては、流行りのTrailblazerのOperationでさえも、そのようなサービスクラスをきれいに整えただけ1のように見えて、ときめきを感じないのです。
Trailblazerについての人気記事のコードを見てみる
先ほどの記事のようなサービスクラスのコードは、日々Qiitaを読んでいるような皆さんにとってはうんざりでしょう。
一方、そのサービスクラスを進化させることによってFat Model問題を解決しようとしているTrailblazerのコードについてはどのような印象をお持ちでしょうか?
Operationのサンプルコード
はじめにで出てきたTrailblazerについての人気記事「Trailblazerを使い、Railsのモデルの肥大化問題からサヨナラする」で書かれているコードを引用しながら見ていきましょう。
コントローラー
まず、コントローラーは以下のようになっています。
def create
# ユーザー認証機能をDeviseで作った場合、ユーザーはcurrent_userで取得できる
run Post::Create, params[:post], current_user: current_user do
# バリデーションに成功したら(全Stepが成功したら)こちらに入る
redirect_to action: :index and return
end
# バリデーションに失敗したら(いずれかのStepが失敗したら)こちらにくる
render :new
end
注目のサービスクラス
そして、こちらがサービスクラス(Operation)のコードです。コメント部分はすべて私が書きました。注目すべきは、フォームのバリデーションなども含めたすべてのビジネスロジックが、まるで手続き型プログラミングのように書かれていることです。そして、それらのステップの記述にはstep
と呼ばれるDSLが使われています。
# Operationというのはサービスクラスのこと
class Post::Create < Trailblazer::Operation
# DSLを使う
extend Contract::DSL
# Postモデル用のフォームオブジェクト(Reformと呼ばれる)を記述する。
# Reformではモデル単位ではなく、フォーム単位でバリデーションを行う。
contract do
model "post"
property :title, validates: { presence: true, length: { maximum: 100 } }
property :body, validates: { presence: true, length: { maximum: 2000 } }
end
# ここのDSLが特徴的。ビジネスロジックをステップごとに記述する。
step Model(Post, :new) # Postモデルを生成
step :assign_current_user! # post.user = current_user と同等
step Contract::Build() # フォームオブジェクト(Reform)をビルド
step Contract::Validate() # フォームオブジェクト(Reform)をバリデート
step Contract::Persist() # フォームオブジェクト(Reform)を使ってモデルを保存
step :notify_author! # ユーザーへの通知
def assign_current_user!(options)
post = options["model"]
post.user = options["current_user"]
end
def notify_author!(options)
# ユーザーへの通知処理
end
end
その他のコード
フォームオブジェクトであるContract
のコードは、元記事をご覧ください。フォームやモデルのバリデーションだけでなく、Strong Parametersの処理もやってくれます。
感想: これが主流になると悲しい...
TrailblazerのOperationでのサービスクラスは、とてもすっきり書かれていて、何をやっているのかがわかりやすいです。プログラマーじゃなくても概要を理解できるレベルだと思います。
しかしですよ! MVCモデルのWEBアプリケーションフレームワークの集大成であり、WEBアプリケーション設計におけるノウハウの塊であるRuby on Railsのクールな機能がほとんど使われていないじゃないですか? 私たちが好きなのはオブジェクト指向ですよ、デザインパターンですよ、そして、オブジェクト同士がメッセージを送り合うコールバックを使う設計ですよ! 手続き型プログラミングみたいに書いてんじゃないよ(涙)
さらに、テストケースを書くならまだしも、一番大切なメインのコードを書くのに独自言語(DSL)を使うのですか! Ruby on Rails作者のDavid Heinemeier Hansson氏は美しいコードを書けるからRubyを選んだと言っています。その美しいコードを書けるRubyを捨ててまでやるのですか(涙)
Rails標準の機能を使って書いてみる
Trailblazerとの比較のために、同じことをRails標準の機能を使って書いてみます。
コントローラー
まずはコントローラーです。Railsの標準で書いた場合、コントローラーのコード量はTrailblazerと比べて少し増えてしまいます。ただ、これから述べるようにモデルをしっかりと書けば、コントローラーが肥大化することはありません。
def create
form = NewPostForm.new(post_params)
# ユーザー認証機能をDeviseで作った場合、ユーザーはcurrent_userで取得できる
form.user = current_user
# ここでバリデーション、モデルの保存、ユーザーへの通知が行われる
if form.save
redirect_to action: :index and return
end
render :new
end
def post_params
params.require(:post).permit(:title, :body)
end
注目のモデル
そして、注目のモデルです。Railsではコールバックの仕組みが積極的に使われます。ここでは、そのコールバックと、それをバリデーションの目的に特化したその名もバリデーションという仕組みを見ていきます。
コールバック
まずはコールバックです。
普通に書く場合
class Post < ApplicationRecord
after_save :notify_author
# ...
def notify_author
# ユーザーへの通知処理
end
end
モデルの保存後にafter_save
で定義したメソッドがコールバックされます。ここでは、投稿(Post)が完了したら、ユーザーにメールなどで通知を行うようになっています。
つまり、ユーザーモデルを保存するだけで、必要なメソッドが必ず実行されるわけです。サービスクラスに書いた場合、コードをコピペして新しいサービスクラスを作ったときに、必要な処理もうっかり消してしまうことがあります。モデルに書いておくとそんな心配もありません。
Fat Model対策をする場合
モジュール
もしモデルの肥大化が気になる場合は、Railsのモジュールの仕組みを使って、モデルを分割することができます。
class Post < ApplicationRecord
include PostNotificator # モデルにはこれを書くだけ
# ...
end
# 機能をモジュールとして、別のファイルで定義する
module PostNotificator
extend ActiveSupport::Concern
included do
after_save :notify_author
end
def notify_author
# ユーザーへの通知処理
end
end
元のコードでは、モデルを保存した後の処理はユーザーへの通知だけでしたが、他にも、たとえば「モデルを保存した後にKVSの値も更新する」などの処理も、モデルにinclude KVSUpdater
と記述して、KVSUpdater
モジュールを定義すれば、Postモデルにすっきりと機能を追加することができます。
class Post < ApplicationRecord
include PostNotificator
include RedisUpdater # モデルに機能を追加
# ...
end
module KVSUpdater
extend ActiveSupport::Concern
included do
after_save :update_kvs
end
def update_kvs
# ここにKVSを更新するコード
end
end
デリゲート
デリゲートの仕組みを使って、特定の処理を別クラスに移譲させることもできます。
class Post < ApplicationRecord
# notify_authorメソッドの処理を移譲する
delegate :notify_author, to: :notify
def notify
PostNotificator.new(self).notify
end
# ...
end
バリデーション
コールバックに続いて、バリデーションです。バリデーションの使い方は、カラムのフォーマット合っているかというようなシンプルものだけではありません。外部APIを呼び出して、その結果によってはモデルを保存しないようなこともバリデーションクラスに書けばすっきりします。
class Post < ApplicationRecord
# モデルの保存前(バリデート時)にコールバックされる
validates_with SubscriptionValidator
# ...
end
class SubsctiptionValidator < ActiveModel::Validator
def validate(record)
# このユーザーが課金しているかどうかを外部APIを使って調べるなどの複雑な処理
end
end
その他のコード
フォームオブジェクトであるTrailblazerのReformは、モデル単位ではなくフォーム単位でバリデーションを行うのですが、Rails標準のActiveModelでも同じようなことができます。
ただ、フォームオブジェクトに関してはReformの方が便利で使いやすいと思います。簡潔に書けるだけでなく、Strong Parametersの処理までやってくれます。元記事のContract(Reform)をご覧ください。
本記事の趣旨からは外れますが、ActiveModelを使ったフォームオブジェクトの実装も書いておきます。
class NewPostForm
include ActiveModel::Model
include ActiveModel::Attributes # Rails5.1以前ではActiveModelAttributesのgemを使う
# モデルにあるアトリビュート
def self.attributes
[:name, :body, :user]
end
attr_accessor *self.attributes
# モデルにないアトリビュート
attribute :privacy_policy, :boolean, default: false
# バリデーション
validates :privacy_policy, acceptance: true
def to_model
Post.new(to_hash)
end
def save
return false unless valid?
to_model.save
end
def to_hash
self.class.attributes.inject({}) do |hash, key|
hash.merge({ key => self.send(key) })
end
end
end
上記の実装はとても大変な感じがしますね。ActiveModelをTrailblazerのReform並に便利にするには、たくさんハックする必要があります。以下のURLを参考にすれば、self.attributes
やto_hash
のあたりが各フォーム間で共通化できると思います。
ActiveModel attributes - Stack Overflow
感想: やっぱりRailsはクール!
ActiveModelのコードを除き、上記のモデルの書き方はやっぱりクールです。必要なコールバックを定義して、そこをトリガーにして動かすのです。
コントローラーの主な処理は、渡されてきたパラメーターでモデルを作成/更新/破棄することだと思います。これに加えて、モデルを扱う前後でいくつかの処理が必要となります。Railsのモデル(ActiveRecord)では、その前後の処理をコールバックを使うことでロジックを分けて書けるようになっているのです。
コールバックの種類はこちらを参照してください。
Active Record コールバック - Rails ガイド
サービスクラスを作った方がよいケースもある?
ここまで書いておいてアレですが、サービスクラスを作った方がいいケースがあることは認めます。代表的なものはバッチ処理でしょう。
ただ、よく(コントローラーの1つのアクションで)複数モデルを扱う場合もサービスクラスが向いているんじゃないかという意見も聞きますが、複数モデル間でリレーションが張られていればやはりモデルだけで済みます。accepts_nested_attributes_for
を定義すれば、1つのモデルを更新するコードを書くだけで他の関連付けられたモデルも更新されます。もちろん、その際、それぞれのモデルのコールバックも呼ばれます。
まとめ
特別なケースを除けば、サービスクラスを作るとおもしろくなくなるという私見から、私はTrailbrazerはあまり使いたくないなぁ...と思います。ただ、時代の流れには逆らえませんし、もしかすると食わず嫌いなだけかもしれませんので、今後使う機会があれば、ぜひ一度がっつりとやってみたいとも思っています。
-
もちろん、ソースコードをきれいに整えて可読性、凝縮性を上げることが何より大切なのは理解しています。 ↩