おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

Rails で fat model を避けるための、あまり知られていない方法について

このエントリで書いた内容は、ほぼ Growing Rails Applications in Practice の内容が元になっています。英語ですが、ここで挙げた内容以外にもコードを綺麗に保つテクニックが書かれており、かつページ数も少なく読みやすいです。コードを綺麗に保つのが好きな方は一読してみることをおすすめします。

はじめに

Rails で fat model を避けるための方法は、7 Patterns to Refactor Fat ActiveRecord Models を始めとして、多くのやり方が存在します*1

validation や callback は ActiveRecord(以下AR) を継承せずとも利用することができます。7 Patterns to Refactor Fat ActiveRecord Models の 「3. Extract Form Objects」がそのいい例ですね。ただ、(ARの)モデルに関する validation や callback をかけたい場合は、モデルに直接書いてしまう人が多いのではないでしょうか。

しかしそれは悲劇の始まりでもあります。例を挙げてみましょう。

友達と好きな趣味を共有しあうSNSのようなサービスを考えてみます。さらに、

  • 自分自身の変更は友達に通知される
  • 趣味は必ず1つ以上持っていなければならない

とします。すると User モデルは次のようになるでしょう

class User < ActiveRecord::Base
  has_many :friends, through: :friendships
  has_many :friendships
  has_many :hobbies, through: :likes
  has_many :likes

  validate :should_have_hobbies

  before_save :notify_friends

  private

  def notify_friends
    # 友達に自分自身の変更を通知するコード
  end

  def should_have_hobbies
    errors[:base] << '好きな趣味を登録してください。' if hobbies.count == 0
  end
end

「自分自身の変更は友達に通知される」を callback で、「趣味は必ず1つ以上持っていなければならない」を validation で実装しています。一見、何の問題もないように思えます。

それぞれ、具体的に何が辛いのか見ていきましょう。

callback の辛さ

このサービスは、毎日の運勢を占ってくれる機能があります。毎日バッチ処理でランダムに 0..100 の点数を出し、User の luck カラムに格納することにします。するとどうなるでしょうか。

ユーザは、友達全員の今日の運勢を通知として毎日受け取ることになってしまいますね。

もちろん、 skip_callback メソッドを利用することで、 callback 処理をスキップさせることは可能です。しかし、通知をさせずに User を変更したいときに、毎回それを念頭に置いてコードを書くのは大変ですね。そしてそれを忘れてしまうともっと大変です。

また、before_save などの callback 用メソッドに if や unless などのオプションを渡して、skip_callback とは逆に、「callbackを実行したい時だけなんらかの処理をする」という仕組みにすることもできます。例えば

class User < ActiveRecord::Base
  before_save :notify_friends, if: :enable_notification

  attr_reader :enable_notification

  def use_notification
    @enable_notification = true
  end
end

のようにして、

user.use_noticication
user.save

のようにしたときだけ通知を飛ばすような仕組みです。これは一見うまいやり方に見えます。実際、アプリケーションコードが少ない時はこれでもうまく回ると思います。しかしアプリケーションコードが肥大してくると、大量の callback とその条件分岐のためのコードによりモデルが太ってきてしまいます。

さらに、上記の例だと条件が単純なため特に問題はないですが、複雑な条件を満たした時のみ callback を実行したいようなケースもありますね。その条件に対して、さらに callback 実行をオンオフするための条件式を追加するのはなるべくやりたくない感じです。

validation の辛さ

次に、User モデルのユニットテストを書いてみましょう。User を save したときに、ちゃんと User#notify_friends が実行されるかをテストしてみます。素直に書くとこんな感じでしょうか。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#notify_friends' do
    it 'save したときに、User#notify_friends が実行されていること' do
      user = User.new
      expect(user).to receive(:notify_friends)
      user.save
    end
  end
end

しかしこれだとうまくテストが通りません。なぜでしょうか。

validate :should_have_hobbies があるので、関連する hobbies がないと validation が通らず、 callback が実行されないからですね。次のように Hobby を作り、User と関連付けさせれば通ります。

