Publié par

Il y a 11 ans -

Temps de lecture 9 minutes

Introduction aux DSL avec Groovy

Nous allons voir comment créer un mini-DSL pour écrire des expressions cron – utilisées pour scheduler des batchs – de façon plus lisible. Au lieu d’écrire « * */5 * * * ? » nous saisirons « every(5.mn) », et tout de suite vous comprenez mieux que le batch sera lancé toutes les 5mn !

Domain-Specific Language

Un Domain-Specific Language (DSL) est un langage spécialement conçu pour un domaine précis. En opposition, les langages généralistes comme le Java permettent de traiter tous types de problèmes mais avec une quantité de code souvent plus importante et une complexité plus élévée.
Evidemment, pour une application web, il est préférable d’utiliser du Java mais pour certains problèmes précis l’utilisation d’un DSL peut vous éviter beaucoup de lignes de code ou vous permet au moins de clarifier un algorithme (et donc de le rendre plus maintenable). Idéalement, les experts du domaine peuvent écrire eux-mêmes le code avec le DSL.

Groovy

Groovy est un langage dynamique basé sur la JVM – c’est-à-dire que les scripts Groovy génèrent des fichiers .class interprétés par la JVM – et se prête bien à la création d’un DSL interne (DSL qui étend d’un langage existant, Groovy en l’occurrence). Cela lui permet de s’intégrer très facilement avec du Java.
Nous allons aborder certaines de ces fonctionnalités au travers d’un exemple concret : les expressions cron. Issues du monde Unix, ces expressions servent à scheduler des jobs via des expressions du style « * 5/10 * * * ? » qui sont certes très courtes à écrire, mais franchement pas claires pour les non-initiés. On va les rendre plus lisibles…

La suite de l’article nécessite quelques bases en Groovy, aussi si vous ne savez pas ce qu’est une closure ou si la syntaxe groovy vous est totalement étrangère, je vous invite à lire le tutorial avant de commencer. Sa syntaxe proche du Java permettra d’appréhender le langage rapidement.

Expression Cron

Une expression cron permet de décrire une combinaison temporelle complexe du genre « tous les derniers vendredis du mois à 10h ».
Elle est composée de 6 champs + 1 champ optionnel :

  • Secondes (0-59)
  • Minutes (0-59)
  • Heures (0-23)
  • Jour du mois (1-31)
  • Mois (1-12)
  • Jour de la semaine (1-7 or SUN-SAT)
  • Année (optionnel) (1970-2099)

Pour chaque champ il est possible d’utiliser les caractères suivants (exemple du champ « heure ») :

  • « * » -> toutes les heures
  • « 3,5 » -> à 3h ou 5h
  • « 3-5 » -> de 3h à 5h
  • « 3/5 » -> toutes les 5h à partir de 3h

Si seulement l’un des 2 champs jours est renseigné, l’autre doit être à ‘?’.
Il existe encore quelques autres caractères spéciaux que nous n’aborderons pas ici.

Quelques exemples :

  • « 0 0 12 * * ? » signifie « tous les jours à 12h »
  • « */10 * * * * ? » signifie « toutes les 10s »
  • « 0 0 8-14 ? * MON » signifie « tous les lundis, toutes les heures, de 8h à 14h »

Création du mini-DSL

Commençons par définir une classe représentant ce type d’expression :

class Cron {
    String s, mn, h, day, month, dayOfWeek, year

    String toString() { "${s?:'*'} ${mn?:'*'} ${h?:'*'} ${day ?: dayOfWeek?'?':'*'} ${month?:'*'} ${dayOfWeek?:day?'?':'?'} ${year?:''}" }
}

Constructeur avec paramètres nommés

Nous pouvons déjà créer des expressions cron à l’aide des paramètres nommés :

def cron = new Cron(day:'1', h:'8', mn:'0', s:'0') // toutes les 1ers du mois à 8h
println cron // affiche '0 0 8 1 * ?'

