paint-brush
Comment développer un DSL dans Kotlinby@yaf
1,832
1,832

Comment développer un DSL dans Kotlin

Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

Les langages spécifiques à un domaine sont plus faciles, plus pratiques et plus expressifs pour décrire les tâches du domaine. Kotlin est un langage de programmation moderne avec de nombreuses fonctionnalités et du sucre syntaxique, il constitue donc un excellent langage hôte pour le DSL interne. L'article décrit comment utiliser les différentes fonctionnalités de Kotlin pour créer un DSL permettant de définir des processus métier.
featured image - Comment développer un DSL dans Kotlin
Fedor Yaremenko HackerNoon profile picture


Les programmeurs se disputent constamment pour savoir quel langage est le meilleur. Une fois, nous avons comparé C et Pascal, mais le temps a passé. Les batailles de Python / Ruby et Java /C# sont déjà derrière nous. Chaque langue a ses avantages et ses inconvénients, c'est pourquoi nous les comparons. Idéalement, nous aimerions étendre les langues pour répondre à nos propres besoins. Les programmeurs ont cette opportunité depuis très longtemps. Nous connaissons différentes manières de métaprogrammer, c'est-à-dire de créer des programmes pour créer des programmes. Même les macros triviales en C vous permettent de générer de gros morceaux de code à partir de petites descriptions. Cependant, ces macros sont peu fiables, limitées et peu expressives. Les langues modernes disposent de moyens d’extension beaucoup plus expressifs. L'une de ces langues est Kotlin.


La définition d'un langage spécifique à un domaine

Un langage spécifique à un domaine (DSL) est un langage développé spécifiquement pour un domaine spécifique, contrairement aux langages généraux tels que Java, C#, C++ et autres. Cela signifie qu'il est plus facile, plus pratique et plus expressif de décrire les tâches du domaine, mais en même temps, cela est peu pratique et peu pratique pour résoudre les tâches quotidiennes, c'est-à-dire que ce n'est pas un langage universel. Comme exemple de DSL, vous pouvez utiliser le langage d'expression régulière. Le domaine des expressions régulières est le format des chaînes.


Pour vérifier la conformité de la chaîne au format, il suffit d'utiliser simplement une bibliothèque qui implémente le support des expressions régulières :

 private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); }


Si vous vérifiez la conformité de la chaîne au format spécifié dans un langage universel, par exemple Java, vous obtiendrez le code suivant :

 private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); }


Le code ci-dessus est plus difficile à lire que les expressions régulières, il est plus facile de faire des erreurs et plus délicat d'apporter des modifications.


D'autres exemples courants de DSL sont HTML, CSS, SQL, UML et BPMN (ces deux derniers utilisent la notation graphique). Les DSL sont utilisés non seulement par les développeurs mais également par les testeurs et les non-spécialistes en informatique.


Types de DSL

Les DSL sont divisés en deux types : externes et internes. Les langages DSL externes ont leur propre syntaxe et ne dépendent pas du langage de programmation universel dans lequel leur prise en charge est implémentée.


Avantages et inconvénients des DSL externes :

🟢 Génération de code dans différents langages / bibliothèques prêtes à l'emploi

🟢 Plus d'options pour définir votre syntaxe

🔴 Utilisation d'outils spécialisés : ANTLR, yacc, lex

🔴 Il est parfois difficile de décrire la grammaire

🔴 Il n'y a pas de support IDE, vous devez écrire vos plugins


Les DSL internes sont basés sur un langage de programmation universel spécifique (langage hôte). Autrement dit, à l'aide d'outils standard du langage hôte, des bibliothèques sont créées qui vous permettent d'écrire de manière plus compacte. À titre d'exemple, considérons l'approche API Fluent.


Avantages et inconvénients des DSL internes :

🟢 Utilise comme base les expressions de la langue d'accueil

🟢 Il est facile d'intégrer le DSL dans le code des langues hôtes et vice versa

🟢 Ne nécessite pas de génération de code

🟢 Peut être débogué en tant que sous-programme dans la langue hôte

🔴 Possibilités limitées de définition de la syntaxe


Un exemple concret

Récemment, nous, dans l'entreprise, avons été confrontés à la nécessité de créer notre DSL. Notre produit a implémenté la fonctionnalité d'acceptation d'achat. Ce module est un mini-moteur de BPM (Business Process Management). Les processus métier sont souvent représentés graphiquement. Par exemple, la notation BPMN ci-dessous montre un processus qui consiste à exécuter la tâche 1, puis à exécuter la tâche 2 et la tâche 3 en parallèle.


Il était important pour nous de pouvoir créer des processus métier par programmation, notamment la création dynamique d'un itinéraire, la définition des exécutants pour les étapes d'approbation, la définition du délai d'exécution des étapes, etc. Pour ce faire, nous avons d'abord essayé de résoudre ce problème en utilisant l'API Fluent. approche.


Nous avons ensuite conclu que la définition de routes d'acceptation à l'aide de l'API Fluent s'avère toujours fastidieuse et notre équipe a envisagé la possibilité de créer son propre DSL. Nous avons étudié à quoi ressemblerait la voie d'acceptation sur un DSL externe et un DSL interne basé sur Kotlin (car notre code produit est écrit en Java et Kotlin ).


ADSL externe :

 acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed


ADSL interne :

 acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } }


À l’exception des accolades, les deux options sont presque identiques. Par conséquent, il a été décidé de ne pas perdre de temps et d'efforts à développer un DSL externe, mais de créer un DSL interne.


Mise en œuvre de la structure de base du DSL

Commençons par développer un modèle objet

 interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } }


