Comprendre et implémenter un DSL en ruby

Publié le 23 décembre 2014 par Martin Catty | back

Cet article est publié sous licence CC BY-NC-SA

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

Qu’est ce qu’un DSL ?

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.

Créer son DSL

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

Gare au contexte

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.

Simuler un DOM avec un DSL

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.

Conclusion

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.