Il n’y a pas besoin de définir de constructeur spécifique, en Groovy on peut passer en paramètre les valeurs des propriétés.
Toutefois, l’utilisation d’un constructeur reste assez technique (new), et il faut préciser les unités « mn » et « s » même lorsqu’elles sont à « 0 ». Nous allons essayer de le rendre plus lisible pour un utilisateur lambda : « new Cron(h:’8′, mn:’0′, s:’0′) » -> « at(8.h) ».

Interception de propriétés/méthodes

Tout d’abord, il faut pouvoir interpréter « 8.h ». 8 est un Integer, mais h n’est pas une propriété définie de la classe Integer.
Nous allons intercepter les appels de propriétés sur la classe Integer à l’aide de la metaClass et renvoyer un objet TimeUnit qui représentera cette unité de temps :

class TimeUnit {
    int value
    String unit // 's', 'mn', 'h', etc
    String toString() { "$value $unit" }
}

println new TimeUnit(value:8, unit:'h') // affiche '8 h'

// si une propriété est appelée sur un Integer, on retourne un objet Duration
Integer.metaClass.getProperty = {
    return new TimeUnit(value:delegate, unit:it)
}

println 8.h // affiche '8 h'

Tous les objets en Groovy possèdent un attribut metaClass qui permet l’introspection. La classe Class possède aussi cet attribut mais il est un peu particulier : il s’agit d’une ExpandoMetaClass plus précisément. Cette metaClass permet d’enrichir le comportement d’une classe en lui rajoutant des méthodes, des propriétés ou en interceptant les appels de méthodes ou propriétés.

Dans l’exemple ci-dessus nous avons surchargé la méthode getProperty() afin d’intercepter toutes les propriétés appelées sur un Integer.
La variable delegate fait référence à l’instance sur laquelle la propriété est appelée.
Et la variable it, implicite à toute closure, correspond ici au nom de la propriété appelée.

Reste à définir la méthode at qui prend en paramètre une TimeUnit et retourne une expression Cron :

public Cron at(TimeUnit time) {
    Cron cron = new Cron((time.unit):time.value)

    // les unités < time.unit sont positionnées à 0
    def properties = ['s', 'mn', 'h', 'day', 'month', 'dayOfWeek', 'year']
    int n = properties.indexOf(time.unit)
    if (n > 0)
        for (int i = 0; i < n; i++)
            if (!cron.(properties[i])) cron.(properties[i]) = '0' 

    return cron
}

// à 8h
println at(8.h) // affiche '0 0 8 * * ?'

L'utilisation des parenthèses autour de (time.unit) est nécessaire ici pour indiquer à Groovy qu'il s'agit d'une variable et non de la chaîne de caractères "(time.unit)".

Faisons évoluer un peu la méthode pour pouvoir passer plusieurs TimeUnit, comme dans "à 8h, 10h, et 14h" :

public Cron at(TimeUnit... times) {
    Cron cron = new Cron()

    times.each { TimeUnit time ->
        if (cron.(time.unit)) cron.(time.unit) += ',' + time.value
        else cron.(time.unit) = time.value

        // les unités < time.unit sont positionnées à 0
        def properties = ['s', 'mn', 'h', 'day', 'month', 'dayOfWeek', 'year']
        int n = properties.indexOf(time.unit)
        if (n > 0)
            for (int i = 0; i < n; i++)
                if (!cron.(properties[i])) cron.(properties[i]) = '0' 
    }

    return cron
}

// à 8h, 10h et 14h
println at(8.h, 10.h, 14.h) // affiche '0 0 8,10,14 * * ?'

Nous allons maintenant remplacer "* */5 * * * ?" par "every(5.mn)", ce qui signifie "toutes les 5mn" !

public Cron every(TimeUnit time) { 
    return new Cron((time.unit):'*/'+time.value)
}

// toutes les 5mn
println every(5.mn) // affiche '* */5 * * * ?'

Essayons maintenant de combiner ces 2 critères : on souhaite traduire "à 2h, tous les quarts d'heure" par "at(2.h) + every(15.mn)".
Certes ce n'est peut-être pas la façon la plus élégante, mais cela nous permet de voir la surcharge d'opérateurs.

Surcharge d'opérateurs

En Groovy quasiment tous les opérateurs peuvent être surchargés.