hobby = Hobby.create!(name: 'ジョギング')
user = User.new
user.likes.create!(hobby: hobby)
user.save

しかし、User を保存するテストコード全てに、このようなコードを追加していくのは大変です。factory_girl などの fixture replacement を使うことで大変さをいくらか減らせますが、そうすると今度は factory_girl の定義自体の複雑さが増して辛いことになったりします。

ではどうするとよい?

ここまで、 callback や validation をモデルにそのまま書くことにより、それらを必要としない場合に複雑さをもたらす例を見てきました。この複雑さを解消するにはどのようにするべきでしょうか?

それには継承を使います。具体的なコード例として、先述の User モデルを書きなおしてみます。

class User < ActiveRecord::Base
  has_many :friends, through: :friendships
  has_many :friendships
  has_many :hobbies, through: :likes
  has_many :likes
end
class User::AttributeUpdator < User
  before_save :notify_friends
  validate :should_have_hobbies

  private

  def notify_friends
    # 友達に自分自身の変更を通知するコード
  end

  def should_have_hobbies
    errors[:base] << '好きな趣味を登録してください。' if hobbies.count == 0
  end
end

通常の User と、何らかの属性を変更するとき用に使う User::AttributeUpdator というクラスに分け、User::AttributeUpdator は User を継承するようにしました。User::AttributeUpdator は User のサブクラスなので、当然 User 側で定義している has_many の関連も使えます。

これにより、他の友だちに通知させたいときは User::AttributeUpdator を利用し、そうでないときは User を使うという形で使い分けをすることができます。

必要なときに必要な分だけの validation や callback を設定することができ、さらにそれぞれの処理をサブクラスに移すことで User モデルがスリムになりました。

やりましたね!…と言いたいところですが、いくつか注意事項があります。

継承を利用する場合の注意点

form_forurl_for に ActiveRecord のオブジェクトを引数として渡した時、URLの組み立てに model_name が使われます。そして User::AttributeUpdator.new.model_name.to_s #=> 'User::AttributeUpdator' なので、User::AttributeUpdator オブジェクトを渡した時は意図した URL が作られないことになります。

また、User モデルが STI を利用していたとしましょう。その場合 User::AttributeUpdator は STI とは違うので、 type カラムに入れたくないですね。

これらを解決するために、active_type という gem を使います。

普通に Gemfile に入れて bundle install して、User::AttributeUpdator の定義箇所を次のようにするとうまく動きます。

class User::AttributeUpdator < ActiveType::Record[User]
 #...
end

ちょっと見慣れない書き方ですね。やっていることはそれほど複雑ではないので、気になる人は独自の gem を作ってしまってもいいかもしれません*2

クラスの変更

ログインが必要なアプリーションを作る時、大抵次のように current_user のようなメソッドを定義することになると思います。

def current_user
  @current_user ||= User.find(session[:user_id])
end

ログインユーザが自分の情報を変更するケースを考えてみましょう。この場合は User::AttributeUpdator を利用するのでしたね。しかし current_user は User クラスのオブジェクトを返してきます。困りました。

そこで、User オブジェクトを User::AttributeUpdator オブジェクトに変換します。

先ほど紹介した active_type が変換用のメソッドを用意してくれています。

user = ActiveType.cast(current_user, User::AttributeUpdator)
user.update(params[:user])

おわりに

継承を使うことにより、callback や validation を分割し、必要なときのみ使えるようにすることができました。このテクニックを実際に仕事のコードとして使ってみましたが、想定通りに整理されたコードを作ることが出来てなかなかよい感じです。

しかし、このテクニック自体はあまり認知されておらず、知らない人が見ると面食らう可能性があるのでもうちょっと普及して欲しいところです。

また、ここで紹介したよりももっと良い方法があるのではないか?という気持ちもあります。もし、もっといい方法知ってるぜ!という方がいたらブログのコメントなりTwitterでメンションなりいただけると嬉しいです (\( ⁰⊖⁰)/)

*1:パーフェクト Ruby on Rails でも一部触れていますね

*2:作ろうと思ったのですがなかなか時間取れないのでした