In our “last episode”:http://avdi.org/devblog/2008/03/31/sustainable-development-in-ruby-part-2-method-injection/ we were augmenting @FMTP::Message@ classes to deal with messages split across multiple packets. As is often the case, fixing one problem revlealed another. What with the unstable weather patterns in Oz – you never know when a spacetime-ripping tornado will appear out of nowhere – our flying monkeys sometimes get blown off course, and arrive out of order. This results in jumbled messages and angry Wicked Witches.
We’ve submitted a revised FMTP RFC to the Flying Monkey Transport Protocol Working Group, but it’s anyone’s guess how long that will take to become an official recommendation. Until then, we’ve taken to embedding another bit of metadata in the message text itself. Messages now look like this:
{{3 OF 5}} .... MESSAGE TEXT ...
In order to work conveniently with these enhanced messages, we need some new accessors on the @FMTP::Message@ class:
- A revised @#end?@ method which uses the new header instead of the now-deprecated “ENDENDEND” token.
- A new @#seq_num@ accessor to tell us which message in a multi-message sequence this is.
- A new @#total_num@ accessor to tell us how many total messages are expected as part of the sequence.
- A modified @#data@ accessor which will return just the message data, minus the message ordering header.
Sounds like it’s time to inject some more methods. But not so fast. I prefer to use the inject method pattern when I only need to add one trivial method. When I need to add or modify more than one method, a few other techniques are better suited. One of the most powerful techniques is delegation.
“Delegation”:http://c2.com/cgi/wiki?DelegationPattern has a long history of use in object-oriented languages. In some languages using it can be quite labor-intensive to implement. In Ruby it is so trivially easy that it’s a little surprising it isn’t used more often.
Here’s an example of a delegate class that implements the requirements above:
require 'delegate' class OrderedMessage < DelegateClass(FMTP::Message) HEADER_PATTERN = /{{(d+) OF (d+)}}/ def seq_num @seq_num ||= matches[1].to_i end def total_num @total_num ||= matches[2].to_i end def data # Implemented this way to demonstrate use of 'super' @data ||= super.gsub(HEADER_PATTERN,'').strip end def end? seq_num == total_num end private def matches HEADER_PATTERN.match(__getobj__.data) end end
We use this class by replacing the line that previously read:
message = extend_message(super)
With this code:
message = OrderedMessage.new(message)
There are a few things worth noting about this approach:
- Using a delegate gives us a safe namespace sandbox to play in. This code defines four methods, a constant, and three instance variables. In particular, note that we define our own
@data
instance variable. If we were monkey patching, or even subclassing, @FMTP:Message@, we would run the risk of inadvertently overriding or overwriting one of the @FMTP::Message@ constants, methods, or instance variables. With delegation, however, we can define pretty much anything we want without having to be concerned with collisions. - Note that @#data@ uses @super@ to delegate to @FMTP::Message@, just as if we were writing a subclass. This is a convenience of using the standard @delegate@ library.
- [NEW] While it’s not really demonstrated in the code above, I should point out that any @FMTP::Message@ methods not explicitly overridden on @OrderedMessage@ will be delegated directly to the wrapped @FMTP::Message@ object.
- An added benefit of using the delegate pattern is that it is very easy to test our additions to @FMTP::Message@ in isolation. Here is the actual RSpec spec I used to develop the code above:
describe "any ordered message", :shared => true do it "should have the correct sequence number" do @it.seq_num.should == @m end it "should report the correct total of messages" do @it.total_num.should == @n end it "should have the correct data" do @it.data.should == @payload end end describe OrderedMessage do def construct_test_data(m, n, data) <<-END {{#{m} OF #{n}}} #{data} END end def make_message @data = construct_test_data(@m, @n, @payload) @base_message = stub("Base", :data => @data) @it = OrderedMessage.new(@base_message) end describe "given message 1 of 3 with data FOO" do before :each do @m = 1 @n = 3 @payload = "FOO" make_message end it_should_behave_like "any ordered message" it "should not be the last message" do # Using '@it.should_not be_end' interacts badly with DelegateClass @it.end?.should_not be_true end end describe "given message 3 of 5 with data BAR" do before :each do @m = 3 @n = 5 @payload = "BAR" make_message end it_should_behave_like "any ordered message" it "should not be the last message" do # Using '@it.should_not be_end' interacts badly with DelegateClass @it.end?.should_not be_true end end describe "given message 4 of 4 with data BAZ" do before :each do @m = 4 @n = 4 @payload = "BAZ" make_message end it_should_behave_like "any ordered message" it "should be the last message" do # Using '@it.should_not be_end' interacts badly with DelegateClass @it.end?.should be_true end end end
Using delegation to extend @FMTP::Message@, we don’t need to instantiate an actual @FMTP::Message@ object in order to test our modifications. All we have to do is stub the methods of @FMTP::Message@ that we actually use in the delegate. This can be a real win when we are working with a third-party library which has extensive and/or poorly-documented dependencies.
h3. Applicability
Consider using delegation when:
* Vendor code controls instantiation of the target.
* Your code is the primary client of the target.
* You need to make more than one addition or modification to the target’s interface.
h3. Caveats
Be aware that delegates produces with the @delegate@ library are not perfect stand-ins for their target objects. In particular, by default the delegate object will have a different @id@ and @object_id@. This is easy to correct, but you should be aware of it, especially when working with @ActiveRecord@, which uses @id@ to associate objects with rows in the DB.