Il suffit de surcharger la méthode avec le nom correspondant. Ici, nous allons surcharger l'opérateur '+', c'est-à-dire la méthode plus :

// modification de la classe Cron
class Cron {

    // (les propriétés n'ont pas été répétées ici)

    // surcharge de l'opérateur '+'
    Cron plus(Cron other) {
        // pour chaque propriété de type String de la classe Cron :
        Cron.metaClass.properties.findAll{ it.type == String }.each {
            def value = Cron.metaClass.getProperty(this, it.name)
            def otherValue = Cron.metaClass.getProperty(other, it.name)
            Cron.metaClass.setProperty(this, it.name, compute(value, otherValue))
        }
        return this
    }

    // concatène 2 valeurs d'une même unité
    String compute(String s1, String s2) {
        if (!s1) return s2 // si l'une des valeurs est null on renvoie l'autre
        if (!s2) return s1
        if (s1 == '0') return s2 // la valeur zéro est considérée comme vide, l'autre valeur l'écrase
        if (s2 == '0') return s1
        return s1 + ',' + s2 // si les 2 sont renseignés on les concatène avec une ','
    }
}

// à 2h tous les 15mn
println at(2.h) + every(15.mn) // affiche '0 */15 2 * * ?'

Nous parcourons les propriétés de la class Cron en utilisant l'introspection sur sa metaClass, et pour chaque propriété, on appelle la méthode compute qui concatène les 2 valeurs avec une virgule. La valeur zéro est toutefois considérée comme vide, elle est donc écrasée si besoin. Cette implémentation est loin de gérer tous les cas, mais il suffit pour les exemples présents.

Nous pouvons ensuite enrichir notre DSL afin d'interpréter "tous les vendredis (à minuit)", en utilisant des constantes :

// constantes des jours de la semaine
final def monday = new Cron(dayOfWeek:'MON', h:'0', mn:'0', s:'0')
final def friday = new Cron(dayOfWeek:'FRI', h:'0', mn:'0', s:'0')

// lundi (à minuit)
println monday // affiche '0 0 0 ? * MON'

// nouvelle méthode every qui prend des Cron en paramètre
public Cron every(Cron... crons) {
    Cron sum = new Cron()
    crons.each{ sum += it }
    return sum
}

// équivalent à tous les lundis (à minuit)
println every(monday) // affiche '0 0 0 ? * MON'

// tous les lundis et vendredis à 22h
println every(monday, friday) + at(22.h) // affiche '0 0 22 ? * MON,FRI'

Vous avez compris le principe, on pourrait continuer à enrichir le DSL, en ajoutant par exemple la gestion des intervalles de temps "de 8h à 12h". Cela pourrait se faire en surchargeant l'opérateur Duration.minus() afin d'écrire "at(8.h-12.h)". En fait la principale contrainte est votre imagination !

Récapitulatif

Revenons sur les points abordés pour ce mini-DSL :

  • paramètres nommés : donne un aspect assez intuitif aux constructeurs
  • ExpandoMetaClass : permet d'enrichir le comportement d'une classe
  • interception de propriétés/méthodes : permet de créer des objets au runtime '5.mn'
  • surcharge d'opérateurs : pour redéfinir le comportement d'un opérateur

D'autres caractéristiques de Groovy sont intéressantes pour les DSL :

Les builders sont particulièrement puissants car ils permettent d'écrire ou de parser tout type de structure arborescente (XML, HTML, Swing, etc) de façon très intuitive. Par exemple, les fichiers de configuration XML sont souvent très verbeux, on peut utiliser un builder pour simplifier leur écriture. Steven Devijver nous montre un exemple de DSL (fait en 2h) pour configurer Architecture rules.

La prochaine étape serait d'intégrer le code Groovy dans une application Java mais cela fera l'objet d'un autre billet ...

Publié par

Commentaire

2 réponses pour " Introduction aux DSL avec Groovy "

  1. Publié par , Il y a 11 ans

    Décidément, plus je vous lis, plus je suis impressionné par la qualité de votre blog.
    Merci encore pour la qualité de vos articles, pour leur richesse et leur diversité.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.