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を参照