In Progress
Unit 1, Lesson 1
In Progress

Observer Variations

Video transcript & code

Back in Episode 21, we took an overgrown controller for a Rails project management application and redesigned it. We identified several implicit model lifecycle events that the controller was handling, and we used the Observer pattern to model those events explicitly. We gave the model the ability to send notifications of lifecycle events to a list of listeners. We separated out the various actions the controller took in response to these events into listener classes. And we rewrote a controller action to tie everything together by registering listeners with the model before updating it.

class Task < ActiveRecord::Base
  around_save :notify_on_save

  def add_listener(listener)
    (@listeners ||= []) << listener
  end

  def notify_listeners(event_name, *args)
    @listeners && @listeners.each do |listener|
      if listener.respond_to?(event_name)
        listener.public_send(event_name, self, *args)
      end
    end
  end

  def notify_on_save
    is_create_save   = !persisted?
    project_changed  = project_id_changed?
    status_changed   = status_changed?
    assignee_changed = assignee_id_changed?
    old_project_id   = project_id_was
    old_status       = status_was
    old_assignee     = User.find_by_id(assignee_id_was)
    yield
    if is_create_save
      notify_listeners(:on_create)
    else
      notify_listeners(:on_project_change, old_project_id, project_id) if project_changed
      notify_listeners(:on_status_change, old_status, status) if status_changed
      notify_listeners(:on_assignment_change, old_assignee, assignee) if assignee_changed
      new_assignee = assignee if assignee_changed
      notify_listeners(:on_update, new_assignee)
    end
  end
end
class PusherTaskListener
  def initialize(socket_id, queue=QC, worker=PusherWorker)
    @socket_id = socket_id
    @worker = worker
    @queue = queue
  end

  def on_create(task)
    push_task(task, 'create_task')
    push_project_update(task.project.id) if task.project
  end

  def on_project_change(task, previous_project_id, new_project_id)
    push_project_update(previous_project_id) if previous_project_id
    push_project_update(new_project_id) if new_project_id
  end

  def on_assignment_change(task, previous_assignee, new_assignee)
    push_task(task, 'create_task', new_assignee) if new_assignee
    push_task(task, 'delete_task', previous_assignee) if previous_assignee
  end

  def on_update(task, new_assignee)
    update_users = task.readers - [new_assignee] if task.project
    push_task(task, 'update_task', update_users)
  end

  # ...details of how notifications are pushed omitted...
end
class TasksController < ApplicationController
  def update
    @task.add_listener TaskPusherListener.new(@socket_id)
    @task.add_listener TaskEmailListener.new(current_user)

    if @task.update_attributes(params[:task])
      respond_to do |format|
        format.json {render 'show', status: :accepted}
      end
    else
      respond_with @task do |format|
        format.json {render @task.errors.messages, status: :unprocessable_entity}
      end
    end
  end
  # ...
end

The advantages here are clear: we created a very clean separation of responsibility. The model is responsible for knowing when significant events happen to it. The controller is responsible for wiring up interested listeners to the model, and then updating it based on user input. And the there are distinct listeners for dealing with email notifications and server push notifications, keeping knowledge about different modes of communication encapsulated.

But this solution is not without its trade-offs. Most significantly, we've lost the ability to simply read down the controller action and see exactly what may happen. We made it easier to understand individual components, at the cost of making it harder to see the big picture of how they fit together.

Today, let's take a look at some ways for bringing parts of the logic back into the controller action, while still benefiting from our improved separation of concerns.

First, some prerequisites. We pull the code for creating and maintaining a list of listeners out of the Task class and into its own ListenerSet class. We can add listeners to a ListenerSet, and then call #notify to cause every listener to be sent a message. If we #notify the ListenerSet with an event name of update, every listener that supports it will be sent the #on_update message.

class ListenerSet
  def add_listener(listener)
    (@listeners ||= []) << listener
  end

  def notify(subject, event_name, *args)
    @listeners && @listeners.each do |listener|
      if listener.respond_to?("on_#{event_name}")
        listener.public_send("on_#{event_name}", subject, *args)
      end
    end
  end
end

class UpdateListener
  def on_update(subject)
    puts "#{subject} has been updated!"
  end
end

