Rails

ActiveRecordのモデルが1つだとつらい

Railsあるある

何気ないモデルの変更がアプリケーション全体を傷つけた

TL;DR

コントローラーごとに分けろといいたいわけではなく、アプリケーション全体で1テーブル1 ActiveRecordモデルをやめて

経緯

子どものお世話を記録するウェッブアプリケーションを正月休みあたりから書いています。1
https://github.com/hanachin/bblog

その中でこういう気持ちが生まれました(Refinements過激派)。

リプライで以下のようなツイートを受け取りました。

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

  • callbackが実行されないAPIを使う11
  • 特定の条件に合致する場合しかバリデーションしない12

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_scopevalidationcallbacksas_jsonを書き散らかしても、他のモデルに影響しないので便利そうです。

Railsの機能がそのまま使えて便利

ふつうのActiveRecordのクラスなので型変換や慣れ親しんだAPIをそのまま使えます。
他のgemの使い方を覚える必要はありません。

やりかた

例: 登録が完了したときメールを送りたい

app/models/signup_user.rb
class SignupUser < User
  after_save :send_signup_email

  private

  def send_signup_email
    UserMailer.signup(self).deliver_later
  end
end
app/controllers/signup_controller.rb
class SignupController < ApplicationController
  def create
    user = SignupUser.new(params)
    if user.save
      redirect_to root_path
    else
      render 'new'
    end
  end
end

例: 公開されている記事だけを表示したい

app/models/article.rb
class Article < ApplicationRecord
end
app/models/published_article.rb
class PublishedArticle < ApplicationRecord
  self.table_name = "articles"
  default_scope -> { where(published: true) }
end
app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    # 一覧用
    @articles = PublishedArticle.order(published_at: :desc)

    # 新規作成用
    @new_article = Article.new
  end
end

例: 作るときだけ関連レコードのpresenceを確認したい

app/models/article.rb
class Article < ApplicationRecord
  belongs_to :author, optional: true
end
app/models/new_article.rb
class NewArticle < Article
  self.table_name = "articles"
  validates :author, presence: true
end
app/controllers/articles_controller.rb
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_scopevalidationcallbacksas_jsonなどモデル全体に影響が出るメソッドでつらみが生まれるのは、それらの機能自体が悪いわけではなく、アプリケーション全体で1モデルを共有しているのが原因ではないか?
アプリケーションの様々な場面での責務を1つのActiveRecordのモデルに詰め込むとつらいので、責務に応じて同じテーブルを参照するActiveRecordのモデルを分けるとよいのでは、影響範囲が狭まるしActiveRecordの機能がそのまま使えて便利!というご提案でした。

1つのテーブルに対応するActiveRecordのモデルを分けるのはマイグレーションやマイクロサービスなどで既にやっている人も多いと思いますが、アプリケーションコードでも分けてこ💪

懸念

最近の趣味のアプリケーションでちょっと試した感じよさそうでしたが大きいアプリケーションになるとまた別のつらみが発生しそう。21


  1. docker-composeで環境を整えたり、SQLでi18nした文字列をjsonとしてrenderしてARインスタンス経由せずに返したり、今回記事にしたARのモデルを分ける設計など普段仕事でやらない実験的なことを趣味でやっています 

  2. https://twitter.com/kimihito_/status/960332669954359297 

  3. https://twitter.com/kazumalab/status/961824048052281345 

  4. https://www.slideshare.net/tricknotes/rails-possiblestory 

  5. https://qiita.com/sinsoku/items/9cbdc5304aa3ede4a178 

  6. https://qiita.com/juntetsu_tei/items/a1b641f7f3b10d3ae6e1 

  7. https://rails-bestpractices.com/posts/2013/06/15/default_scope-is-evil/ 

  8. http://guides.rubyonrails.org/active_record_validations.html#skipping-validations 

  9. http://guides.rubyonrails.org/active_record_validations.html#on 

  10. http://guides.rubyonrails.org/active_record_validations.html#conditional-validation 

  11. http://guides.rubyonrails.org/active_record_callbacks.html#skipping-callbacks 

  12. http://guides.rubyonrails.org/active_record_callbacks.html#conditional-callbacks 

  13. http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-as_json 

  14. https://speakerdeck.com/willnet/rerufalseshen-basifang 

  15. Plain Old Ruby Object、継承元なしのObjectを継承してるただのRubyのオブジェクト、class PORO; endこういうやつ。 

  16. ActiveModel::Attributesが使えるようになればPOROでも同じように型変換できるので問題なくなるかも https://qiita.com/alpaca_taichou/items/bebace92f06af3f32898 

  17. 学習コスト💪 

  18. https://qiita.com/nay3/items/ef773006cd7f815a07cd 

  19. 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 

  20. ActiveRecordではテーブルごとにスキーマの情報をキャッシュしておりクラスが分かれていても影響が出る場合があります。reset_column_informationを呼んでいるのはキャッシュをクリアするためです。詳しくはonkさんの記事を読みましょう。 https://blog.onk.ninja/2017/10/18/use_reset_column_information 

  21. 複雑な実業務でやると影響範囲がアプリケーション全体から特定のモデルを使う機能に変わるだけで結局モデルクラスが増えた分メンテコスト増えたり、同じテーブルに対する操作が複数のモデルに散らばってしまいそう(concernでまとめてあげれば再利用できそうですが)、これはFormオブジェクトに分けても同じかな。