paint-brush
So entwickeln Sie ein DSL in Kotlinvon@yaf
2,105 Lesungen
2,105 Lesungen

So entwickeln Sie ein DSL in Kotlin

von Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

Zu lang; Lesen

Domänenspezifische Sprachen sind einfacher, bequemer und aussagekräftiger, um die Aufgaben des Fachgebiets zu beschreiben. Kotlin ist eine moderne Programmiersprache mit vielen Funktionen und syntaktischem Zucker und eignet sich daher hervorragend als Hostsprache für internes DSL. Der Artikel beschreibt, wie Sie die verschiedenen Funktionen von Kotlin nutzen, um eine DSL zum Definieren von Geschäftsprozessen zu erstellen.
featured image - So entwickeln Sie ein DSL in Kotlin
Fedor Yaremenko HackerNoon profile picture


Programmierer streiten ständig darüber, welche Sprache die beste ist. Einmal haben wir C und Pascal verglichen, aber die Zeit verging. Die Schlachten zwischen Python / Ruby und Java /C# liegen bereits hinter uns. Jede Sprache hat ihre Vor- und Nachteile, weshalb wir sie vergleichen. Idealerweise möchten wir die Sprachen entsprechend unseren eigenen Bedürfnissen erweitern. Programmierer haben diese Möglichkeit schon sehr lange. Wir kennen verschiedene Arten der Metaprogrammierung, also der Erstellung von Programmen zur Erstellung von Programmen. Selbst triviale Makros in C ermöglichen es Ihnen, aus kleinen Beschreibungen große Codeblöcke zu generieren. Allerdings sind diese Makros unzuverlässig, begrenzt und nicht sehr ausdrucksstark. Moderne Sprachen verfügen über viel ausdrucksstärkere Erweiterungsmöglichkeiten. Eine dieser Sprachen ist Kotlin.


Die Definition einer domänenspezifischen Sprache

Eine domänenspezifische Sprache (DSL) ist eine Sprache, die im Gegensatz zu Allzwecksprachen wie Java, C#, C++ und anderen speziell für einen bestimmten Themenbereich entwickelt wurde. Dies bedeutet, dass es einfacher, bequemer und aussagekräftiger ist, die Aufgaben des Fachgebiets zu beschreiben, aber gleichzeitig unbequem und unpraktisch für die Lösung alltäglicher Aufgaben, d. h. es handelt sich nicht um eine universelle Sprache. Als Beispiel für DSL, Sie können die reguläre Ausdruckssprache verwenden. Der Themenbereich regulärer Ausdrücke ist das Format von Strings.


Um die Zeichenfolge auf Formatkonformität zu überprüfen, reicht es aus, einfach eine Bibliothek zu verwenden, die die Unterstützung für reguläre Ausdrücke implementiert:

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


Wenn Sie die Zeichenfolge auf Übereinstimmung mit dem angegebenen Format in einer universellen Sprache, beispielsweise Java, überprüfen, erhalten Sie den folgenden Code:

 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(); }


Der obige Code ist schwerer zu lesen als reguläre Ausdrücke, es ist einfacher, Fehler zu machen und es ist schwieriger, Änderungen vorzunehmen.


Weitere gängige Beispiele für DSL sind HTML, CSS, SQL, UML und BPMN (die beiden letzteren verwenden grafische Notation). DSLs werden nicht nur von Entwicklern, sondern auch von Testern und Nicht-IT-Spezialisten genutzt.


Arten von DSL

DSLs werden in zwei Typen unterteilt: externe und interne. Externe DSL- Sprachen haben ihre eigene Syntax und sind nicht von der universellen Programmiersprache abhängig, in der ihre Unterstützung implementiert ist.


Vor- und Nachteile externer DSLs:

🟢 Codegenerierung in verschiedenen Sprachen / vorgefertigten Bibliotheken

🟢 Weitere Optionen zum Festlegen Ihrer Syntax

🔴 Verwendung spezieller Tools: ANTLR, yacc, lex

🔴 Manchmal ist es schwierig, Grammatik zu beschreiben

🔴 Es gibt keine IDE-Unterstützung, Sie müssen Ihre Plugins schreiben


