Thursday, October 15, 2009

Sharing Model Code via Modules

Recently, I worked on an embedded Rails application where there were two models representing network devices.  The models were quite different, but nonetheless, both had host and name fields, and there was some shared functionality.  Here's an example:

class NetworkVideoDevice < ActiveRecord::Base
  require 'ping'


  def responds_to_ping?
    Ping.pingecho host, 1.0
  end

  has_many :live_streams
 
  validates_uniqueness_of :host

  validates_presence_of   :name

  named_scope :ordered_by_host, {:order => "host"}
end

class NetworkAudioDevice < ActiveRecord::Base
  require 'ping'

  def responds_to_ping?
    Ping.pingecho host, 1.0
  end

  has_many :live_streams
 
  validates_uniqueness_of :host

  validates_presence_of   :name

  named_scope :ordered_by_host, {:order => "host"}
end

At this point, developers may be aware of the code smells.  Maintaining the same functionality in two places is error-prone, hard to debug, and puts an unnecessary burden on developers.  In the pre-Rails 2.0 days, I saw many developers using Single Table Inheritence to share functionality between models.  As Rails developers' familiarity with Ruby increased, though, I've noticed developers taking advantage of Ruby's Modules for solutions to problems like this.

First, let's factor out the common method, responds_to_ping? into a module.  Since this module will only be used by Active Record models, I'm going to put it directly in the app/models directory so I don't even have to require it anywhere.  Now, the code looks like this:

# the newly-created module:
module NetworkDevice
  require 'ping'

  def responds_to_ping?
    Ping.pingecho host, 1.0
  end
end

class NetworkVideoDevice < ActiveRecord::Base
  include NetworkDevice

  has_many :live_streams
 
  validates_uniqueness_of :host

  validates_presence_of   :name

  named_scope :ordered_by_host, {:order => "host"}
end

class NetworkAudioDevice < ActiveRecord::Base
  include NetworkDevice

  has_many :live_streams
 
  validates_uniqueness_of :host

  validates_presence_of   :name

  named_scope :ordered_by_host, {:order => "host"}
end

Now, if we want to change that ping timeout from one second to two seconds, we only need to change it in one place.

This is a good start, but we can take our refactoring even further. 


Sharing ActiveRecord Functionality via included and class_eval

We also do the same validation on some fields, and we have the same named_scope call in both models.  Let's stick those in the module, too!  Now, NetworkDevice looks like this:

module NetworkDevice < ActiveRecord::Base
  require 'ping'

  def responds_to_ping?
    Ping.pingecho host, 1.0
  end

  has_many :live_streams
 
  validates_uniqueness_of :host

  validates_presence_of   :name

  named_scope :ordered_by_host, {:order => "host"}
end

But wait!  Try to start your application, and it barfs!  What happened?
NoMethodError: undefined method `has_many' for NetworkDevice:Module

Our NetworkDevice module doesn't know anything about ActiveRecord methods like has_many, so when Ruby tries to evaluate it outside the context of an AR::Base-descended class, it doesn't work. 

The solution? use Module's included method and Class's class_eval method.

included, a class method available to modules, lets us defer funcionality until the module is included somewhere.  We simply tell Ruby to evaluate some code in the context of the including class, which will descend from ActiveRecord::Base. 

class_eval lets us define or call class methods on the receiver on the fly.  In this case, we'll want to call some ActiveRecord::Base class methods, like named_scope.

Sound confusing?  Let's see the new code in action:

module NetworkDevice < ActiveRecord::Base
  require 'ping'

  def responds_to_ping?
    Ping.pingecho host, 1.0
  end

  # run this in the context of the including class:
  def self.included(base_class)
    base_class.class_eval do
      has_many :live_streams
 
      validates_uniqueness_of :host
      validates_presence_of   :name

      named_scope :ordered_by_host, {:order => "host"}
    end
  end
end

There!  The errors are gone, and all of our general network device code is in one place.  Now, our code is easier to maintain, is less susceptible to bugs, and we'll probably even save our clients/employers some money in the long run.

No comments: