Referring to definition, DSL (Domain Specific Language) is a computer language specialized to a particular application domain. This means it is developed to satisfy specific needs.
By reading this article you will learn what the DSL is and what it has in common with Ruby.
DSL, say welcome!
Referring to definition, DSL (Domain Specific Language) is a computer language specialized to a particular application domain. This means it is developed to satisfy specific needs. There are two types of DSL:
-
An external DSL which requires its own syntax parser. A good known example may be the SQL language – it allows to interact with database in a language in which the database was not created.
-
An internal DSL which itself does not have its own syntax but instead uses a syntax of a given programming language.
As you can probably guess we are going to stay focused on the second DSL type.
What does it do?
Basically, by making use of a Ruby metaprogramming, it allows to create your own mini-language. Metaprogramming is a programming technique that allows to write a code dynamically at runtime (on the fly). You may be unaware of this, but you probably use many different DSLs every day. To understand what a DSL can do, let’s take a look at a few examples below – all of these have one common element, but can you point it?
Rails routing
Rails.application.routes.draw do
root to: 'home#index'
resources :users do
get :search, on: :collection
end
end
```
Every person who have ever used Rails know a config/routes.rb file where we define application routes (mapping between HTTP verbs and URLs to controller actions). But have you ever wondered how does it work? In fact, it is just Ruby code.
Factory Bot
FactoryBot.define do
factory :user do
company
sequence(:email) { |i| "user_#{i}@test.com" }
sequence(:first_name) { |i| "User #{i}" }
last_name 'Test'
role 'manager'
end
end
Writing tests often requires fabricating objects. Hence to avoid a waste of time, it would be a really good idea to simplify the process as much as possible. That is what the FactoryBot does – easy to remember keywords and a way of describing an object.
Sinatra
require 'sinatra/base'
class WebApplication < Sinatra::Base
get '/' do
'Hello world'
end
end
```
Sinatra is a framework which allows you to create web applications from scratch. Could it be easier to define request method, path and response?
Other DSL examples might be Rake, RSpec or Active Record. The key element of each DSL is the use of blocks.
Building time
Time to understand what is hiding under the hood and how the implementation can look like.
Let’s assume we have an application which stores data about different products. We want to extend it by giving possibility to import data from a user defined file. Also, the file should allow to calculate values dynamically if needed. To achieve that, we decide to create DSL.
A simple product representation may have following attributes (product.rb):
class Product
attr_accessor :name, :description, :price
end
Instead of using a real database we will just simulate its work (fake_products_database.rb):
class FakeProductsDatabase
def self.store(product)
puts [product.name, product.description, product.price].join(' - ')
end
end
Now, we will create a class that will be responsible for reading and handling file containing products data (dsl/data_importer.rb):
module Dsl
class DataImporter
module Syntax
def add_product(&block)
FakeProductsDatabase.store product(&block)
end
private
def product(&block)
ProductBuilder.new.tap { |b| b.instance_eval(&block) }.product
end
end
include Syntax
def self.import_data(file_path)
new.instance_eval File.read(file_path)
end
end
end
```
The class has a method named import_data which expects a file path as an argument. The file is being read and the result is passed to the instance_eval method which is called on the class instance. What does it do? It evaluates the string as a Ruby code within the instance context. This means self will be the instance of DataImporter class. Thanks to the fact we are able to define desired syntax/keywords (for a better readability the syntax is defined as a module). When the add_product method is called the block given for the method is evaluated by ProductBuilder instance which builds Product instance. ProductBuilder class is described below (dsl/product_builder.rb):
module Dsl
class ProductBuilder
ATTRIBUTES = %i[name description price].freeze
attr_reader :product
def initialize
@product = Product.new
end
ATTRIBUTES.each do |attribute|
define_method(attribute) do |arg = nil, &block|
value = block.is_a?(Proc) ? block.call : arg
product.public_send("#{attribute}=", value)
end
end
end
end
```
The class defines syntax allowed within add_product block. With a bit of metaprogramming it adds methods which assign values to product attributes. These methods also support passing a block instead of a direct value, so a value can be calculated at runtime. Using attribute reader, we are able to obtain a built product at the end.
Now, let’s add the import script (import_job.rb):
requirerelative 'dsl/dataimporter'
requirerelative 'dsl/productbuilder'
requirerelative 'fakeproductsdatabase'
requirerelative 'product'
Dsl::DataImporter.import_data(ARGV[0])
```
And finally - using our DSL - a file with products data (dataset.rb):
```ruby
add_product do
name 'Charger'
description 'Life saving'
price 19.99
end
add_product do
name 'Car wreck'
description { "Wrecked at #{Time.now.strftime('%F %T')}" }
price 0.01
end
add_product do
name 'Lockpick'
description 'Doors shall not close'
price 7.50
end
```
To import the data we just need to execute one command:
ruby import_job.rb dataset.rb
And the result is..
Charger - Life saving - 19.99
Car wreck - Wrecked at 2018-12-09 09:47:42 - 0.01
Lockpick - Doors shall not close - 7.5
..success!
Conclusion
By looking at the all examples above, it is not hard to notice the possibilities offered by DSL. DSL allows to simplify some routine operations by hiding all required logic behind and exposing to user only the most important keywords. It allows you to get a higher level of abstraction and offers flexible use possibilities (what is especially valuable in terms of reusability). On the other hand, adding DSL to your project should be always well considered – an implementation using metaprogramming is definitely much harder to understand and maintain. Moreover, it requires solid tests suite due to its dynamism. Documenting DSL furthers its easier understanding, so it is definitely worth doing. Although implementing your own DSL can be rewarding, it is good to remember that it must pay off.
Did you get interested in the topic? If so let us know – we will tell you about DSL which we have recently created to meet the requirements in one of our projects.