Building a basic DSL to create callbacks in Ruby

Jan 14, 2011
57a73a8edf3bf8ee3bb7e00f7f6753d4

Do you know what a Domain Specific Language(DSL) is and how to implement one in Ruby?. This article aims to provide a slight introduction to this topic. It is divided in 3 sections, first we'll define what a DSL is, second we'll see some examples of DSL implementations, and third we'll build a DSL.

What is a DSL?

According wikipedia a DSL is defined as:

In software development and domain engineering, a domain-specific language (DSL) is a programming language or specification language dedicated to a particular problem domain, a particular problem representation technique, and/or a particular solution technique.

To clarify, we'll see some examples. As you read through them take into account the following points:

  • Ruby blocks are used everywhere. They are the bare minimum construction element.
  • Though the used structures are not part of the Ruby core, all of them use valid Ruby constructs.
  • The main purpose of creating new code structure is to provide a more human readable code.

This implies that the following DSL examples(codes) are build with sentences like:

  def describe(subject, &block); end

  def Given(expression, &block); end

  def get(route, &block); end

Be aware, its respective gems may not define each DSL as I did, but it helps to show different possible ways to do it.

Let's start with the examples.

DSL implementations

If you are doing Ruby then you probably have already used DSL's. Gems like RSpec, Cucumber and Sinatra are good examples of DSL implementations. Let's see their syntax and put special attention to the structures they use.

First, let's see three snippets from these languages.

Rspec snippet

In RSpec when you want to test if some object responds to a method call you usually write something like:

  describe MyObject do
    it 'should respond to a method call' do
      subject.should respond_to(:method_call)
    end
  end

Cucumber snippet

In Cucumber when you write step definitions you do things like:

  Given /^I click link "([^""]*)"$/ do |link|
    find(:css, link).click
  end

Sinatra snippet

In Sinatra when you whant to write a route/controller you do something like:

  get "/" do
    "Hi there"
  end

Now let's write our own DSL.

Writing a DSL

The following technique intention is similar to that from Rails Controller Filters.

Let's build a DSL called Wrappable. Wrappable will be a simple custom DSL that wrap's' a method with callbacks using the Ruby language.

Let me clarify what I mean by wrap using the following snippet:

  def before; end
  def original; end
  def after; end

Wrappable will wrap the original method. Whenever original is invoked, the before method will be automatically invoked first, second it will invoke the original method and finally it will invoke the after method.

The way the before and after methods behave is known as a Callback.

This behavior can also be achieved with something like:

  def before; end
  def original
    before
    # original sentences
    after
  end
  def after; end

But, I want to do this dynamically, using a wrap method that will be able to setup the calls to the methods before and after programatically.

Usage example

We will end up the example with the following CallbackTest class:

    class CallbackTest

      # This module contains the whole functionality
      include Wrappable

      def original
        puts "Original method"
      end

      def before
        puts "Before method"
      end

      def after
        puts "After method"
      end

      wrap :original do
        before_run :before
        after_run :after
      end

    end

    CallbackTest.new.original

The script output should be as follows:

    ...$ ruby my_test.rb
    Before method
    Original method     
    After method

Writing the callback step by step

In order to build the whole example let's start by listing what we require to do and then we will code the example from scratch.

The requirements

Consider the script fragment where wrap is invoked:

    wrap :original do
      before_run :before
      after_run :after
    end

This means:

  • Step 1, we need a class method called wrap. The wrap method has two parameters: The first is the *symbol representing the name of the method that will be wrapped and the second is a block.
  • Step 2, the block parameter contains two method calls that configure what methods should be invoked: before_run and after_run. Each method receives one parameter as symbol that represents the name of the method that will be invoked respectively.
  • Step 3, create the wrap behavior. This step involves creating a new method that will eventually call the original, before and after methods and implies that we need to keep a reference to the original method so we do not overwrite it.

Step 1

What we are going to do here is: * Create a Wrappable module with an empty wrap method and its two parameters. * Add the Wrappable module methods to the CallbackTest metaclass (adding static methods). * Invoking the Wrappable's wrap method inside CallbackTest class.

Now, save the following snippet as callback_test.rb:

    module Wrappable
      def wrap(original_method, &block)
      end
    end

    class CallbackTest

      extend Wrappable

      def original
        puts "Original method"
      end

      wrap :original do
      end

    end

    CallbackTest.new.original

Now execute it:

    ...$ ruby callback_test.rb
    Original method

Step 2

What we are going to do here is:

  • Invoke the before_run and after_run inside the wrap's parameter block.
  • Create a WrapperOptions class.
    • This class will namespace the before_run and after_run methods.
    • I want the wrap's parameter block to be evaluated inside this WrapperOptions class.
    • By using this class we can handle all options parsing in just one place.

Update callback_test.rb with the following:

    module Wrappable
      class WrapperOptions
        def initialize(&block)
          instance_eval(&block)
        end
        private
        def before_run(method_name)
          @before = method_name
        end
        def after_run(method_name)
          @after = method_name
        end
      end

      def wrap(original_method, &block)
        wrapper_options = WrapperOptions.new(&block)
      end
    end

    class CallbackTest

      extend Wrappable

      def original
        puts "Original method"
      end

      def before
        puts "Before method"
      end

      def after
        puts "After method"
      end

      wrap :original do
        before_run :before
        after_run :after
      end

    end

    CallbackTest.new.original

And verify that your script still works:

    ...$ ruby callback_test.rb
    Original method

Step 3

Our CallbackTest remains unchanged. Let's improve and complete the Wrappable module. What we are going to do here is:

  • Alias the original method so we don't overwrite it.
  • Create accessors to our wrapped method names in the WrapperOptions class.
  • Create a new method that will eventually call the original, before and after methods.

    module Wrappable
      class WrapperOptions
        attr_reader :before, :after
        def initialize(&block)
          instance_eval(&block)
        end
        private
        def before_run(method_name)
          @before = method_name
        end
        def after_run(method_name)
          @after = method_name
        end
      end
    
      def wrap(original_method, &block)
        wrapper_options = WrapperOptions.new(&block)
        alias_method :old_method, original_method
        define_method original_method do
          send(wrapper_options.before)
          send(:old_method)
          send(wrapper_options.after)
        end
      end
    end
    

And finally test that the whole this script works:

    ...$ ruby my_test.rb
    Before method
    Original method     
    After method

That's it.

Conclusion

This was a simple way to build a custom DSL, there are many DSL examples all around the web for example:

Note that this implementation only supports method names as parameters, if you want to view an example of transparently supporting blocks as wrappers then look at the complete exercise gist.

Finally, if you really need to implement callbacks then I recommend you to look at the ActiveSupport::Callbacks package.

Thank you for reading.

Regards

blog comments powered byDisqus