Railsあるある
何気ないモデルの変更がアプリケーション全体を傷つけた
TL;DR
最近の趣味アプリではコントローラーごとにモデル生やしてトップレベルのモデル使わない設計で書いていて、コールバックやデフォルトスコープ書き散らかしても影響範囲がコントローラー内だけで済むので便利だしFormオブジェクト書いてグルーしまくる必要もなく快適
— Miyagi (@hanachin_) 2018年1月30日
アプリ全体で1モデルだとグローバル変数と一緒、モデル全体に影響でる機能がアプリ全体に影響でてつらい。機能ごとにスキップしたり使い分けはできるけどモデルごと全部分けたほうが楽、コントローラーごとに分けると責務が明確になりやすい。みたいな感じです! 詳しい記事はやる気でたら書く...
— Miyagi (@hanachin_) 2018年2月9日
自分自身ネームスペースどう切るかとかは興味なくてdefault_scopeがつらいのではなくいろんな責務があるのにActiveRecordのモデルが1つなのがつらみの根本的な原因、Formにしてもそれは残るしいっそActiveRecordごと分ける方が便利では既にmigration/micro serviceでやってるしみたいな感じなのでした
— Miyagi (@hanachin_) 2018年2月13日
コントローラーごとに分けろといいたいわけではなく、アプリケーション全体で1テーブル1 ActiveRecordモデルをやめて
経緯
子どものお世話を記録するウェッブアプリケーションを正月休みあたりから書いています。1
https://github.com/hanachin/bblog
その中でこういう気持ちが生まれました(Refinements過激派)。
特定のcontrollerでしかつかわないメソッド、modelに生やさずcontrollerで無名モジュール作ってrefineでよいのでは (過激派
— Miyagi (@hanachin_) 2017年12月22日
リプライで以下のようなツイートを受け取りました。
内部クラスとして HogeController::User を作るのはどうか、って話をこのあいだしてました。
— Takafumi ONAKA (@onk) 2017年12月22日
モデル消えるのヤバイw / HogeController 内では ::User と User とで自然に使い分けられるのが良さそうな感じで、内部クラスの方は find_by_xxx とかのクラスメソッドを置くと良いのかなと。(インスタンス側に何かが欲しいなら decorator という仕組みが既にある
— Takafumi ONAKA (@onk) 2017年12月22日
良さそうなんですが関連テーブルみたりスコープ生やしたりクラスメソッドはやすとなるとコントローラ下にコントローラ用のモデルあると便利そうですね!(パスがapp/models/hoges_controller/user.rbみたいになりそうなのに若干抵抗あり)
— Miyagi (@hanachin_) 2017年12月22日
1月のOkinawa.rbで最近試してる話をしたとツイートしたら反響をいただき23記事にした次第です。
何気ないモデルの変更がアプリケーション全体を傷つける原因
アプリケーションの規模がどんなに大きく成長しても1つのテーブルに対応するActiveRecordのモデルは常に1つ。アプリケーション全体が1つのモデルに依存!
なのでモデルの振る舞いに影響が出る機能を使うとアプリケーション全体に影響がでる。
具体的にどういう機能で問題が出やすいか
Railsあるある4を参考にいくつか挙げてみます。
- default_scope
- validation
- callbacks
- as_json
どれもモデルの振る舞いに影響が出るものばかりですね。
特にdefault_scopeに関しては地雷メソッド5とか撒いてはいけない種6とかevil7など過激な呼ばれ方をされています。
逃れ方
一応それぞれ色々な方法で外したりスキップ出来ます。
default_scope
- unscopedでスコープを外す
- reorder, rewhereなどで条件を変更する
validation
- validationがかからないAPIや
save(validate: false)
を使う8 - contextを設定して特定の場合だけ実行されるようにする
save(context: :account_setup)
9 - 特定の条件に合致する場合しかバリデーションしない10
callbacks
as_json
ActiveRecordの:only
, :except
, :methods
, :include
のオプションを指定するとある程度カスタマイズできる13
逃れるの無理説
アプリケーション全体でモデル1つだとモデルが大きくなるにつれ複雑に組み合わさる。モデルを使う場所全部でこれらの機能を意識しながら書くの無理では...。
ということで最初から使わないほうがよいのでは、みたいな結論になりがちです。
単純にConcernに切り出してファイルを分けてもモデルが1つのままではモデルの変更がアプリケーション全体に影響します。
モデル全体に影響する機能を使わない場合、代わりに何を使うの?
レールの伸ばし方14ではモデルの責務をPORO15, FormObject, ServiceObjectに分ける方法が紹介されています。
ActiveRecord以外の層つくると意外と面倒
ActiveRecordを使うとparamsで受け取った文字列を渡すだけでいい感じに型変換してくれてべんりです。
Form Objectなどを分けた場合、このあたりの型変換のコードでかなり記述量が増えたりします。16
なのでForm Objectを作りやすくするためのまた別のgemを導入することが多いです。17
例えばモデルのクラスを分ける
モデルの振る舞いの影響範囲がアプリケーション全体に及んでしまうのがつらみの原因なら、責務ごとにモデルごと別々に分けると疎になって便利では?
以下で普段の開発の中でActiveRecordのクラスを分ける例を挙げます。
例: マイグレーション実行時に使うモデル
マイグレーション作成時のチェックポイント18から引用します。
app/models 下のモデルクラスなど、マイグレーションファイルの外部に定義している、将来実装を変更する可能性のあるクラスを直接利用することは禁じ手と考えた方がいいでしょう。
なぜかというと、マイグレーションファイルというのは、未来にわたって末永く、書いたときの意図どおりに動く 必要があるからです。言い換えれば、マイグレーションファイルのコードは、マイグレーションファイル内で閉じていて、凍結されていることが望ましい のです。
問題: 1つのクラスに2つの責務
# app/models/user.rb
class User < ApplicationRecord
UNKNOWN_BIRTHDAY = Date.new(9999, 1, 1)
end
require 'date'
class AddBirthdayDateToUsers < ActiveRecord::Migration[5.1]
def up
add_column :users, :birthday_date, :date
User.reset_column_information
User.find_each do |u|
birthday_date = Date.new(u.year, u.month, u.day) rescue nil
u.update(birthday_date: birthday_date || User::UNKNOWN_BIRTHDAY)
end
change_column_null :users, :birthday_date, false
end
end
上記のようにマイグレーション実行時にアプリケーションで定義したActiveRecordのクラスを参照すると、アプリケーションコードにマイグレーションのコードが依存し、1つのクラスに2つの責務が生まれます💪
- アプリケーションを実行するための責務
- マイグレーションを実行するための責務
この場合、アプリケーションを実行するための修正がマイグレーション実行に影響を及ぼす可能性があります。
具体的な例をRails で信頼性の高い Migration を書くには19から引用すると以下のような感じです。
特に Model を使ってデータの移行を行う場合は注意が必要です。create, update, where など一部のメソッドしか使わないつもりでついついそのまま使ってしまいがちですが、hook や default_scope、validation などの変化によって知らぬうちに挙動が変わってしまいます。Migration 毎に専用の Model を作りましょう。
解決策: マイグレーション用のモデルをつくる
マイグレーションファイル中でマイグレーションの実行に必要な責務だけを持ったActiveRecordのクラスを宣言します。アプリケーションコードの変更がマイグレーションに影響することはありません。20
require 'date'
class AddBirthdayDateToUsers < ActiveRecord::Migration[5.1]
class User < ActiveRecord::Base
UNKNOWN_BIRTHDAY = Date.new(9999, 1, 1)
end
def up
add_column :users, :birthday_date, :date
User.find_each do |u|
birthday_date = Date.new(u.year, u.month, u.day) rescue nil
u.update(birthday_date: birthday_date || User::UNKNOWN_BIRTHDAY)
end
change_column_null :users, :birthday_date, false
User.reset_column_information
end
def down
remove_column :users, :birthday_date
end
end
責務に応じてモデルを分けるとよいのでは
上記の例ではActiveRecordのモデルをわけた例を紹介しました。
モデルを分けた結果、モデルが単一責任になり、モデルへの変更が別のモデルやアプリケーションコードに影響しなくなりました。
ふつうのアプリケーションのコードも無理して1つのモデルに全部詰め込まず、マイグレーションのようにActiveRecordのモデルを分けるとよいのでは?
影響範囲が狭くなる
目的ごとにモデルを定義すると影響範囲がアプリケーション全体から狭まります。
default_scope
やvalidation
やcallbacks
やas_json
を書き散らかしても、他のモデルに影響しないので便利そうです。
Railsの機能がそのまま使えて便利
ふつうのActiveRecordのクラスなので型変換や慣れ親しんだAPIをそのまま使えます。
他のgemの使い方を覚える必要はありません。
やりかた
例: 登録が完了したときメールを送りたい
class SignupUser < User
after_save :send_signup_email
private
def send_signup_email
UserMailer.signup(self).deliver_later
end
end
class SignupController < ApplicationController
def create
user = SignupUser.new(params)
if user.save
redirect_to root_path
else
render 'new'
end
end
end
例: 公開されている記事だけを表示したい
class Article < ApplicationRecord
end
class PublishedArticle < ApplicationRecord
self.table_name = "articles"
default_scope -> { where(published: true) }
end
class ArticlesController < ApplicationController
def index
# 一覧用
@articles = PublishedArticle.order(published_at: :desc)
# 新規作成用
@new_article = Article.new
end
end
例: 作るときだけ関連レコードのpresenceを確認したい
class Article < ApplicationRecord
belongs_to :author, optional: true
end
class NewArticle < Article
self.table_name = "articles"
validates :author, presence: true
end
class ArticlesController < ApplicationController
def create
@new_article = NewArticle.new(params)
if @new_article.save
redirect_to @new_article
else
render 'new'
end
end
end
まとめ
アプリケーション全体で1つのテーブルに対応するActiveRecordのモデルが1つだとモデル全体に影響でる機能がアプリケーション全体に影響でてつらい。
default_scope
やvalidation
やcallbacks
やas_json
などモデル全体に影響が出るメソッドでつらみが生まれるのは、それらの機能自体が悪いわけではなく、アプリケーション全体で1モデルを共有しているのが原因ではないか?
アプリケーションの様々な場面での責務を1つのActiveRecordのモデルに詰め込むとつらいので、責務に応じて同じテーブルを参照するActiveRecordのモデルを分けるとよいのでは、影響範囲が狭まるしActiveRecordの機能がそのまま使えて便利!というご提案でした。
1つのテーブルに対応するActiveRecordのモデルを分けるのはマイグレーションやマイクロサービスなどで既にやっている人も多いと思いますが、アプリケーションコードでも分けてこ💪
懸念
最近の趣味のアプリケーションでちょっと試した感じよさそうでしたが大きいアプリケーションになるとまた別のつらみが発生しそう。21
-
docker-composeで環境を整えたり、SQLでi18nした文字列をjsonとしてrenderしてARインスタンス経由せずに返したり、今回記事にしたARのモデルを分ける設計など普段仕事でやらない実験的なことを趣味でやっています ↩
-
https://rails-bestpractices.com/posts/2013/06/15/default_scope-is-evil/ ↩
-
http://guides.rubyonrails.org/active_record_validations.html#skipping-validations ↩
-
http://guides.rubyonrails.org/active_record_validations.html#on ↩
-
http://guides.rubyonrails.org/active_record_validations.html#conditional-validation ↩
-
http://guides.rubyonrails.org/active_record_callbacks.html#skipping-callbacks ↩
-
http://guides.rubyonrails.org/active_record_callbacks.html#conditional-callbacks ↩
-
http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-as_json ↩
-
Plain Old Ruby Object、継承元なしのObjectを継承してるただのRubyのオブジェクト、
class PORO; end
こういうやつ。 ↩ -
ActiveModel::Attributes
が使えるようになればPOROでも同じように型変換できるので問題なくなるかも https://qiita.com/alpaca_taichou/items/bebace92f06af3f32898 ↩ -
学習コスト💪 ↩
-
https://qiita.com/shuhei/items/c0a6c3e29c87de6dff63#migration-%E6%AF%8E%E3%81%AB%E5%B0%82%E7%94%A8%E3%81%AE-model-%E3%82%92%E7%94%A8%E6%84%8F%E3%81%99%E3%82%8B ↩
-
ActiveRecordではテーブルごとにスキーマの情報をキャッシュしておりクラスが分かれていても影響が出る場合があります。
reset_column_information
を呼んでいるのはキャッシュをクリアするためです。詳しくはonkさんの記事を読みましょう。 https://blog.onk.ninja/2017/10/18/use_reset_column_information ↩ -
複雑な実業務でやると影響範囲がアプリケーション全体から特定のモデルを使う機能に変わるだけで結局モデルクラスが増えた分メンテコスト増えたり、同じテーブルに対する操作が複数のモデルに散らばってしまいそう(concernでまとめてあげれば再利用できそうですが)、これはFormオブジェクトに分けても同じかな。 ↩