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 :)