ls = ListenerSet.new
subject = "test subject"
ls.add_listener(UpdateListener.new)
ls.notify(subject, :update)
# >> test subject has been updated!

We also add a class GenericListener. This class is instantiated with an event name and a handler for that event in the form of a proc. It implements #method_missing and respond_to_missing? to respond to the message that corresponds to the event it was initialized with. So we can instantiate a GenericListener for an event named "teatime", and it will respond to the #on_teatime message by calling the given handler.

class GenericListener
  def initialize(event_name, handler)
    @event_name = event_name
    @handler    = handler
  end

  def respond_to_missing?(method_name, include_private=false)
    method_name.to_s == "on_#{@event_name}"
  end

  def method_missing(method_name, *args)
    if respond_to_missing?(method_name)
      @handler.call(*args)
    else
      super
    end
  end
end

tea_listener = GenericListener.new(
  :teatime, 
  ->{ puts "Time for tea!" })

tea_listener.on_teatime
# >> Time for tea!

With these helpers in place, let's take a look at the first alternative version of our controller action. We'll simplify things down to their bare essentials by only showing three possible lifecycle events, and omitting the code for rendering a page or redirecting to another URL.

class TasksController < ApplicationController
  def update
    @task.update(params[:task], 
      create: ->(task) do
        push_task('create_task')
        push_project_update(task.project)
      end,
      complete: ->(task) do
        mail_completion_notice(task.assignee)
      end,
      reassign: ->(task, old_user, new_user) do
        push_task('create_task', new_user)
        mail_assignment(new_user)
        push_task('delete_task', old_user)
        mail_assignment_removal(old_user)
        mail_assignment(new_user)
      end)
    # Render, redirect, etc...
  end
end

In this version, we've brought the event handlers back into the update action. We've done this by adding a parameter to the Task#update method, which is a Hash of event listeners. Each key in the hash is the name of an event, and each value is a lambda containing the code to handle that event.

This is fairly east to implement on the model side of things. We define an #update method, which takes a hash of attributes and a hash of callbacks. Unlike in the preceding episode, where there was a set of listeners for the whole Task object, here we instantiate a ListenerSet just for this invocation. We then iterate through the callback hash, creating GenericListeners for each callback and adding them to the listener set.

Once again, we'll simplify things to keep the focus on event listeners. So we won't show any of the logic for actually updating attributes, saving the record, or determining which events have occurred. Instead we'll just have the method trigger each listener in turn.

class Task < ActiveRecord::Base
  # ...
  def update(attributes, callbacks)
    listeners = ListenerSet.new
    callbacks.each do |event_name, handler|
      listeners.add_listener(GenericListener.new(event_name, handler))
    end
    # update and save...
    listeners.notify(self, :create)
    listeners.notify(self, :complete)
    listeners.notify(self, :reassign, user_was, user)
  end
  # ...
end

Using a hash of event names to lambdas is one of the easiest ways to specify a list of method callbacks in-line. Unfortunately, it has some drawbacks. For one thing, the syntax is a little awkward. In Ruby we don't usually have multiline argument lists with complex code inside them. Another potential complaint is that since hash keys are unique, we can only add a single handler for each event.

Let's look at another possible interface to do the same thing.

class TasksController < ApplicationController
  def update
    @task.update(params[:task])
      .on_create do |task|
        push_task('create_task')
        push_project_update(task.project)
      end
      .on_complete do |task|
        mail_completion_notice(task.assignee)
      end
      .on_reassign do |task, old_user, new_user|
        push_task('create_task', new_user)
        mail_assignment(new_user)

        push_task('delete_task', old_user)
        mail_assignment_removal(old_user)
        mail_assignment(new_user)
      end
      .execute
    # Render, redirect, etc...
  end
end

This version uses what is sometimes called a "fluent interface". We call Task#update with just the list of updated attributes. Then we chain a series of message sends onto the return value of #update. Each send takes establishes a new event handler, using a block. Finally, we terminate the chain with a the #execute message.

Implementing this version takes a bit more effort. First, we create a new helper class we call FluentCallbackBuilder. It is initialized with a reference to a ListenerSet, as well as a block which performs the action which the listeners will observe. It implements #method_missing. Each unrecognized method will be treated as an event name, with the "on_" prefix stripped off. The event name and the given block will be used to create a GenericListener, which will then be added to the listener set. Finally, the method returns self so that further calls can be chained onto it.

