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 "[email protected]"
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="[email protected]"> #<User:0x34d2740 @name="dummy", @email="[email protected]">
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.
No comment for this article.