Railsあるある

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

TL;DR

経緯

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

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

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

1月のOkinawa.rbで最近試してる話をしたとツイートしたら反響をいただき12記事にした次第です。

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

アプリケーションの規模がどんなに大きく成長しても1つのテーブルに対応したActiveRecordのモデルは常に1つ。アプリケーション全体が1つのモデルに依存!
なのでモデルの振る舞いに影響が出る機能を使うとアプリケーション全体に影響がでる。

具体的にどういう機能で問題が出やすいか

Railsあるある3を参考にいくつか挙げてみます。

  • default_scope
  • validation
  • callbacks
  • as_json

どれもモデルの振る舞いに影響が出るものばかりですね。

特にdefault_scopeに関しては地雷メソッド4とか撒いてはいけない種5とかevil6など過激な呼ばれ方をされています。

逃れ方

一応それぞれ色々な方法で外したりスキップ出来ます。

default_scope

  • unscopedでスコープを外す
  • reorder, rewhereなどで条件を変更する

validation

  • validationがかからないAPIやsave(validate: false)を使う7
  • contextを設定して特定の場合だけ実行されるようにするsave(context: :account_setup)8
  • 特定の条件に合致する場合しかバリデーションしない9

callbacks

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

as_json

ActiveRecordの:only, :except, :methods, :includeのオプションを指定するとある程度カスタマイズできる12

逃れるの無理説

アプリケーション全体でモデル1つだとモデルが大きくなるにつれ複雑に組み合わさる。モデルを使う場所全部でこれらの機能を意識しながら書くの無理では...。
ということで最初から使わないほうがよいのでは、みたいな結論になりがちです。

単純にConcernに切り出してファイルを分けてもモデルが1つのままではモデルの変更がアプリケーション全体に影響します。

モデル全体に影響する機能を使わない場合、代わりに何を使うの?

レールの伸ばし方13ではモデルの責務をPORO14, FormObject, ServiceObjectに分ける方法が紹介されています。

ActiveRecord以外の層つくると意外と面倒

ActiveRecordを使うとparamsで受け取った文字列を渡すだけでいい感じに型変換してくれてべんりです。
Form Objectなどを分けた場合、このあたりの型変換のコードでかなり記述量が増えたりします。15
なのでForm Objectを作りやすくするためのまた別のgemを導入することが多いです。16

例えばモデルのクラスを分ける

モデルの振る舞いの影響範囲がアプリケーション全体に及んでしまうのがつらみの原因なら、責務ごとにモデルごと別々に分けると疎になって便利では?
以下で普段の開発の中でActiveRecordのクラスを分ける例を挙げます。

例: マイグレーション実行時に使うモデル

マイグレーション作成時のチェックポイント17から引用します。

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 を書くには18から引用すると以下のような感じです。

特に Model を使ってデータの移行を行う場合は注意が必要です。create, update, where など一部のメソッドしか使わないつもりでついついそのまま使ってしまいがちですが、hook や default_scope、validation などの変化によって知らぬうちに挙動が変わってしまいます。Migration 毎に専用の Model を作りましょう。

解決策: マイグレーション用のモデルをつくる

マイグレーションファイル中でマイグレーションの実行に必要な責務だけを持ったActiveRecordのクラスを宣言します。アプリケーションコードの変更がマイグレーションに影響することはありません。19

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/controllers/signup_controller/user.rb
class SignupController < ApplicationController
  class User < ::User
    after_save :send_signup_email

    private

    def send_signup_email
      UserMailer.signup(self).deliver_later
    end
  end

  def create
    user = User.new(params)
    if user.save
      redirect_to root_path
    else
      render 'new'
    end
  end
end

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

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  class PublishedArticle < ::ApplicationRecord
    self.table_name = "articles"

    default_scope -> { where(published: true) }
  end

  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/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  class Article < ::Article
    validates :author, presence: true
  end

  def create
    article = Article.new(params)

    if article.save
      redirect_to article
    else
      render 'new'
    end
  end
end

まとめ

アプリケーション全体でActiveRecordのモデルが1つだとモデル全体に影響でる機能がアプリケーション全体に影響でてつらい。
default_scopevalidationcallbacksas_jsonなどモデル全体に影響が出るメソッドでつらみが生まれるのは、それらの機能自体が悪いわけではなく、アプリケーション全体で1モデルを共有しているのが原因ではないか?
責務に応じてコントローラーやアクションごとにActiveRecordのモデルをつくると影響範囲が狭まるし、ActiveRecordの機能がそのまま使えて便利では!というご提案でした。

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

懸念

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  16. 学習コスト💪 

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

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

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

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

3123contribution

個人的には、「モデルに特殊な機能性をもたせたい」場合に、

のような道具立てを使っています。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.