おもしろ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:作ろうと思ったのですがなかなか時間取れないのでした

iphone のカメラを修理した

iphone6 plus で写真を撮るときに、フォーカスが合わずにぼやけた写真しか撮れなくなってしまいました。

ググるとどうやらハードウェアの不具合の模様。

iPhone 6 Plusのカメラのピントが合わない不具合が発生 - 非天マザー by B-CHAN

ジーニアスバーに持って行こうと思って調べるも、予約可能な日時を考えると一週間ほど待つことになりそうなので、宅配便を使った修理サービスを使用してみることにしました。apple のことだから代替機とかも用意してくれるのではと勝手に思い込んでいたのですが、普通に iphone を持って行かれてしまい、しばらく電話なし生活を余儀なくされました。

普段ほとんど電話を使うことはないので基本的には問題ないのですが、一点困ることがありました。僕の家はインターフォンがないので、宅配のものを受け取るのに電話が必須だったのです…><

つまり iphone を受け取るのに iphone が必要という詰んだ状況に。困った。

部室にあった検証用のSIMフリーのAndroid端末をお借りして、iphone の SIM を入れればなんとかなる…と思いきや、SIMのサイズが合わない。

そこで電気屋に行ってSIMサイズを変更するアダプタ購入した*1ら電話を使うことができるようになりました。

ググったらどうもiphoneのSIMはアダプタを使っても、端末によって使えたり使えなかったりするようなので、今回電話が使えたのは運が良かったようです*2

というわけでなんとかiphoneを修理することができました。どうやらググると代替機を用意してくれるプランもあったようです。次回はこれを使うか、おとなしくジーニアスバーを利用したいと思います…。

Apple - サポート - 修理サービス Q&A センター

*1:新宿の大きい電気屋を二軒まわりました。SIMアダプタの在庫は少ないようですね…

*2:ただ、インターネットは使えませんでした。

nested attributes なレコードを、特定の属性が空の時に削除する

nested attributes なレコードを削除したい場合、accepts_nested_attributes_forallow_destroy: true オプションを渡すと削除可能になります。削除するには、対象となる対象に { _destroy: 1 } のようなパラメータを渡します。

これを踏まえて素直にフォームを作ろうとすると、削除用のチェックボックスをつける事になるでしょう。しかし次のようなフォームにチェックボックスをつけると、ユーザにとってわかりづらいUIになってしまいます。単純にテキストフィールドを空にして更新したらレコードが削除されて欲しい。

f:id:willnet:20150725173208p:plain

そこで次のようにします。

class User < ActiveRecord::Base
  accepts_nested_attributes_for :family_members,
                                reject_if: :reject_family_member,
                                allow_destroy: true

  def reject_family_member(attributes)
    exists = attributes[:id].present?
    empty = attributes[:email].blank?
    attributes.merge!(_destroy: 1) if exists && empty
    !exists && empty
  end
end

reject_if の手続きの中で、レコードとして保存済みでかつメールアドレスが空のものに { _destroy: 1 } を追加しています。これでチェックボックスなしで nested attributes なレコードを削除することが出来ました。

参考

ruby on rails - Destroy on blank nested attribute - Stack Overflow

ActiveJob はまだちょっと使うには早いかも

Rails 4.2 から導入された ActiveJob は、sidekiq や resque などのバックグラウンドジョブ系 gem を、同じ利用方法で扱えるようにしてくれます。

これは便利だなーと思い、sidekiq を ActiveJob を通じて使ってみたのですが、しばらく使った後に「まだ本格的に使うのは早いかも」と感じました。

リトライ機能が貧弱

sidekiq は、ジョブが失敗した時にリトライする機能があります。失敗するたびに次にリトライする間隔が伸び、一定回数失敗したら完全に失敗として扱われます。リトライ間隔や、完全に失敗になるまでの回数はもちろん変更可能です。

Web上でリトライしているジョブや完全に失敗したジョブを確認することもできます。

ActiveJob を使うと、この細やかなリトライ機能を失うことになります。例えば、次のように例外を拾って再度キューにジョブを入れることはできますが、リトライ間隔や最大のリトライ回数などはサポートしていません。この場合は無限にリトライすることになってしまいますね。

