October 27, 2007 by alex
We recently introduced a new feature in autoki called the social feed. It’s basically a yellow box displaying any events on the platform relevant to the current user, like a friend has posted a new photo, or a new interesting car was uploaded. The data model behind this is pretty straightforward, we have a FeedEvent class and all kinds of subclasses, e.g. a MessageReceivedEvent. Each event belongs to a user and an event source, in this example the user would be the user who received the message and the event source would be the message itself. For each user, we simply display all the events that belong to him or her.
Now the question was this: How do we create these events? The most straightforward way would probably have been to create them in the models, so the Message model would have an after_create callback that created the event. What we didn’t like about this solution was that we would put a whole bunch of logic into the models that didn’t really belong there. Why would a Message care if there was some kind of event feed? Plus these events would be all around in our unit tests and make the bloated and probably sloooow (again). So we wanted to use the observer pattern to remove the creation of the event from the models.
For observers in a Rails app you basically have two choices waiting there for you: the ruby Observable mixin and the ActiveRecord::Observer class. We didn’t have much time (as usual) and only took a very short look at both and quickly decided to go with the ruby Observable. ARObservers seemed to only allow the usual before/after create/update/save/destroy callbacks and looked much more heavyweight than the tiny Observable module. So we did this: (sorry new example, this time with comments.)
def test_create_comment_creates_event
myuser = create_logged_in_user
user = create_user
FeedEvent.delete_all
post "comments/create", :comment => {:content => 'test', :comment_owner_id => user.id, :comment_owner_type => 'User'}
assert_equal(1, FeedEvent.count)
end
def test_create_attaches_observer
user = new_logged_in_user
c = new_challenge
post :create, :comment => {:content => 'test', :comment_owner_id => c.id, :comment_owner_type => 'Challenge'}
assert_equal(1, assigns(:comment).count_observers)
end
def create
@comment = Comment.new params[:comment].merge(:user_id => logged_in_user_id)
@comment.add_observer CommentObserver.new
@comment.save
end
(using mocha)
def test_create_notifies_observer
user = new_user
observer = stub
comment = Comment.new :comment_owner => user, :content => 'x'*20, :user => new_user
observer.expects(:update).with(:create, comment)
comment.add_observer observer
comment.save!
end
require 'observer'
class Comment
include Observable
after_create :notify_comment_observer
...
private
def notify_comment_observer
changed
notify_observers :create, self
end
end
def test_update_creates_create_event
comment_observer = CommentObserver.new
comment = new_comment
comment.receiver = new_user
CommentCreateEvent.expects(:create!).with(:user => comment.receiver, :source => comment)
comment_observer.update :create, comment
end
class CommentObserver
def update(event_name, comment)
CommentCreateEvent.create! :user => comment.receiver, :source => comment unless comment.user == comment.receiver || comment.receiver.blank?
end
end
Wow. That’s a whole lot of code for a tiny little yellow box with one sentence in it. And it’s not even complete because you have to handle deletion of the comments and some other stuff as well. (no event if user comments on his own objects).
Anyway, for some reason we implemented this for 8 types of events or so: Integration test, attach observer in controller, call changed
and notify_observers
method in model, create event in observer. It was a real pain because we were implementing almost the same thing again and again. Especially attaching the observer in the controller seemed too much work. We thought about doing some meta ruby magic to be able to do the same as rails does with cache sweepers. Instead of attaching the sweeper to the model directly you simply declare that you want this cache sweeper to be active in this action:
class MyController < ApplicationController
cache_sweeper :comments_sweeper, :only => :create
end
It didn’t really work out because we couldn’t figure out the right magic to do the right things when the model was loaded or created without letting the model do it, which would make the whole use of observers pointless.
Finally after a couple of days we took a closer look at the ARObservers. They don’t require you to add them to a model instance every time you want to use them. All you have to do in order to use them is to configure them in your environment:
config.active_record.observers = :message_observer
You then derive you observer class from ActiveRecord::Observer and from then on get all your before/after create/update/save/destroy events for free, delivered right to your observer method with the corresponding name:
class MessageObserver < ActiveRecord::Observer
def after_create(message)
MessageReceivedEvent.create! :user => message.receiver, :source => message
end
end
Now we still had the problem that we wanted custom events, e.g. an after_read event. After some digging deep down in the rails sources we found out how:
class Message
def read
self.mark_as_read!
<strong>callback :after_read</strong>
end
end
That’s it. Now we implement an after_read method in the observer and we were done.
Any idea how the rails guys got the observers attached to every model instance in the app automagically? Well, they attached the observer to the class, not the instance. Sounds like a brilliant idea doesn’t it? But wait. We said one big reason for us to use the observers was that we didn’t want our unit tests to be concerned with this. And now the observers were attached to the classes and all our tests were carrying them as well. Our first solution was to move the observer configuration from environment.rb to the development.rb/production.rb files but this only solved one problem while creating two others: We now had the observer configuration in multiple places and, more importantly, our integration tests didn’t have the observers as well, hence, were now failing.
We ended up doing this: moved the observer configuration back into environment.rb, removed the observers from the model classes before running the unit tests, attached them back before running the integration tests:
# test_helper.rb
# remove all activerecord observers
Dir.glob File.join(RAILS_ROOT, 'app', 'observers', '*.rb') do |file|
File.basename(file, '_observer.rb').camelize.constantize.delete_observers
end
(We have a separate directory for our obserers in app/obserers)
For our integration tests we created a new file integration_test_helper.rb which we now require instead of the test_helper.rb:
require "#{File.dirname(__FILE__)}/test_helper"
# add observers in case they have been removed by unit tests
Dir.glob File.join(RAILS_ROOT, 'app', 'observers', '*.rb') do |file|
clazz = File.basename(file, '_observer.rb').camelize.constantize
clazz.delete_observers
observer_clazz = File.basename(file, '.rb').camelize.constantize
observer = observer_clazz.instance if observer_clazz.respond_to?(:instance)
clazz.add_observer observer if observer.is_a? ActiveRecord::Observer
end
With this setup we now have working unit and integration tests. No need for controller tests and implementation, model tests and implementation only if we have a custom event (such as after_read). What’s left are integration tests and a unit test and implementation of the observer, which is mostly trivial:
class MessageObserverTest < Test::Unit::TestCase
def setup
@observer = MessageObserver.instance
@message = new_message
end
def test_create_creates_message_received_event
MessageReceivedEvent.expects(:create!).with(:user => @message.receiver, :source => @message)
@observer.after_create(@message)
end
end