Rails 5におけるActive Record Observerの代替ライブラリを作った

はじめに

最近、Rails 5におけるActive Record Observerの代替ライブラリとして、Everettというものを作りました。

本エントリでは、このgemの紹介と作成のきっかけや工夫した点などを述べたいと思います。

Everettとは

概要

READMEの冒頭にも書いてあるように、EverettはRails 5におけるActive Record Observerの代替を目的としたgemです。
インターフェースはActiveRecord::Observerクラスのそれに可能な限り準拠しているため、通常のユースケースの範囲内なら同じように動作します。*1

例えば、Contactモデルに対するContactObserverは次のように書くことができます。

class ContactObserver < Everett::Observer
  def after_create(contact)
    Rails.logger.info('New contact has been added!')
  end

  def after_destroy(contact)
    Rails.logger.info("Contact with an id of #{contact.id} has been destroyed!")
  end
end

Observerのクラス名を"観測対象のモデル名+Observer"という形式以外にしたい場合、あるいは複数のモデルを観測対象としたい場合には、observeメソッドを使います。

class NotificationsObserver < Everett::Observer
  observe :comment, :like

  def after_create(record)
    # notifiy users of new comment or like
  end
end

このように、継承する親クラス以外はActive Record Observerの時と同じように書けることがわかると思います。

after_{create,update,destroy}_commit

前述の通り、Everettは基本的にはActiveRecord::Observerのインターフェースに準拠しているのですが、一点だけ追加した機能があります。
それは、Rails 5から導入されたafter_{create,update,destroy}_commitコールバックです。

例えば、レコードの新規作成後にメールを送信するObserverは次のように書くことができます。

class CommentObserver < Everett::Observer
  def after_create_commit(comment)
    CommentMailer.notification(comment).deliver_now
  end
end

ActiveRecord::Observerでは、このgemのような工夫をしないと、レコードが特定の状態の場合にのみ呼び出されるafter_commitコールバックを定義することができませんでした。
Everettでは、新しいコールバックのインターフェースを用いてこの問題を解決しています。

作成のきっかけ

とあるRailsアプリケーションを4.2から5.0にアップグレードしようとした際に、rails-observersが原因でbundle updateが通らないという問題に直面したことがきっかけです。
rails-observersのRails 5対応自体はこのプルリクエストで既に実施されていて、マージもされているのですが、一向にリリースされる気配がないという状況になっています。*2

そこで、Gemfileにmasterブランチを指定するのを避けたかったこともあり、この機会にrails-observersを捨てることにしました。
最初に思いついたActive Record Observerの代替案は、次のようにモジュールをincludeする形に書き換えることでした。

# app/observers/foo_observer.rb
module FooObserver
  extend ActiveSupport::Concern

  included do
    after_create do
      # Do something
    end
  end
end

# app/models/foo.rb
class Foo < ApplicationRecord
  include FooObserver
end

こうすることで、今まで通りモデルと直接関係のないロジックを分離することができます。
しかし、この方法ではActive Record Observerに定義されたコールバックの引数への参照をselfを使った形に書き換えなければならないので、移行に少々手間がかかってしまうのが難点でした。

もっと良いやり方がないか模索していたところ、callback objectsを指定する方法*3を知りました。
callback objectsを用いると、先程のコードは次のように書き換えることができます。

# app/observers/foo_observer.rb
class FooObserver
  def after_create(foo)
    # Do something
  end
end

# app/models/foo.rb
class Foo < ApplicationRecord
  after_create FooObserver.new
end

この段階で「もう少し詰めればいい感じになるかも?」と思い、gemとして作ってみようと決めました。

実装方針

「今あるObseverのコードをRails 5でもそのまま使いたい!」というのが一番のモチベーションだったので、次のように方針を定めました。

  • ActiveRecord::Observerクラスにおける観測対象の指定とコールバックの定義に関するインターフェースは変えない
  • 長く使えるものにするため、Active Recordに可能な限り依存しない&手を入れない実装にする
  • Action Controller Sweeperは使っていないので今回のgem化の対象外とする

通常のgem開発とは異なり、利用者向けのインターフェースは既に与えられているため、どう実装すればベストか? を考えることに多くの時間を費やすことになりました。

工夫した点

前述のcallback objectsを指定する方法により、Observerに定義されたコールバックをモデルに登録するコードはすぐに書くことができました。
しかし、Observerと観測対象のモデルの関連をどう管理するかについてはアイデアがなかったので、ここで行き詰まってしまいました。

こういう時は先人の知恵を…とrails-observersのコードをよくよく読んだところ、GoFのObserverパターンに則って実装されていることがわかりました。
そこで、Everettでもこのデザインパターンを参考に実装してみたところ、納得のいく形に仕上げることができました。
具体的には、次のような形になっています。

irb(main):001:0> Foo.new
=> #<Foo id: nil, name: nil, created_at: nil, updated_at: nil>
irb(main):002:0> Everett::Subject[Foo].add_observer(FooObserver.instance)
=> true
irb(main):003:0> Everett::Subject[Foo].add_observer(FooObserver.instance)
=> false
irb(main):004:0> Foo.new(name: "foo")
New instance with name of foo has been initialized.
=> #<Foo id: nil, name: "foo", created_at: nil, updated_at: nil>

あるモデルを引数に指定してEverett::Subject.[]を呼び出すとモデル毎に同一のインスタンスが返り、このインスタンスがObserverを管理しています。
そして、Everett::Subject#add_observerを呼び出すことで、対応するモデルにObserverを登録することができます。
3-4個目の実行結果はそれぞれ、あるモデルに対して同じObserverは一度しか登録できないこと、インスタンスの初期化後に特定のログを出力するObserverが有効になったことを示しています。

おわりに

Everettという名前は、多世界解釈(Many-worlds interpretation)という理論を提唱したHugh Everettにちなんでつけました。
ちょうどgemを作り始めた頃にSTEINS;GATEのアニメを観ていて、中二病感が高まって世界線に関係する事柄を色々調べていたら見つけたのがきっかけです。
この理論の中でObserverというロールが登場することもあり、「ちょうどいいじゃん!」と勢いで採用して今に至ります。

*1:たぶん自分が把握しきれていない機能があると思う

*2:ここらへんの事情に詳しい方はいらっしゃいますか?

*3:コールバックの指定方法についてはhttp://api.rubyonrails.org/classes/ActiveRecord/Callbacks.htmlを参照