class SiteScrapperJob < ActiveJob::Base
  rescue_from(ErrorLoadingSite) do
    retry_job queue: :low_priority
  end

  def perform(*args)
    raise ErrorLoadingSite if cannot scrape
  end
end

リトライをサポートしてくれる gem

gocardless/activejob-retry という gem があります。これを使えば問題は解決しそうですが、READMEを読むと

This is an alpha library in active development, so the API may change.

との事なので、まだ実践で使うには早いのかなと思います。また、仮に alpha でなくなったとしても、ActiveJobの性質上、sidekiq の WebUI でリトライしているジョブの数を確認したりはできないはず。

まとめ

ActiveJob を使うと、様々なバックグラウンドジョブ系の gem を意識せず同じ使い方で使えるのがメリットだと思います。しかし1つの gem をずっと使うのであれば、その gem 特有の機能が使えなくなるデメリットが大きいように思えます。個人的には、今の段階ではなるべく ActiveJob は避けていくのがよいのかなと思います*1

*1:deliver_later くらいなら問題ないと思います

色彩検定2級を受けてきた

6月28日(日)に色彩検定2級の試験を受けてきました。

色彩検定協会/カラーコーディネーター

webサービスを作るときに、どんな色を使ったらキマるのか全然わからず適当に勘で決めていたのですが、色彩検定の勉強をする中である程度のセオリーをつかめた気がします。

まず去年受けた3級の試験勉強を通じて、色には色相とトーン(彩度、明度)があり、それぞれの種類と人に与える印象の違いについて学びました。

これがわかると、例えばビジネス関連のサービスを作りたかったら、色相は誠実な印象を与える青で、トーンは落ち着いた印象のライトグレイッシュを使おうか…などと、論理的に色を絞り込むことができます。今書いたような例がぴったりハマるようになるには経験が必要ですが、勘で決めていた頃よりはだいぶ前進できた気がします。

2級では、複数の色をどのように組み合わせると調和して見えやすいか学びました。例えば同系のトーンの組み合わせだと調和するとか。webサービスを作るには複数色必要なので、2級の範囲までは学んで損はないように思います。色とかどうやって決めたらいいのという人にはオススメです。

色彩検定の範囲には、色の分類の仕方などのwebデザインに直接使える項目以外にも、目の構造とか光の波長についてとかファッション、インテリアなどの項目もあります。僕は雑学を学ぶつもりでひと通りやりましたが、無駄なことをしたくない人は、色の分類のところだけ拾い読みするのもいいんじゃないかと思います。

勉強したいけどモチベーションが続かない!という人は毎月やっているデザインビギナーズのミートアップに来るとよいと思います ;-)

プロフィールサイトを作った

先日ブログをはてなに戻したのですが、はてなブログはいわゆる naked ドメインに対応していないため、ドメインを willnet.in から blog.willnet.in に変更しました。

結果として willnet.in が空いてしまったので、プロフィールっぽい感じのページとして利用することにしました。

willnet.in

各エントリのリダイレクトをする必要があったので、sinatra で簡単に実装。heroku に置いてサーバは Passenger にしています。

willnet/willnet_in

これでようやく落ち着いてブログ書ける環境になったかな…

はてなに出戻りました

3年ほど Lokka + heroku でブログを書いていましたが、この度はてなに戻ることにしました。

もともと Lokka を使おうとしたのは、

  1. markdown が使いたかった
  2. 自分でカスタマイズしたかった

という理由からでした。

しかし

  • 1 については今のはてなブログでは普通に markdown が使える
  • 2 については、今は他のプロダクトを複数持っており、そっちを優先したいので、ブログについてはなるべくカスタマイズせずに済ませたくなってしまった

というわけで出戻ることにしたのでした。

以前の willnet.in で書いていた記事はインポートしてきましたが、URLが変わってしまったため以前の記事にブックマークをつけていた場合はリンク切れになってしまっています*1。追記: リダイレクトするように修正しました。

最近は昔ほどエントリを書けていませんが、数は少なくてもなるべく質の高いものを書いていきたいなと思っております。これからもご愛顧のほどよろしくお願いしますm(__)m

*1:これがあったためなかなかブログの引っ越しに踏み切れなかった