Lambda

Tout d’abord, regardons la classe AcceptanceContext . Il est conçu pour stocker une collection d'éléments d'itinéraire et est utilisé pour représenter l'ensemble du diagramme ainsi que les blocs parallel .

Les méthodes addStep et parallel prennent un lambda avec un récepteur comme paramètre.


Un lambda avec un récepteur est un moyen de définir une expression lambda qui a accès à un objet récepteur spécifique. Dans le corps du littéral de fonction, l'objet récepteur passé à un appel devient un this implicite, de sorte que vous pouvez accéder aux membres de cet objet récepteur sans aucun qualificatif supplémentaire, ou accéder à l'objet récepteur à l'aide d'une expression this .


De plus, si le dernier argument d'un appel de méthode est lambda, alors le lambda peut être placé en dehors des parenthèses. C'est pourquoi dans notre DSL nous pouvons écrire un code comme celui-ci :

 parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } }


Cela équivaut à un code sans sucre syntaxique :

 parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) })


Les Lambdas avec récepteurs et Lambda en dehors des parenthèses sont des fonctionnalités Kotlin particulièrement utiles lorsque vous travaillez avec des DSL.


Déclaration d'objet

Voyons maintenant l' acceptance de l'entité. acceptance est un objet. Dans Kotlin, une déclaration d'objet est un moyen de définir un singleton, une classe avec une seule instance. Ainsi, la déclaration d'objet définit à la fois la classe et son instance unique.


Surcharge de l'opérateur « invoquer »

De plus, l'opérateur invoke est surchargé pour l'objet accreditation . L'opérateur invoke est une fonction spéciale que vous pouvez définir dans vos classes. Lorsque vous invoquez une instance d'une classe comme s'il s'agissait d'une fonction, la fonction de l'opérateur invoke est appelée. Cela vous permet de traiter les objets comme des fonctions et de les appeler de manière similaire à une fonction.


Notez que le paramètre de la méthode invoke est également un lambda avec un récepteur. Nous pouvons maintenant définir une voie d’acceptation…

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } }


…et parcourez-le

 val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext


Ajout de détails

Fonctions d'infixe

Jetez un oeil à ce code

 addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... }


Nous pouvons implémenter cela comme suit :

 enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)


La classe ExecutorCondition nous permet de définir plusieurs exécuteurs de tâches possibles. Dans ExecutorCondition nous définissons la fonction infixe or . Une fonction infixe est un type spécial de fonction qui vous permet de l'appeler en utilisant une notation infixe plus naturelle.


Sans utiliser cette fonctionnalité du langage, il faudrait écrire ainsi :

 addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... }


Les fonctions Infix sont également utilisées pour définir l'état requis du protocole et l'heure avec un fuseau horaire.

 enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) }


Fonctions d'extension

String.timezone est une fonction d'extension. Dans Kotlin, les fonctions d'extension permettent d'ajouter de nouvelles fonctions aux classes existantes sans modifier leur code source. Cette fonctionnalité est particulièrement utile lorsque vous souhaitez augmenter les fonctionnalités de classes sur lesquelles vous n'avez aucun contrôle, telles que les classes issues de bibliothèques standards ou externes.


Utilisation dans le DSL :

 addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... }


Ici "2022-12-08 08:00" est un objet récepteur, sur lequel la fonction d'extension timezone est appelée, et PST est le paramètre. L'objet récepteur est accessible à l'aide this mot-clé.

Surcharge des opérateurs

La prochaine fonctionnalité Kotlin que nous utilisons dans notre DSL est la surcharge des opérateurs. Nous avons déjà considéré la surcharge de l'opérateur invoke . Dans Kotlin, vous pouvez surcharger d’autres opérateurs, y compris arithmétiques.

 addStep { ... +canChange }


Ici, l'opérateur unaire + est surchargé. Voici le code qui implémente cela :

 class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } }


La touche finale

Nous pouvons maintenant décrire les voies d'acceptation sur notre DSL. Cependant, les utilisateurs DSL doivent être protégés contre d'éventuelles erreurs. Par exemple, dans la version actuelle, le code suivant est acceptable :

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } }


addStep dans addStep semble étrange, n'est-ce pas ? Voyons pourquoi ce code se compile avec succès sans aucune erreur. Comme mentionné ci-dessus, les méthodes acceptance#invoke et AcceptanceContext#addStep prennent un lambda avec un récepteur comme paramètre, et un objet récepteur est accessible par un mot-clé this . On peut donc réécrire le code précédent comme ceci :

 val acceptanceRoute = acceptance { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }


Vous pouvez maintenant voir que [email protected] est appelé les deux fois. Surtout pour de tels cas, Kotlin a une annotation DslMarker . Vous pouvez utiliser @DslMarker pour définir des annotations personnalisées. Les récepteurs marqués de la même annotation ne sont pas accessibles les uns dans les autres.

 @DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... }


Maintenant le code

 val acceptanceRoute = acceptance { addStep { ... addStep { ... } } }


ne sera pas compilé en raison d'une erreur 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

Liens

Vous trouverez ci-dessous des liens vers la documentation officielle de Kotlin sur les fonctionnalités du langage prises en compte dans cet article :



Conclusion

Les langages spécifiques à un domaine offrent un moyen puissant d'améliorer la productivité, de réduire les erreurs et d'améliorer la collaboration en fournissant un moyen spécialisé et expressif de modéliser et de résoudre des problèmes dans un domaine spécifique. Kotlin est un langage de programmation moderne avec de nombreuses fonctionnalités et du sucre syntaxique, il constitue donc un excellent langage hôte pour le DSL interne.