This is an alternative implementation of the observer pattern. As you may know, Ruby (and Rails/ActiveRecord, and DataMapper) already have an implementation of it. This implementation is a variation of the pattern, so it is not supposed to supersede the existing implementations, but "complete" them for the specific use-cases addressed.
The Observer Pattern clearly defines two roles: the observer and the observed. The observer registers itself by the observed. The observed decides when (for which "actions") to notify the observer. The observer knows what to do when notified.
What's the limitation? The observed has to know when and whom to notify. The observer has to know what to do. For this logic to be implemented for two other separate entities, behaviour has to be copied from one place to the other. So, why not delegate this information (to whom, when, behaviour) to a third role, the notifier?
Great library, which works great for POROs, but not for models (specifically ActiveRecord, which overwrites a lot of its functionality)
Observers there are external entities which observe models. They don't exactly work as links between two models, just extract functionality (callbacks) which would otherwise flood the model. For that, they're great. For the rest, not really.
Currently doesn't support callbacks on collections (even though it supports observation for any method, cool!)
Add this line to your application's Gemfile:
gem 'association_observers'
And then execute:
$ bundle
Or install it yourself as:
$ gem install association_observers
Here is a functional example:
require 'logger'
LOGGER = Logger.new(STDOUT)
# Notifiers
class HaveSliceNotifier < Notifier::Base
def action(cake, kid)
cake.update_column(:slices, cake.slices - 1)
cake.destroy if cake.slices == 0
kid.increment(:slices).save
end
end
class BustKidsAssNotifier < Notifier::Base
module ObserverMethods
def bust_kids_ass!
LOGGER.info("Slam!!")
end
end
module ObservableMethods
def is_for_grandpa?
true # it is always for grandpa
end
end
def conditions(cake, mom)
cake.is_for_grandpa?
end
def action(cake, mom)
mom.bust_kids_ass!
end
end
class TellKidHesFatNotifier < Notifier::Base
module ObservableMethods
def cry!
LOGGER.info(":'(")
end
def throw_slices_away!
update_column(:slices, 0)
end
end
def conditions(kid, mom)
kid.slices > 20
end
def action(kid, mom)
LOGGER.info("Hey Fatty, BEEFCAKE!!!!!")
kid.cry!
kid.throw_slices_away!
end
end
# TABLES
ActiveRecord::Schema.define do
create_table :cakes, :force => true do |t|
t.integer :slices
t.integer :mom_id
end
create_table :kids, :force => true do |t|
t.integer :mom_id
t.integer :slices, :default => 0
end
create_table :moms, :force => true do |t|
end
end
# ENTITIES
class Cake < ActiveRecord::Base
def self.default_slices ; 8 ; end
belongs_to :mom
has_one :kid, :through => :mom
before_create do |record|
record.slices ||= record.class.default_slices
end
end
class Mom < ActiveRecord::Base
has_one :kid
has_many :cakes
end
class Kid < ActiveRecord::Base
belongs_to :mom
has_many :cakes, :through => :mom
observes :cakes, :notifier => :have_slice, :on => :create
end
class Mom < ActiveRecord::Base
observes :cakes, :on => :destroy, :notifier => :bust_kids_ass
observes :kid, :on => :update, :notifier => :tell_kid_hes_fat
end
You can find this under the examples.
The #observes method for the models accepts as argument the association being observed and a set of options:
- as : if the association is polymorphic, then this parameter has to be filled with the name by which the observer is recognized by the observed
- on : accepts one event or a set of events to observe (:create, :update, :save, :destroy) for the observed association (default: :save)
- notifier(s?): accepts one notifier or a set of notifiers that will handle the events; notifier name has to match the name of the notifier being defined by yourself; if you don't set any notifier, then the events on the observed will only propagate to the observers of the observer (if it is being observed)
The other important task is to define your own notifiers. First, where. For Rails, the gem expects a "notifiers" folder to exist under the "app" folder. Everywhere else, it's entirely up to you where you should define them. Second, how. Your notifier must inherit from Notifier::Base. This class provides you with hook methods you should/could redefine your way:
Methods to overwrite:
- action(observable, observer) : where you should define the behaviour which results of the observation of an event
- conditions(observable, observer) : checks whether the action should be run (defaults to true if not overwritten)
Additionally, you can optimize the behaviour for collection associations. Let's say a brand has many products which know its owner, and when the brand changes from owner, you want to update a certain flag on products. Per default, the action will be run individually for every product. If we are talking about DB statements and 100 products, it will be 100 sequential statements... That's a drag if you can accomplish that in one DB statement. for that, you can overwrite these two methods:
- notify_many(observable, observers)
- conditions_many(observable, observers)
the observers parameters is a container of not-yet loaded collection associations. Check your ORM's documentation to know what you can do with it and whether you can achieve your result without populating it (there is such a use under the examples).
Purpose of the Notifier is to abstract the behaviour from the Observer relationship between associations. But if you still need to complement/overwrite behaviour from your observer/observable models, you can write it in notifier-specific modules, the ObserverMethods and the ObservableMethods, which will be included in the respective models.
This gem supports the 3 most popular background queue implementations around: Delayed Job, Resque and Sidekiq. If you are using this gem out of Rails, you can do:
AssociationObservers::options[:queue][:engine] = :delayed_job # or :sidekiq, or :resque
if in Rails, you can do it in application.rb:
module YourApp
class Application < Rails::Application
...
config.association_observers.queue.engine = :delayed_job # or :sidekiq, or :resque
...
end
end
and that's it. Why is this important? The notification of observer collections, when you don't rewrite the #notify_many hook method of the notifier, takes the following approach:
- Queries the collection in batches (of 50, by default)
- Iterates over each batch
- Performs the #update hook method on each
It also takes this approach to the propagation. Batch-querying is much better than querying all at once, but you still load everything and perform the various iterations synchronously. If you already use one of the aforementioned background queues, each batch will generate a job which will be handled asynchronously. You also have the control over the batch size. If you want to set a new default batch size, just:
# standard
AssociationObservers::options[:batch_size] = 200 # or 20...
# rails way
module YourApp
class Application < Rails::Application
...
config.association_observers.batch_size = 200 # or 20...
...
end
end
You can also set the queue name (default: "observers" and priority (if using delayed job) on the options, they are just another option under queue.
This gem is tested against the following rubies:
- 1.8.7
- 1.9.2
- 1.9.3
- 2.0.0
- rubinius
- jruby
-
Support for other ORM's (currently supporting ActiveRecord and DataMapper)
-
Action routine definition on the "#observes" declaration (sometimes one does not need the overhead of writing a notifier)
-
Overall spec readability
-
ActiveRecord: Observe method calls (currently only observing model callbacks)
-
DataMapper: Support Many-to-Many collection observation (currently ignoring)
-
Support for ActiveRecord
-
Support for DataMapper
-
Integration with delayed job, resque and sidekiq
The observer models have to be eager-loaded for the observer/observable behaviour to be extended in the respective associations. It is kind of a drag, but a drag the Rails Observers already suffer from (these have to be declared in the application configuration).
If you are auto-loading your models, the same logic from the paragraph above applies. If you are requiring your models, just proceed, this is not your concern :)
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request