Interne DSLs basieren auf einer bestimmten universellen Programmiersprache (Hostsprache). Das heißt, mit Hilfe von Standardtools der Wirtssprache werden Bibliotheken erstellt, die ein kompakteres Schreiben ermöglichen. Betrachten Sie als Beispiel den Fluent API-Ansatz.


Vor- und Nachteile interner DSLs:

🟢 Verwendet die Ausdrücke der Gastsprache als Grundlage

🟢 Es ist einfach, DSL in den Code der Hostsprachen einzubetten und umgekehrt

🟢 Erfordert keine Codegenerierung

🟢 Kann als Unterprogramm in der Hostsprache debuggt werden

🔴 Begrenzte Möglichkeiten bei der Einstellung der Syntax


Ein Beispiel aus dem wirklichen Leben

Vor kurzem standen wir im Unternehmen vor der Notwendigkeit, unser DSL einzurichten. Unser Produkt hat die Funktionalität der Kaufannahme implementiert. Dieses Modul ist eine Mini-Engine von BPM (Business Process Management). Geschäftsprozesse werden häufig grafisch dargestellt. Die folgende BPMN-Notation zeigt beispielsweise einen Prozess, der aus der Ausführung von Aufgabe 1 und der anschließenden parallelen Ausführung von Aufgabe 2 und Aufgabe 3 besteht.


Für uns war es wichtig, Geschäftsprozesse programmgesteuert erstellen zu können, einschließlich der dynamischen Erstellung einer Route, der Festlegung von Ausführenden für Genehmigungsphasen, der Festlegung der Frist für die Phasenausführung usw. Dazu haben wir zunächst versucht, dieses Problem mithilfe der Fluent-API zu lösen Ansatz.


Dann kamen wir zu dem Schluss, dass das Festlegen von Akzeptanzrouten mithilfe der Fluent-API immer noch umständlich ist, und unser Team erwog die Möglichkeit, ein eigenes DSL zu erstellen. Wir haben untersucht, wie der Akzeptanzweg auf einem externen DSL und einem internen DSL auf Basis von Kotlin aussehen würde (da unser Produktcode in Java und Kotlin geschrieben ist).


Externes DSL:

 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


Internes DSL:

 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 } }


Bis auf die geschweiften Klammern sind beide Optionen nahezu gleich. Daher wurde beschlossen, keine Zeit und Mühe mit der Entwicklung eines externen DSL zu verschwenden, sondern ein internes DSL zu erstellen.


Umsetzung der Grundstruktur des DSL

Beginnen wir mit der Entwicklung eines Objektmodells

 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 } }


Lambdas

Schauen wir uns zunächst die AcceptanceContext -Klasse an. Es dient zum Speichern einer Sammlung von Routenelementen und wird zur Darstellung des gesamten Diagramms sowie parallel Blöcke verwendet.

Die Methoden addStep und parallel verwenden ein Lambda mit einem Empfänger als Parameter.


Ein Lambda mit einem Empfänger ist eine Möglichkeit, einen Lambda-Ausdruck zu definieren, der Zugriff auf ein bestimmtes Empfängerobjekt hat. Innerhalb des Hauptteils des Funktionsliterals wird das an einen Aufruf übergebene Empfängerobjekt zu einem impliziten this , sodass Sie ohne zusätzliche Qualifizierer auf die Mitglieder dieses Empfängerobjekts zugreifen oder mit einem this Ausdruck auf das Empfängerobjekt zugreifen können.


Wenn das letzte Argument eines Methodenaufrufs außerdem Lambda ist, kann das Lambda außerhalb der Klammern platziert werden. Deshalb können wir in unserem DSL einen Code wie den folgenden schreiben:

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


Dies entspricht einem Code ohne syntaktischen Zucker:

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


Lambdas mit Empfängern und Lambda außerhalb der Klammern sind Kotlin-Funktionen, die besonders nützlich sind, wenn mit DSLs gearbeitet wird.


Objektdeklaration

Schauen wir uns nun die acceptance an. acceptance ist ein Objekt. In Kotlin ist eine Objektdeklaration eine Möglichkeit, einen Singleton zu definieren – eine Klasse mit nur einer Instanz. Die Objektdeklaration definiert also gleichzeitig die Klasse und ihre einzelne Instanz.


Überladung des „invoke“-Operators