The only other method in this class is #execute, which executes the block the builder was instantiated with.

class FluentCallbackBuilder
  def initialize(listeners, &action)
    @listeners = listeners
    @action    = action
  end

  def method_missing(method_name, &block)
    event_name = method_name.to_s.sub(/^on_/, '')
    listener   = GenericListener.new(event_name, block)
    @listeners.add_listener(listener)
    self
  end

  def execute
    @action.call
  end
end

Let's change Task#update to use this builder. Once again, we start by instantiating a ListenerSet. This time we pass the listener set to a new FluentCallbackBuilder. We also move the body of the method inside the block passed to the builder. This is what will be run when the builder's #execute method is called. We are careful to make this instantiation of a FluentCallbackBuilder the last thing that happens in the method, so that the builder is returned by the method. This is what enables our controller to chain message sends off of the end of the #update call.

class Task < ActiveRecord::Base
  # ...
  def update(attributes)
    listeners = ListenerSet.new
    FluentCallbackBuilder.new(listeners) do
      # update and save...
      listeners.notify(self, :create)
      listeners.notify(self, :complete)
      listeners.notify(self, :reassign, user_was, user)
    end
  end
  # ...
end

This revision is not as syntactically odd as the hash-of-callbacks version. But one problem with it does have is the necessity for that #execute to be sent in order to terminate the handler chain and trigger the update to be completed. This is unintuitive, and it's easy to forget and leave that message off the end of the chain.

Our last variation on the controller code looks like this:

class TasksController < ApplicationController
  def update
    @task.update(params[:task]) do |update|
      update.on_create do |task|
        push_task('create_task')
        push_project_update(task.project)
      end
      update.on_complete do |task|
        mail_completion_notice(task.assignee)
      end
      update.on_reassign do |task, old_user, new_user|
        push_task('create_task', new_user)
        mail_assignment(new_user)

        push_task('delete_task', old_user)
        mail_assignment_removal(old_user)
        mail_assignment(new_user)
      end
    # Render, redirect, etc...
  end
end

In this version, Task#update takes a block. The block receives an argument which represents the update process which is taking place. Once again we send messages to set event handlers, but this time they are sent to the "update" object. Unlike the fluent interface, there is no special terminator method.

To make this interface work, we once again need a special helper class. This time we'll call it ListenerSetBuilder. Like the FluentCallbackBuilder, it implements #method_missing, and in fact the implementation is identical. But this time around there is no need to keep a reference to an action to be executed when the callback chain is terminated.

class ListenerSetBuilder
  def initialize(listeners)
    @listeners = listeners
  end

  def method_missing(method_name, &block)
    event_name = method_name.to_s.sub(/^on_/, '')
    listener   = GenericListener.new(event_name, block)
    @listeners.add_listener(listener)
  end
end

Back in the Task model, we pass our listener set into a ListenerSetBuilder. Then we yield to the caller, passing the builder object as a block argument. The caller can use the block to specify callbacks, if it so desires, which the builder will add to our listener set. Finally, we perform the work of the method, including notifying listeners of significant events.

class Task < ActiveRecord::Base
  # ...
  def update(attributes)
    listeners = ListenerSet.new
    builder   = ListenerSetBuilder.new(listeners)
    yield(builder) if block_given?
    # update and save
    listeners.notify(self, :create)
    listeners.notify(self, :complete)
    listeners.notify(self, :reassign, user_was, user)
  end
  # ...
end

This version is my personal favorite. It requires a little extra effort to implement over the hash version, but it's flexible, robust, and I think it reads well.

Specifying callbacks inline preserves the separation between "what is happening" and "what to do when it happens", while making it clear to the reader exactly what side effects the message send in question may have. However, it does have one important drawback: unlike our old class-based listeners, it's harder to share these inline callbacks across multiple controller actions or multiple controllers. But we're out of time, so figuring out how to get the best of both worlds—as well as how to make the implementation more generic, so that any model can use it—will have to wait for another episode.

Happy hacking!

Responses