What is Ruby DSL? #
As you already know, DSL means domain-specific language. It’s like a language in a language. Here are some examples that we use every day:
class User attr_reader :name end class Profile < ActiveRecord::Base has_many :posts end class ApiController < ActionController::Base before_action :authenticate end
But all these examples use rails-provided DSL, how about your own? First of all, you should know that all these methods (attr_reader
, has_many
and before_action
) are actually class-methods of Module
, ActiveRecord::Base
and ActionController::Base
. So you can write something like:
class TestClass def self.my_dsl_method(dsl_method_arg) puts "dsl method called with #{dsl_method_arg}" end my_dsl_method :some_argument end
And the body of my_dsl_method
can be more dynamic then just printing a string. It can define methods, execute some calculations, etc. Less words, more code!
For example, we want a DSL like this:
survey 'Survey title' do question 'Question 1 title' do answer 'Option 1' answer "Option 2" end question 'Question 2 title' do answer 'Option 3' answer 'Option 4' end end
Blocks and context #
Ruby blocks are the heart of DSL in ruby. Blocks can be call
-ed, instance_eval
-ed and instance_exec
-ed. The difference between call
and instance_eval
is the context of block execution:
require 'ostruct' proc1 = proc { puts self.name } proc2 = proc { |obj| puts obj.name } object = OpenStruct.new(name: 'John') object.instance_eval(&proc1) # self in proc1 is object # => 'John' proc2.call(object) # obj in proc2 is object # => 'John'
instance_exec
is pretty much like instance_eval
but it also takes extra arguments that becomes accessible in the block.
So? #
Here is the draft of the code that simulates a survey:
class Survey def initialize(title, &block) @title = title instance_eval(&block) end def question(question_title, &block) # Storing question end def run # Printing stored questions end end def survey(title, &block) Survey.new(title, &block).run end survey 'Survey title' do question 'My Question1' question 'My Question2' end
This DSL does not make anything yet, but it’s a good prototype of how it should look. We instance_eval
passed block, so DSL-method question
becomes instance method of Survey
class, and instance of Survey
becomes the context of internal DSL block.
Let’s implement question
and run
methods.
Method question should build instance of Question
class with passed title
and store it.
class Survey # ... def question(question_title, &block) questions << Question.new(question_title, &block) end def questions # With this code we have pre-defined value # of `questions` method @questions ||= [] end end
And the Question
class:
class Question def initialize(title, &block) @title = title instance_eval(&block) end def answer(answer_text) answers << answer_text end def answers @answers ||= [] end end
Let’s put all together:
survey 'Survey title' do # here `self` is an instance of Survey class # so we can call ANY instance methods of Survey question 'Question 1' do # here `self` is an instance of Question class answer 'Answer 1' answer 'Answer 2' end end
The only missing method is Survey#run
:
class Survey # ... def run puts @title questions.each(&:run) end end class Question # ... def run puts @title answers.each_with_index do |answer, index| puts "#{index + 1}. #{answer}" end result_number = gets.chomp.to_i - 1 result = answers[result_number] puts "Your answer is #{result}" end end
Full code is available on gist or a bit more complex example with tests on GitHub .
Advanced DSL example #
DSL that we have made before allows us to write some code using shortcuts, but usually we create DSL to define new methods (or change existing ones).
Let’s build something like:
class User extend MyModuleWithDSL accessor_with_default_value :full_name, 'Fname Lname' end u = User.new u.full_name # => 'Fname Lname' u.full_name = 'Another Name' u.full_name # => 'Another Name'
Defining methods dynamically #
Here we need define_method
method. It takes two parameters: a method name and a block that becomes body of created method:
class User define_method :my_method do |arg| puts "Called with #{arg}" end end User.new.my_method(123) # => Called with 123
A huge difference between defining method with define_method
and def method_name
is that define_method
does not lose current context:
local_var = 'test' define_method :method1 do puts local_var # Works here end method1 # => 'test' def method2 puts local_var # Does not work end method2 # => NameError: undefined local variable or method `local_var' for main:Object
This allows us to pass any variables to DSL and use them in dynamically defined methods.
Where is the code? #
Here is it:
module ModuleWithDSL def accessor_with_default_value(attr_name, default_value) # Here we need to build something like # def full_name # @full_name || 'Fname Lname' # end # attr_writer :full_name define_method attr_name do instance_variable_get("@#{attr_name}") || default_value end attr_writer attr_name end end class User extend ModuleWithDSL # so all methods from ModuleWithDSL # become class-methods of User accessor_with_default_value :full_name, 'default' end u = User.new u.full_name # => 'default' u.full_name = 'Full Name' u.full_name # => 'Full Name'
What’s wrong in the previous example? #
What I personally don’t like here is that there is no way to override defined method using super
(I don’t like alias_method_chain
, we really can make it work using super method). Why super does not work? Because the method is defined directly on the class:
User.instance_method(:full_name) # => #<UnboundMethod: User#full_name>
The following example is quite complex, but still:
module ModuleWithDSL def self.extended(base) mod = base.const_set(:AccessorsWithDefaultValues, Module.new) base.send(:include, mod) super end def accessor_with_default_value(attr_name, default_value) const_get(:AccessorsWithDefaultValues).module_eval do define_method attr_name do instance_variable_get("@#{attr_name}") || default_value end attr_writer attr_name end end end
Here when this module extend
-s some class/module, we define a sub-module called AccessorsWithDefaultValues
in the class using extended
hook, include it in the class and define ALL dynamic methods on this module. What does it give us?
User.instance_method(:full_name) # => #<UnboundMethod: User(User::AccessorsWithDefaultValues)#full_name> User.ancestors # => [User, User::AccessorsWithDefaultValues, ...]
So now we can override our accessor
-s on User
:
class User extend ModuleWithDSL accessor_with_default_value :counter, 123 def counter super + 10 # default behavior + 10 end end user = User.new user.counter # => 133 (123 + 10 = default + custom) user.counter = 15 user.counter # => 25
ActiveRecord does the same job, you can override any column-method:
User.instance_method(:email) # => #<UnboundMethod: User(#<#<Class:0x00000008c44ac0>:0x00000008c44b38>)#email(__temp__56d61696c6)> # This module is anonymous, but it doesn't matter, it wasn't defined _directly_ on User: class User < ActiveRecord::Base # If you have column `email` def email super.upcase end end
A lot of developers build a DSL in their libraries to simplify the code, and this allows us to write our code in a more declarative fashion. But they forgot (quite often) about allowing us to override the code generated with this DSL. When the library does not support overriding generated code, use alias_method
:
class User include SomeModule # here we call some DSL # and, let's say, it generates method 'run' # and we need to add before hooks # to this method alias_method :original_run, :run def run # before hook goes here.. original_run end end
Conclusion #
DSL is a tool for fast prototyping, here are some advantages:
- It makes your code look nicer (the code that calls DSL)
- Amazing level of abstraction, you can focus exactly on your logic and forget about any code-related stuff.
And disadvantages:
- Your code becomes slow (usually just a little bit, but sometimes it can be critical)
- The effort to maintain your DSL grows with its complexity. Yes, it’s very easy to make DSL implementation look ugly
I really love to use DSL from third-party libraries (AR, RSpec, Capybara, FactoryBot). But as I said before, I don’t write it in enterprise and usually I ask people to avoid it.
But if you write DSL, please wrap generated stuff into module to let developers override your code and be happy, this is what DSL is made for :)