Tuesday, March 18, 2008

Ruby: Isolate Dynamic Receptor

Isolate Dynamic Receptor

A class utilizing method_missing has become painful to alter

Introduce a new class and move the method_missing logic to that class.

Motivation

As I previously mentioned, objects that use method_missing often raise NoMethodError errors unexpectedly, or worse you get no more information than: stack level too deep (SystemStackError).

Despite the added complexity, method_missing is a powerful tool that needs to be used when the interface of a class can not be predetermined. On those occasions I like to use Isolate Dynamic Receptor to limit the behavior of an object that also relies on method_missing.

The ActiveRecord::Base (AR::B) class defines method_missing to handle dynamic find messages. The implementation of method_missing allows you to send find messages that use attributes of a class as limiting conditions for the results that will be returned by the dynamic find messages. For example, given a Person subclass of AR::B that has both a first name and a ssn attribute it's possible to send the messages Person.find_by_first_name, Person.find_by_ssn, and Person.find_by_first_name_and_ssn.

It's possible, but not realistic to dynamically define methods for all possible combinations of the attributes of an AR::B subclass. Instead utilizing method_missing is a good solution; however, by defining method_missing on the AR::B class itself the complexity of the class is increased significantly. AR::B would benefit from a maintainability perspective if instead the dynamic finder logic were defined on a class whose single responsibility was to handle dynamic find messages. For example, the above Person class could support find with the following syntax: Person.find.by_first_name, Person.find.by_ssn, or Person.find.by_first_name_and_ssn

Note: very often it's possible to know all valid method calls ahead of time, in which case I prefer Replace Dynamic Receptor with Dynamically Define Method.

Mechanics
  • Create a new class whose sole responsibility is to handle the dynamic method calls.
  • Copy the logic from method_missing on the original class to the method_missing of the focused class.
  • Change all client code that previously called the dynamic methods on the original object.
  • Remove the method_missing from the original object.
  • Test
Example

Here's a recorder class that records all calls to method_missing.

class Recorder
instance_methods.each do |meth|
undef_method meth unless meth =~ /^(__|inspect)/
end

def messages
@messages ||= []
end

def method_missing(sym, *args)
messages << [sym, args]
self
end
end

The recorder class may need additional behavior such as the ability to play back all the messages on an object and the ability to represent all the calls as strings.

class Recorder
def play_for(obj)
messages.inject(obj) do |result, message|
result.send message.first, *message.last
end
end

def to_s
messages.inject([]) do |result, message|
result << "#{message.first}(args: #{message.last.inspect})"
end.join(".")
end
end

As the behavior of Recorder grows it becomes harder to understand what messages are dynamically handled and what messages are actually explicitly defined. By design the functionality of method_missing should handle any unknown message, but how do you know if you've broken something by adding a explicitly defined method?

The solution to this problem is to introduce an additional class that has the single responsibility of handling the dynamic method calls. In this case we have a class Recorder that handles recording unknown messages as well as playing back the messages or printing them. To reduce complexity we will introduce the MessageCollector class that handles the method_missing calls.

class MessageCollector
instance_methods.each do |meth|
undef_method meth unless meth =~ /^(__|inspect)/
end

def messages
@messages ||= []
end

def method_missing(sym, *args)
messages << [sym, args]
self
end
end

The record method of Recorder will create a new instance of the MessageCollector class and each additional chained call will be recorded. The play back and printing capabilities will remain on the Recorder object.

class Recorder
def play_for(obj)
@message_collector.messages.inject(obj) do |result, message|
result.send message.first, *message.last
end
end

def record
@message_collector ||= MessageCollector.new
end

def to_s
@message_collector.messages.inject([]) do |result, message|
result << "#{message.first}(args: #{message.last.inspect})"
end.join(".")
end
end

1 comment:

  1. I wrote a gem which implements this a few weeks back. You can get it here (message-recorder)

    ReplyDelete

Note: Only a member of this blog may post a comment.