Lorsqu’on débute en Ruby, les DSL apparaissent comme une sorte de magie noire alors qu’on les croise à chaque coin de son éditeur.
Il suffit d’ouvrir un fichier d’environnement dans une application Ruby on Rails pour tomber dessus:
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# In the development environment your application's code is reloaded on
# every request. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
config.cache_classes = false
# Do not eager load code on boot.
config.eager_load = false
…
end
Vous le voyez, dans les DSL tout repose sur l’usage de blocs. Dans cet article je vous présenter un exemple basique dans l’exposition d’un DSL sans intérêt et un exemple plus avancé permettant d’utiliser un DSL pour définir un DOM de la sorte:
Form.new do
method "post"
action "/"
p do
id "my_firstname"
input do
type "text"
name "firstname"
end
end
end
Un DSL est l’application d’un langage particulier dans un domaine précis. Comparativement au langage parlé, c’est l’équivalent d’un jargon.
C’est à dire que vous allez utiliser un sous ensemble d’un langage pour exprimer un contexte précis.
Prenons un cas simple où je souhaite pouvoir décrire des services à l’aide d’une syntaxe encadrée.
Je voudrai pouvoir exprimer mes services de la sorte:
Service.new do
description "postgresql"
port 5432
host "foo.bar.com"
name "db"
username "jean"
password "fam0us"
end
On le voit, la configuration d’un service avec cette syntaxe est très lisible et facilement mémorisable. On est finalement assez proche de l’écriture d’un fichier de configuration.
Voyons ce qu’il nous faut pour rendre notre interface de service disponible.
On se doute qu’il va nous falloir une classe avec un constructeur qui prend un bloc. On va invoquer ce bloc s’il est présent (toute méthode ruby possède un bloc optionnel). Pour cela nous utiliserons yield.
class Service
def initialize(&block)
yield if block_given?
end
end
Service.new do
description "postgresql" # en fait l'appel description("postgresql")
end
Aïe: undefined method 'description' for main:Object
Apparemment notre bloc essaye d’invoquer description
au niveau main, vérifions.
class Service
def initialize(&block)
yield if block_given?
end
end
def description(d)
puts "Not sure you want to call me."
end
Service.new do
description "postgresql"
end
$ ruby service.rb
Not sure you want to call me.
Effectivement notre bloc ne s’exécute pas dans le contexte du service, il prend le contexte courant. À ce moment self
ne représente donc pas l’instance de Service
mais le main.
Nous pourrions améliorer notre programme de la sorte pour passer notre contexte au yield:
class Service
def initialize(&block)
yield(self) if block_given?
end
def description(description)
end
end
Service.new do |s|
s.description "postgresql"
end
Bon, ça marche, mais on ne respecte pas la syntaxe initiale souhaitée. Pour ce faire nous avons besoin d’évaluer notre bloc dans le contexte de notre objet.
class Service
def initialize(&block)
instance_eval(&block) if block_given?
end
def description(description)
@description = description
end
end
Service.new do
description "postgresql"
end
En faisant cela on applique un self.instance_eval(&block)
. self
étant à cet endroit l’instance de Service
cela fonctionne normalement.
Si notre DSL doit gérer beaucoup d’attributs on pourra l’enrichir pour gérer dynamiquement nos setters, ou utiliser method_missing
.
On pourrait même s’amuser à créer une petite interface pour rendre n’importe quelle classe utilisable comme un DSL.
class DSL
def self.build(object, &block)
object.instance_eval(&block)
object
end
end
class Service
def description(description)
@description = description
end
end
DSL.build(Service.new) do
description "postgresql"
end
Nous voilà donc arrivé au résultat voulu en finalement très peu de code. Mais je sens que quelque chose vous chagrine.
Vous vous dites que c’est super mais dans ce cas pourquoi des DSL type create_table dans ActiveRecord::Migration
sont sous cette forme:
create_table :contacts do |t|
t.string :email, null: false
t.timestamps
end
et pas celle ci:
create_table :contacts do
string :email, null: false
timestamps
end
Vous le savez sans doute, l’évaluation de code est à utiliser modérément. Dans le contexte de mon bloc je vais être cantonné à mon binding, c’est à dire celui de mon service.
Si je souhaite mettre en place un peu d’abstraction cela commence à poser souci:
class Service
def initialize(&block)
instance_eval(&block) if block_given?
end
def description(description)
@description = description
end
end
class ServiceDefinition
SERVICES = { pg: 'postgresql' }
def self.define
Service.new do
description name(:pg)
end
end
def self.name(sym)
SERVICES[sym]
end
end
undefined method name for #<Service:0x007f93ba10c930>
. Je ne peux pas appeler name
ici car il le cherche sur mon service.
Si je veux arriver au bon résultat je vais devoir sauvegarder mon contexte avant le bloc:
class Service
def initialize(&block)
instance_eval(&block) if block_given?
end
def description(description)
@description = description
end
end
class ServiceDefinition
SERVICES = { pg: 'postgresql' }
def self.define
context = self
Service.new do
description context.name(:pg)
end
end
def self.name(sym)
SERVICES[sym]
end
end
Dans le cas présent on voit que ce n’est pas une excellente idée ; attention donc à être sûr de ce qu’on fait et ne pas se créer des problèmes vicieux et complexe à débugger par la suite.
Voyons maintenant comment mettre en place un DSL un tant soit peu utile permettant de définir un document structuré type page HTML.
Le fichier dom.rb est sur notre compte github.
module WithAttributes
def attributes(*args)
args.each do |attribute|
define_method(attribute) do |value|
# use attr_ prefix to be able to filter these var later.
instance_variable_set "@attr_#{attribute}", value
end
end
end
end
Je définis un module qui me permettra de définir des attributs dans mes différentes classes représentant mes éléments.
Ces attributs sont placés dans des variables d’instances. Par exemple pour un attribut name
c’est @attr_name
qui sera créée ainsi que sont setter associé def name(name)
.
L’intérêt d’utiliser un nom préfixé est de pouvoir lister et filtrer ces attributs par la suite.
class Element
extend WithAttributes
attributes :id
def initialize(&block)
@childs = []
instance_eval(&block) if block_given?
end
def to_html
tag = self.class.to_s.downcase
str = @childs.inject("<#{tag}#{attributes}>") do |acc, child|
acc << child.to_html
acc
end + "</#{tag}>"
end
def attributes
# only keep attr variables
instance_variables.select { |v| v.to_s.match(/^@attr_/) }.inject("") do |acc, a|
name = a.to_s
name.slice!('@attr_')
value = instance_variable_get(a)
acc << " #{name}=\"#{value}\""
end
end
end
Element est ma classe de base, celle dont hériteront les autres classes. J’utilise le désormais connu instance_eval(&block)
qui permettra de donner des valeurs à mes attributs en utilisant les méthodes définies dynamiquement.
J’ai une méthode to_html
récursive qui affiche ma balise avec ses attributs et s’invoque sur les éléments enfants.
class Form < Element
attributes :method, :action
def p(&block)
@childs << P.new(&block)
end
end
class P < Element
def input(&block)
@childs << Input.new(&block)
end
end
class Input < Element
attributes :type, :name
end
Le reste est très simple, ce sont des classes qui héritent de ma classe Element
et exposent leurs méthodes et attributs.
Le concept de DSL est à la fois puissant tout en étant simple à implémenter. Si vous souhaitez aller plus loin sans repartir de 0 à chaque fois vous pouvez également jeter un œil à la gem docile qui vous permet de mettre en place des DSL très simplement.
L’équipe Synbioz.
Libres d’être ensemble.