このエントリは Ruby on Rails Advent Calendar 15 日目です。(遅くなってすいません)
同時に 14 日目のじょーかーさんのエントリへのアンサーエントリでもあります。
(まあ、じょーかーさんがこの Advent Calendar に登録したときに、タイトルから内容を推察してこれを書くことを決めましたが、実際のところ、あまりアンサーにもカウンターにもなってないし、全然関係ない内容と言えないこともないので、まあサービスクラスについては僕も推奨したことがあるし、僕も反省してるんですよ程度に読んでもらえると幸いです。)
まずはじめにごめんなさい
3 年くらい前に僕は Rails にサービスクラスというものを導入するといいことがあるよと書いたのだけど、それからいくつもの Rails アプリケーションを見たり、実際に自分で開発したりして、うーんって思うことも増えてきたので今の思いをあらためて書いてみます。このエントリを今まで参考にされてきた方々にはこの場を借りて謝罪させていただきます。
Railsでサービスとフォームを導入してみる話 - assertInstanceOf('Engineer', $a_suenami)
ただ基本的な思いは変わってなく、たぶん「サービスと呼んでしまったのがよくなかったな〜」というのが反省の肝です。
僕は単に CQS を実現したいだけだったのかもしれない
僕の Rails 歴もそろそろ 7 年だか 8 年だかをむかえ、業務で利用した期間に限定しても 5 年は超えてると思うので、そろそろ Rails とはこういうものだと自信を持って語っていい程度にはなったと思っている。
その僕の経験上、ActiveRecord を使ってアプリケーションを開発する場合、参照系の機能(検索とか)はパフォーマンス面で悩むことが多く、登録・更新系では機能面(仕様の複雑化、コードの可読性、バグ混入率)の悩みが多くなりがちである。というか、これはおそらく Rails に限った話ではなく、だからこそ CQRS というアーキテクチャパターンが注目されているんだと思うけど、ActiveRecord を使っているとなおさらそう思う。(他のフレームワークや ORM の使用経験がそんなにないのでわからないけど)
そして質的に異なるこの 2 つの悩みは解決方法がまったく異なるので、それらは別々に手を打てるようにできるだけ分離しておきたいし、そうあるべきだよねというのがこのエントリの趣旨である。
Rails における参照系の悩み
参照系に関する悩みについては以下のようなことが理由として挙げられるような気がする。
- ActiveRecord が生成する SQL が狙ったものにならないことがある
- UNION や CTE などの一部のクエリは ActiveRecord の機能としては提供されてない
- USE INDEX 等によるインデックスヒントが与えられない
もちろん、そもそものテーブル設計がイケてないのではないかという意見もあるとは思うのだが、そのイケてない設計に達しやすい原因の一端をになっているのも ActiveRecord ではないかと思っており、なかなか業が深い。
ただ、これらの問題に対しては比較的手を打ちやすいと思うし、最悪の場合は任意の SQL を実行してしまうという裏技もまあできなくはない。なので、悩むことはあるものの、有限時間の悩みで比較的納得感のある解決策にたどり着きやすい。
# インデックスヒント(MySQLの場合)
class Article < ActiveRecord::Base
def use_index(index_name)
from("`#{table_name}` USE INDEX (`#{index_name}`)")
end
end
# 任意の SQL(SQLインジェクションに注意)
# これは本当に最後の手段というか、原則やっちゃダメなのでよい子のみんなは真似しないように!
ActiveRecord::Base.connection.execute("SELECT * FROM `articles` LIMIT 10")
なにより息を吸って吐くように Gemfile に gem を追加してサードパーティのライブラリを利用していく Ruby の文化圏である。SQL で困ったら Memcached なり Redis なり Elasticsearch なり別のミドルウェアの導入を検討するだろうし、それらのクライアントラッパーとなる gem はだいたいのミドルウェアにおいて存在するので、そういった方向での解決を模索するほうがなんとなく Rails っぽさがある(注: 僕の観測範囲に限る)。
Rails における登録・更新系の悩み
登録・更新系での悩みは参照系とは質的に異なる。こちらはデータ整合性に関する問題が圧倒的で、つまり登録・更新時の入力値バリデーションやトランザクション制御の問題に帰結する。
特によく直面する複雑性は 2 つあって、1 つは条件付きバリデーション、もう 1 つが dependent
の連鎖による処理のわかりにくさだ。
条件付きバリデーション
Rails では ActiveRecord クラスに宣言的にバリデーションルールを記述できる。
class Article < ActiveRecord::Base
validates user_id, presence: true
validates title,
presence: true,
length: { maximum: 30 }
validates content, presence: true
end
素晴らしい!一目見ただけでこのクラスのバリデーションルールがわかる。そして、これは以下のように条件を指定することもできる。
class Article < ActieRecord::Base
validates user_id, presence: true
validates title,
presence: true,
length: { maximum: 30 }
if: :published? # <- NEW
validates content,
presence: true,
unless: :draft? # <- NEW
def published?
self.published_at.present?
end
def draft?
!published?
end
end
if
と unless
を両方紹介したかったので同じ条件をあえて違う書き方にしたが、どちらも意味は同じで、要するに下書き状態のときはそのバリデーションを適用しないということだ。まあ、これくらいシンプルであればまあわからなくはないし、便利な機能だなと思う。
でもこれはシステムの大規模化とビジネスの複雑化によってだんだんエスカレートする。
class SomeModel < ActiveRecord::Base
validates :attr1, presence: true, if: :condition1
validates :attr2, presence: true, unless: :condition2
validate :validate_something, if: :condition3
validate :validate_other, if: :condition4
def validate_something
# any validation process
end
def validate_other
# any validation process
end
end
こうなるともうどういうケースでどのバリデーションが有効なのか、ある機能を利用するときにどういう条件を満たせば正常に登録や更新が可能なのかが見ただけではわからなくなる。そして、そのうち、バグを混入してしまうことになり、大切な DB に不正なデータが入ってしまうことになるのである。
dependent
の連鎖
もう一つ、よくある問題は dependent
の問題だ。要は RDB で外部キーに設定する ON DELETE
句と同じようなもので、ON DELETE CASCADE
と同等の設定を Rails だと以下のように書く。
class Article < ActiveRecord::Base
belongs_to :user, dependent: :destroy
end
これもこれだけ単体で見ると非常に便利に見える。ただ、RDB の外部キーやトリガーでも同様だが、こういった「自動的に関連する処理が実行されてしまう仕組み」は本当に注意して使わないと大量の処理が連動して実行されてしまう。
class User < ActiveRecord::Base
has_many: :articles
has_many: :comments
end
class Article < ActiveRecord::Base
belongs_to: :user, dependent: :destroy
has_many: :comments
end
class Comment < ActiveRecord::Base
belongs_to: :user, dependent: :destroy
belongs_to: :article, dependent: :destroy
end
このような場合、ユーザが退会(user.destroy
)すると連動してそのユーザが書いた記事がすべて消え、さらにそれをトリガーとしてその記事へのコメントもすべて消える。この振る舞い自体は正しいだろうし、それを宣言的に書けているという意味ではすばらしいことであるが、以下の 2 点で問題もある。
- 処理の流れを追いにくい
- ユーザの退会なのに User クラスを見ただけで何が起こるのかを把握できない
- パフォーマンス問題への対応をしにくい
- 例えば数百、数千記事を書いているユーザが退会したら?
- その中に多くのコメントがついている人気記事があったとしたら?
こういう場合においては宣言的であることは必ずしも嬉しくはなく「ユーザ退会」という処理をきちんと設計し、ある程度は手続き的に記述したくなるものである。
素直に負けを認める、僕が欲しかったのはサービスクラスではなかった
僕が欲しかったのはサービスクラスでなく、単に CQS の C 側を担当してくれるクラスだったんだと思う。サービスなんて呼ぶから「サービスって何?PoEAA のサービスなの?エヴァンスの言ってるサービスなの?」となるけど、コマンドと呼べば(少なくとも)エンジニアにはわかりやすいのではないだろうか。DB に対して SELECT 以外の SQL を実行するすべてはコマンドになるのだから。
コマンドはもちろん「会員登録」とか「画像アップロード」のようにユーザの言葉や DDD でいうユビキタス言語になっているのが理想だけど、今ここで僕が言いたいのは本当に単にエンジニアの目線で DB への書き込みと参照を分離したいというだけなので、このエントリで言及する範囲内ではそれは必須ではない。コマンドがユーザ目線での処理単位になるべきだということについては機会があれば別途エントリを書こうと思う。
つまり僕が欲しかったのはこういうクラスだ。
class ArticlePostCommand
extend ActiveModel::Model
attr_reader title, content, user_id
validates user_id, presence: true
def initialize(title: nil, content: nil, by: nil)
@title = title
@content = content
@user_id = by
end
def run
return false if invalid?
Article.create!(
title: title,
content: content,
user_id: user_id
)
end
end
class ArticlePublishCommand
extend ActiveModel::Model
attr_reader article
validate :validate_article
def initialize(article_id: nil)
@article_id = article_id
end
def validate_article
self.article = Article.find_by(id: @article_id)
if article.nil?
self.errors.add(:article_id, :not_exists)
else
if article.title.blank?
self.errors.add(:article_id, :blank_title)
end
if article.content.blank?
self.errors.add(:article_id, :blank_content)
end
end
end
def run
return false if invalid?
article.update(published_at: Time.now)
end
end
class ArticleDeleteCommand
extend ActiveModel::Model
attr_reader article
validate :validate_article
def initialize(artilce_id: nil)
@article_id = article_id
end
def validate_article
self.article = Article.find_by(id: @article_id)
if article.nil?
self.errors.add(:article_id, :not_exists)
end
end
def run
return false if invalid?
# ここでいろいろ関連するデータを消す
# 場合によっては非同期化や一時無効化などのパフォーマンス対策を検討する
article.destroy!
end
end
こうすることによって、記事を公開するときにだけ必要なバリデーションルールは ArticlePublishCommand
に隠蔽できたし、記事を削除するときにどういうことが起こるかは ArticleDeleteCommand#run
(だけ)を見ればわかるようになった。連動していろいろなデータが消えて DB の負荷が上がるみたいな問題が起こっても Sidekiq とかを使って非同期化するなり、一旦 disabled
的なフラグを立てて(評判のあまりよくない論理削除ってやつだ!)深夜バッチでゆっくり消すなり、対策を立てやすくなる。
ActiveRecord のバリデーションルールは「不変条件」なので、そこのところよろしく頼む
DbC (契約による設計)が好きな人ならわかると思うけど、僕たちがバリデーションとかアサーションとか呼んでるものには「事前条件」「事後条件」「不変条件」がある。まあ読んで字のごとくではあるが、それぞれ、ある手続きを始めるときに満たしていなければならないもの、手続きを終えるときに満たしていなければならないもの、常に満たしていなければならないものである。(ざっくり)
そしてこの分類で言えば ActiveRecord の validates
や validate
で宣言的に定義するルールは不変条件であるべきだと思う。DB のテーブルと 1 対 1 に対応するクラスであるという性質上、これらはテーブルに設定された各種制約とほぼ同等であり、例えば NOT NULL 制約が設定されたカラムと対応する attribute に presence: true
が設定されるのは問題ないし、同様にユニーク制約が設定されている場合に uniqueness: true
が設定されるのは問題ないのだが、それは誰がどういうコンテキストでデータを更新しても必ず満たされなければならないルール、すなわち不変条件である。
逆に、ある特定のコンテキストでのみ事前条件として適用されるビジネスルールはどこか別のレイヤーに委ねたいし、そうでないと先のような条件付きバリデーションだらけになる。
仮に「ユーザ退会」という操作ひとつとってもユーザ自ら退会する場合と管理画面からオペレータによって強制退会させられる場合で事前条件はきっと異なるし、User#destroy
が実行されたときに必ず満たさなければならないルールというのは実は僕たちが考えているよりずっと少ない。
バリデーションには必ずどのコンテキストでそれを適用するかという観点が必要である。
そういう意味で、それを委ねるレイヤーとして ActiveRecord を継承したモデルクラス以外の可能性を模索していきたいし、それを今までサービスと呼んでしまっていたことは反省しているし、本当にごめんなさいという気持ちでいっぱいである。
最近やっていること
上のほうに書いたサンプルコードを見てもらえればわかると思うが、僕がここでコマンドと呼んでるものはクラスとして表現されているものの実際のところ単なる手続きである。コンストラクタ → 各種バリデーションメソッド → 実行(run
)という順番で呼ばれることを想定しているし、逆にそれ以外の順番で呼ばれてもまったく意図通りの結果にはならない。(run
の先頭で valid?
を実行しているのでたぶん違う順番で実行できないけど)
このクラスを使う側からしても
command = ArticlePostCommand.new(title: 'title', content: 'content', by: current_user.id)
if command.run
@article = command.article
render
else
render :new
end
というふうになってしまい、なんとなくコンストラクタを実行してインスタンスを作成するところが煩わしい。
なので、もっとステートレスな関数っぽく呼べるように以下のような親クラスを作ってみたりしている。ちなみにこれ(ここでは UseCase と呼んでいるが発想は同じ)を参考にした(というか、ほぼパクった)感じである。
module Command
extend ActiveSupport::Concern
include ActiveModel::Model
module ClassMethods
def run(*args)
new(*args).tap do |command|
command.run if command.valid?
end
end
end
def run
raise NotImplementedError
end
def success?
errors.none?
end
end
class ArticlePostCommand
include Command
attr_reader title, content, user_id
validates user_id, presence: true
def initialize(title: nil, content: nil, by: nil)
@title = title
@content = content
@user_id = by
end
def run
Article.create!(
title: title,
content: content,
user_id: user_id
)
end
end
こうすることによって、呼び出す側では
command = ArticlePostCommand.run(title: 'title', content: 'content', by: current_user.id)
if command.success?
@article = command.article
render
else
render :new
end
と記述することができ、関数っぽい感じで呼び出すことができ、その戻り値は実行結果を保持するコンテナインスタンスという感じで処理を続けることができるのである。