Ruby's low-overhead metaprogramming facilities make it easy to create elegant APIs with minimal effort. As a testament to its power, I can say with a high degree of confidence that all of your favorite Ruby libraries leverage metaprogramming in one way or another.
Recently, I found myself needing to add some very simple behavior to each method in a class. For the sake of this post, let's assume our job is to push to an event queue whenever a method starts or finishes.
class Store
def initialize(db, event_queue)
@db = db
@event_queue = event_queue
end
def order_purchase(user, items)
@db.transaction do
decrease_inventory(items)
add_to_purchase_history(user, items)
end
end
def decrease_inventory(items)
# TODO
end
def add_to_purchase_history(user, items)
# TODO
end
end
There are a couple of different ways to get our desired behavior. The simplest way would be to edit the methods directly.
class Store
def order_purchase(user, items)
@event_queue.enqueue(__method__, [user, items], :start)
@db.transaction do
decrease_inventory(items)
add_to_purchase_history(user, items)
end
@event_queue.enqueue(__method__, [user, items], :end)
end
def decrease_inventory(items)
@event_queue.enqueue(__method__, [items], :start)
# TODO
@event_queue.enqueue(__method__, [items], :end)
end
def add_to_purchase_history(user, items)
@event_queue.enqueue(__method__, [user, items], :start)
# TODO
@event_queue.enqueue(__method__, [user, items], :end)
end
end
This is straightforward and fast. I know, I know. It's tempting to commit this and call it a day. There's a lot of duplication though, and we don't want to start the pattern of adding two lines of code every time we need to add notifcations to a method. I wonder if we can make this cleaner with a little metaprogramming...
First, let's think about how the ideal API would look:
class Store
#extend Notifier
#notify_on :order_purchase
#notify_on :decrease_inventory
#notify_on :add_to_purchase_history
end
Now, we just need to write the module. It seems like the pattern we're going for is "notify of start, do some stuff, notify of end." I have an idea: We can define methods at the class level, so what if we actually rewrote the methods? We could pull that off with define_method
and some aliasing.
require 'securerandom'
module Notifier
def notify_on(event)
guid = SecureRandom.uuid
define_method("#{guid}") do |*args, &block|
@event_queue.enqueue(event, args, :start)
begin
result = __send__("#{guid}_#{event}", *args, &block)
ensure
@event_queue.enqueue(event, args, :end)
end
result
end
alias_method "#{guid}_#{event}", event
alias_method event, "#{guid}"
end
end
Things get just a little bit tricky here.
-
Our
guid
is an attempt at guaranteeing uniqueness, so that we won't override any existing methods. -
notify_on
is at the class level, butdefine_method
creates an instance method on its receiver, which will be whatever class we extendNotifier
with. So, the implementation inside ofdefine_method
actually becomes an instance level implementation. -
__send__
is our way of executing the original method. We have to use this since we're working with a string. Note that we pass our"#{guid}_#{event}"
because we end up aliasing a few lines down. -
We have to use
ensure
because we don't know if an exception could bubble up as a result of using__send__
and we want to guarantee that we will always push the concluding event. [1] -
Finally,
alias_method
creates a second reference to the method implementation, meaning we can call it using either reference. We take it a step further and actually rebind our original reference name to our new implementation. This way, the class extending this module doesn't have to do anything to the original method itself to gain the additional functionality.
With that in mind, we can now uncomment the code from our example.
class Store
extend Notifier
notify_on :order_purchase
notify_on :decrease_inventory
notify_on :add_to_purchase_history
end
Let's see all the pieces in action.
class EventQueue
attr_reader :messages
def initialize
@messages = []
end
def enqueue(event, args, state)
@messages << [event, args, state]
end
end
class DB
def transaction
yield
end
end
q = EventQueue.new
db = DB.new
Store.new(db, q).order_purchase(:ryan, ["almonds", "avocados"])
require 'pp'; pp q.messages
# [[:order_purchase, [:ryan, ["almonds", "avocados"]], :start],
# [:decrease_inventory, [["almonds", "avocados"]], :start],
# [:decrease_inventory, [["almonds", "avocados"]], :end],
# [:add_to_purchase_history, [:ryan, ["almonds", "avocados"]], :start],
# [:add_to_purchase_history, [:ryan, ["almonds", "avocados"]], :end],
# [:order_purchase, [:ryan, ["almonds", "avocados"]], :end]]
With a little cleverness and a small amount of knowledge about the standard library and the Ruby object model, we were able to write a simple, more intuitive API. It's these relatively simple constructs that make Ruby one of my favorite programming languages.
Tradeoffs
We've traded a complex abstraction for a simple interface. There were a lot of things that we needed to get right for this example. One small slip and we could end up with subtle errors and bugs. Why do it? I think there are two primary benefits: modularity and client-side simplicity.
Now that the notification responsibility is decoupled from the purchase responsibility, there's a higher chance of reuse. This functionality is generic enough that other parts of the codebase may leverage it in the future. Alone, that doesn't explain why we resorted to metaprogramming, which leads me to my next point.
The other solutions we examined worked, but none were quite as nice as the solution we ended up with. We don't have to wrap all of our client code in blocks, and we gain a terse syntax for adding our desired functionality. Whether those qualities warrant our metaprogramming is subjective, but I think there's something to be said for metaprogramming's prolific use throughout the Ruby community.
See Also
- Andrea Singh on Ruby's Eigenclass
- Rails' alias_method_chain
- Paolo Perrotta's excellent book on metaprogramming in Ruby
[1] Actually, we don't know what will happen as a result of calling enqueue
either, but for the sake of simplicity we'll assume that exceptions won't be raised. A more thorough implementation would probably need some verification that both messages made it to the queue or otherwise continue sending them until they do.