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 / und /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. Python Ruby Java Die Definition einer domänenspezifischen Sprache Eine 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. domänenspezifische Sprache (DSL) 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. Sprachen haben ihre eigene Syntax und sind nicht von der universellen Programmiersprache abhängig, in der ihre Unterstützung implementiert ist. Externe DSL- 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 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. Interne DSLs 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 geschrieben ist). Kotlin 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 -Klasse an. Es dient zum Speichern einer Sammlung von Routenelementen und wird zur Darstellung des gesamten Diagramms sowie Blöcke verwendet. AcceptanceContext parallel Die Methoden und verwenden ein Lambda mit einem Empfänger als Parameter. addStep parallel 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 , sodass Sie ohne zusätzliche Qualifizierer auf die Mitglieder dieses Empfängerobjekts zugreifen oder mit einem Ausdruck auf das Empfängerobjekt zugreifen können. this this 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 an. 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. acceptance acceptance Überladung des „invoke“-Operators Darüber hinaus ist der für das überlastet. Der ist eine spezielle Funktion, die Sie in Ihren Klassen definieren können. Wenn Sie eine Instanz einer Klasse wie eine Funktion aufrufen, wird die aufgerufen. Dadurch können Sie Objekte als Funktionen behandeln und sie funktionsähnlich aufrufen. invoke accreditation invoke invoke Beachten Sie, dass der Parameter der auch ein Lambda mit einem Empfänger ist. Jetzt können wir einen Akzeptanzweg definieren … invoke 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 Klasse können wir mehrere mögliche Task-Ausführer festlegen. In definieren wir die Infix-Funktion . Eine Infix-Funktion ist eine besondere Art von Funktion, die es Ihnen ermöglicht, sie mit einer natürlicheren Infix-Notation aufzurufen. ExecutorCondition ExecutorCondition or 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 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. String.timezone Nutzung im DSL: addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... } Hier ist ein Empfängerobjekt, auf dem die Erweiterungsfunktion aufgerufen wird, und ist der Parameter. Mit Schlüsselwort wird auf das Empfängerobjekt zugegriffen. "2022-12-08 08:00" timezone PST this Überlastung des Bedieners Die nächste Kotlin-Funktion, die wir in unserem DSL verwenden, ist die Überlastung des Betreibers. Wir haben die Überlastung des bereits berücksichtigt. In Kotlin können Sie andere Operatoren überladen, auch arithmetische. invoke 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 } } } innerhalb von 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 und ein Lambda mit einem Empfänger als Parameter, und auf ein Empfängerobjekt kann über das Schlüsselwort „ zugegriffen werden. Wir können den vorherigen Code also wie folgt umschreiben: addStep addStep acceptance#invoke AcceptanceContext#addStep this val acceptanceRoute = acceptance { this@acceptance.addStep { this@addStep.executor = HEAD_OF_DEPARTMENT this@addStep.duration = days(7) this@addStep.protocol shouldBe signed this@acceptance.addStep { executor = FINANCE_DEPARTMENT } } } Jetzt können Sie sehen, dass beide Male aufgerufen wird. Speziell für solche Fälle verfügt Kotlin über eine Annotation. Sie können verwenden, um benutzerdefinierte Anmerkungen zu definieren. Auf Empfänger, die mit derselben Anmerkung gekennzeichnet sind, kann nicht ineinander zugegriffen werden. this@acceptance.addStep DslMarker @DslMarker @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: Funktionsliterale mit Empfänger https://kotlinlang.org/docs/lambdas.html#function-literals-with-receiver Übergeben von nachgestellten Lambdas https://kotlinlang.org/docs/lambdas.html#passing-trailing-lambdas Objektdeklarationen https://kotlinlang.org/docs/object-declarations.html#object-declarations-overview Operatorüberladung https://kotlinlang.org/docs/operator-overloading.html Infix-Notation https://kotlinlang.org/docs/functions.html#infix-notation Erweiterungsfunktionen https://kotlinlang.org/docs/extensions.html#extension-functions Bereichskontrolle: @DslMarker https://kotlinlang.org/docs/type-safe-builders.html#scope-control-dslmarker 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.