Darüber hinaus ist der invoke für das accreditation überlastet. Der invoke ist eine spezielle Funktion, die Sie in Ihren Klassen definieren können. Wenn Sie eine Instanz einer Klasse wie eine Funktion aufrufen, wird die invoke aufgerufen. Dadurch können Sie Objekte als Funktionen behandeln und sie funktionsähnlich aufrufen.


Beachten Sie, dass der Parameter der invoke auch ein Lambda mit einem Empfänger ist. Jetzt können wir einen Akzeptanzweg definieren …

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


…und hindurchgehen

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


Details hinzufügen

Infix-Funktionen

Schauen Sie sich diesen Code an

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


Wir können dies wie folgt umsetzen:

 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)


Mit der ExecutorCondition Klasse können wir mehrere mögliche Task-Ausführer festlegen. In ExecutorCondition definieren wir die Infix-Funktion or . Eine Infix-Funktion ist eine besondere Art von Funktion, die es Ihnen ermöglicht, sie mit einer natürlicheren Infix-Notation aufzurufen.


Ohne diese Funktion der Sprache müssten wir so schreiben:

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


Infix-Funktionen werden auch verwendet, um den erforderlichen Status des Protokolls und der Zeit mit einer Zeitzone festzulegen.

 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) }


Erweiterungsfunktionen

String.timezone ist eine Erweiterungsfunktion. In Kotlin können Sie mit Erweiterungsfunktionen neue Funktionen zu vorhandenen Klassen hinzufügen, ohne deren Quellcode zu ändern. Diese Funktion ist besonders nützlich, wenn Sie die Funktionalität von Klassen erweitern möchten, über die Sie keine Kontrolle haben, z. B. Klassen aus Standard- oder externen Bibliotheken.


Nutzung im DSL:

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


Hier ist "2022-12-08 08:00" ein Empfängerobjekt, auf dem die Erweiterungsfunktion timezone aufgerufen wird, und PST ist der Parameter. Mit this Schlüsselwort wird auf das Empfängerobjekt zugegriffen.

Überlastung des Bedieners

Die nächste Kotlin-Funktion, die wir in unserem DSL verwenden, ist die Überlastung des Betreibers. Wir haben die Überlastung des invoke bereits berücksichtigt. In Kotlin können Sie andere Operatoren überladen, auch arithmetische.

 addStep { ... +canChange }


Hier ist der unäre Operator + überladen. Nachfolgend finden Sie den Code, der dies implementiert:

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


Feinschliff

Jetzt können wir Akzeptanzwege auf unserem DSL beschreiben. Allerdings sollten DSL-Nutzer vor möglichen Fehlern geschützt werden. In der aktuellen Version ist beispielsweise der folgende Code akzeptabel:

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


addStep innerhalb von addStep sieht seltsam aus, nicht wahr? Lassen Sie uns herausfinden, warum dieser Code erfolgreich und ohne Fehler kompiliert werden kann. Wie oben erwähnt, akzeptieren die Methoden acceptance#invoke und AcceptanceContext#addStep ein Lambda mit einem Empfänger als Parameter, und auf ein Empfängerobjekt kann über das Schlüsselwort „ this zugegriffen werden. Wir können den vorherigen Code also wie folgt umschreiben:

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


Jetzt können Sie sehen, dass [email protected] beide Male aufgerufen wird. Speziell für solche Fälle verfügt Kotlin über eine DslMarker Annotation. Sie können @DslMarker verwenden, um benutzerdefinierte Anmerkungen zu definieren. Auf Empfänger, die mit derselben Anmerkung gekennzeichnet sind, kann nicht ineinander zugegriffen werden.

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


Jetzt der Code

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


wird aufgrund eines Fehlers nicht kompiliert 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

Links

Nachfolgend finden Sie Links zur offiziellen Kotlin-Dokumentation zu den Sprachfunktionen, die in diesem Artikel berücksichtigt wurden:



Abschluss

Domänenspezifische Sprachen bieten ein leistungsstarkes Mittel zur Steigerung der Produktivität, zur Reduzierung von Fehlern und zur Verbesserung der Zusammenarbeit, indem sie eine spezialisierte und ausdrucksstarke Möglichkeit bieten, Probleme innerhalb einer bestimmten Domäne zu modellieren und zu lösen. Kotlin ist eine moderne Programmiersprache mit vielen Funktionen und syntaktischem Zucker und eignet sich daher hervorragend als Hostsprache für internes DSL.