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.