How to write your own DSL in Ruby

  sonic0002        2017-03-04 09:40:34       7,750        0    

DSL(Domain Specific Language) is a language defined to fulfill some domain specific requirements to ease people's work. It can be used to define attributes and actions of a domain easily and cleanly. And it is often created based on some common observations or micro patterns of some domain.

In Ruby world, there are quite a few places people can find DSL. For example, Ruby itself, Chef recipes.

To create a class in Ruby, the traditional OOP way would look like.

class User
  @name = nil
  @email = nil

  def name
    @name
  end

  def name=(val)
    @name = val
  end

  def email
    @email
  end

  def email=(val)
    @email = val
  end
end

This is quite a lot of codes to just define two attributes and their corresponding set and get methods for class User. 

In DSL style, the same class can be written as below

class User
  attr_accessor :name, :email
end

The attr_accessor has the function of defining the attributes and their corresponding set and get accessors. It's really tidy and clean and easy to read. Many people would consider this as syntax sugar as well.

Now what if someone wants to define his/her own DSL for creating some objects easily? Just like what the rspec FactoryGirl has done. Assume a factory is needed to create User objects so that they can be created easily with different properties.

Before creating the DSL, a concept/method in Ruby -- instance_eval -- has to be understood first as it's the key for the DSL creation. According to the doc, the definition of instance_eval is

Evaluates a string containing Ruby source code, or the given block, within the context of the receiver (obj). In order to set the context, the variable self is set to obj while the code is executing, giving the code access to obj’s instance variables.

This means if a block is supplied when calling the instance_eval on an object, the block will consider the object as self and all methods invoked within the block will be called on the context of self, which is the object.

For example, there is a class Definition created.

class Definition
    def factory(clazz, &block)
      builder = Builder.new
      if block_given?
        builder.instance_eval(&block)
      end
      DSL.registry[clazz] = builder
    end
end

And there is a module DSL which has a method define.

module DSL  
  def self.define(name, &block)
    p name
    definition = Definition.new
    if block_given?
      definition.instance_eval(&block)
    end
  end
end

And there is following code which calls the DSL.define with a block.

DSL.define "Demo" do
  factory User
end

The factory under the above block is called on the definition object created in DSL.define method instead of DSL. The above code shows an implementation of a DSL. 

How above code works is that the DSL.define method takes two parameters, one is the name which will be printed directly and the second is a block. In that method, there will be an object of Definition created and the supplied block will be evaluated on the definition object. Also in Definition class, it defines a method factory which takes two parameters as well. The first one is the name of a Class type and the second is a block. The clazz will be used to create a mapping between the Class type and the corresponding block so that it can be referred later when building an object of this clazz type. The Builder class is used for creating attributes and their associated methods for a specified Class type.  When creating the actual object for a specified Class type, there is a build method to be defined for DSL module. Hence the complete DSL code would look like

module DSL
  @registry = {}

  def self.registry
    @registry
  end

  class Builder
    attr_reader :attributes

    def initialize
      @attributes = {}
    end

    def method_missing(name, *args, &block)
      @attributes[name] = args[0]
    end
  end

  class Definition
      def factory(clazz, &block)
        builder = Builder.new
        if block_given?
          builder.instance_eval(&block)
        end
        DSL.registry[clazz] = builder
      end
  end

  def self.define(name, &block)
    p name
    definition = Definition.new
    if block_given?
      definition.instance_eval(&block)
    end
  end

  def self.build(clazz, overrides = {})
    instance = clazz.new
    attrs = registry[clazz].attributes.merge(overrides)
    attrs.each do |name, value|
      instance.send("#{name}=", value)
    end
    instance
  end
end

Hence the entry point of the DSL is the define method and the build method is used for creating actual object of any type. A demo of above code would be

DSL.define "Demo" do
  factory User do
    name  "tester"
    email "tester@example.com"
  end
end

tester = DSL.build(User)
p tester

dummy = DSL.build(User, name: "dummy")
p dummy

The output would be

"Demo"
#<User:0x34d28d8 @name="tester", @email="tester@example.com">
#<User:0x34d2740 @name="dummy", @email="tester@example.com">

From above code, it's easily understandable that the DSL.define method defines the attributes and actions applicable to the User type. 

Creating your own DSL is not a difficult task as long as you know how instance_eval works.

TUTORIAL  RUBY  DSL 

       

  RELATED


  0 COMMENT


No comment for this article.



  RANDOM FUN

This is pretty good