Monday, March 27, 2006

Separation Of Concerns In Ruby

I once worked in a Java project where cross-cutting concerns where a big issue. One of the problems was logging. Error handling was another. Logging and error handling code was sprinkled throughout 15 MB source code. Fixing it was no picnic.

Today, I once again had reason to reflect on how to separate cross-cutting concerns. This time in Ruby. For example, if I have a Ruby class like this:


class TemperatureConverter
def celsius_to_fahrenheit(c)
9.0 / 5 * c + 32
end
def fahrenheit_to_celsius(f)
5.0 / 9 * (f - 32)
end
end


it would be very nice if I could add a cross-cutting concern without having to modify the original code. I would like make the methods in the class loggable by opening the class and declaring that I want logging, like this:

class TemperatureConverter
loggable_method :celsius_to_fahrenheit, :debug
loggable_method :fahrenheit_to_celsius, :debug
end

How do I do that? I can add loggable_method as an instance method to Object. Object is the parent class of all other classes. With Ruby, thought and action is one:

require 'logger'
class Object
def loggable_method(method_name, level)
self.class_eval(wrap_loggable_method(method_name, level))
end

private

def wrap_loggable_method(method_name, level)
new_method_name = "original_#{method_name.to_s}"
alias_method new_method_name, method_name
<<-"END_METHOD"
def #{method_name}(*args)
begin
result = #{new_method_name}(*args)
logger.#{level} {"Called #{method_name}"}
result
rescue logger.error {$!}
raise
end
end
END_METHOD
end

def logger()
@logger ||= Logger.new('errors.log')
end
end


loggable_method uses the method class_eval to evaluate a string in the context of self. The string is a method definition generated by wrap_loggable_method.

wrap_loggable_method first renames the original method, then generates a method definition that calls the original method, logs the call, and then returns the return value from the original method.

If there is an error, it is rescued, an error message is logged, and the error is raised again.

This is pretty neat, because what we have here is a method of adding any type of cross-cutting concerns to any method in any class, with very little work, and without modifying the original class.

The big deal here is that doing this in Ruby is no big deal. In Java, I would probably use a framework like Spring to do something like this. I would write an XML configuration file to wire everything together. This can quickly get complex, and I have to preplan for it, making sure that I can program against interfaces, so that Spring (or whatever) can generate proxy classes.

With Ruby, the whole thing is much simpler. I need no framework, there is no configuration file, and I can add cross-cutting concerns to any class, even if I have not written it myself.

In the end, it means Ruby has an edge in productivity here, not just in writing the application, but throughout the entire lifecycle. The benefits to maintenance can be several times more valuable than the productivity gains when creating the application.

1 comment:

Justin said...

Given the nature of Ruby's open classes, I think this is a common pattern when you really get deep into Ruby. For example, I'm working on a little domain-specific language (DSL) at my job, and I've noticed I have a set of code right at the top of the library the modifies core classes to make them a little friendlier for me.

For example, I needed keys to come out of hashes in a predictable order. I didnt care WHAT order they came out in, just as long as it was always the same.

I messed around with indexing the hash, and tracking order, etc but it was too painful. Then I realized I could just override Hash.each and get sorted keys that way:

class Hash
def each
self.keys.sort.each { |k|
yield k, self[k]
}
end
end

I've also found some odd ommissions in the core libraries, like certain base objects don't implement <=> very well (I think Symbol doesn't like being compared to a String this way). Well, I can just override that behavior if I need it.

Of course, I am seriously abusing the Ruby base classes in many instances, and that would be bad if this was a framework. But since it's my own little world, I can get away with it.

My point is, though, that Ruby really lets you build up a toolbox as you need it, when you need it.