paint-brush
Как разработать DSL в Kotlinк@yaf
2,102 чтения
2,102 чтения

Как разработать DSL в Kotlin

к Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

Слишком долго; Читать

Предметно-ориентированные языки проще, удобнее и выразительнее описывают задачи предметной области. Kotlin — это современный язык программирования со множеством функций и синтаксическим сахаром, поэтому он отлично подходит в качестве основного языка для внутреннего DSL. В статье описывается, как использовать различные возможности Kotlin для создания DSL для определения бизнес-процессов.
featured image - Как разработать DSL в Kotlin
Fedor Yaremenko HackerNoon profile picture


Программисты постоянно спорят о том, какой язык лучший. Однажды мы сравнивали Си и Паскаль, но время шло. Битвы Python / Ruby и Java /C# уже позади. У каждого языка есть свои плюсы и минусы, поэтому мы их сравниваем. В идеале мы хотели бы расширить количество языков в соответствии с нашими потребностями. У программистов была такая возможность уже очень давно. Мы знаем разные способы метапрограммирования, то есть создания программ для создания программ. Даже тривиальные макросы на языке C позволяют генерировать большие фрагменты кода из небольших описаний. Однако эти макросы ненадежны, ограничены и маловыразительны. Современные языки обладают гораздо более выразительными средствами расширения. Одним из таких языков является Котлин.


Определение предметно-ориентированного языка

Предметно-ориентированный язык (DSL) — это язык, разработанный специально для конкретной предметной области, в отличие от языков общего назначения, таких как Java, C#, C++ и других. Это означает, что описывать задачи предметной области проще, удобнее и выразительнее, но в то же время он неудобен и непрактичен для решения повседневных задач, т.е. не является универсальным языком. DSL, можно взять язык регулярных выражений. Предметной областью регулярных выражений является формат строк.


Чтобы проверить строку на соответствие формату, достаточно просто использовать библиотеку, реализующую поддержку регулярных выражений:

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


Если вы проверите строку на соответствие заданному формату на универсальном языке, например Java, вы получите следующий код:

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


Приведенный выше код труднее читать, чем регулярные выражения, в нем легче допускать ошибки и сложнее вносить изменения.


Другими распространенными примерами DSL являются HTML, CSS, SQL, UML и BPMN (последние два используют графическую нотацию). DSL используются не только разработчиками, но также тестировщиками и специалистами, не связанными с ИТ.


Типы DSL

DSL делятся на два типа: внешние и внутренние. Внешние языки DSL имеют собственный синтаксис и не зависят от универсального языка программирования, в котором реализована их поддержка.


Плюсы и минусы внешних DSL:

🟢 Генерация кода на разных языках/готовые библиотеки

🟢 Дополнительные возможности настройки синтаксиса

🔴 Использование специализированных инструментов: ANTLR, yacc, lex.

🔴 Иногда сложно описать грамматику

🔴 Нет поддержки IDE, нужно писать свои плагины


Внутренние DSL основаны на определенном универсальном языке программирования (хост-языке). То есть с помощью стандартных средств основного языка создаются библиотеки, позволяющие писать более компактно. В качестве примера рассмотрим подход Fluent API.


Плюсы и минусы внутренних DSL:

🟢 Использует в качестве основы выражения принимающего языка

🟢 Легко встроить DSL в код на хост-языках и наоборот

🟢 Не требует генерации кода

🟢 Может быть отлажена как подпрограмма на основном языке.

🔴 Ограниченные возможности настройки синтаксиса


Реальный пример

Недавно мы в компании столкнулись с необходимостью создания своего DSL. В нашем продукте реализован функционал приема покупок. Этот модуль представляет собой мини-движок BPM (Управление бизнес-процессами). Бизнес-процессы часто представляются графически. Например, в нотации BPMN ниже показан процесс, который состоит из выполнения задачи 1, а затем параллельного выполнения задач 2 и задачи 3.


Нам было важно иметь возможность программно создавать бизнес-процессы, в том числе динамически строить маршрут, задавать исполнителей для этапов согласования, задавать сроки выполнения этапов и т. д. Для этого мы сначала попытались решить эту задачу с помощью Fluent API. подход.


Тогда мы пришли к выводу, что настройка маршрутов приема с помощью Fluent API по-прежнему оказывается громоздкой и наша команда рассмотрела вариант создания собственного DSL. Мы исследовали, как будет выглядеть маршрут принятия на внешнем DSL и внутреннем DSL на основе Kotlin (поскольку код нашего продукта написан на Java и Kotlin ).


Внешний 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


Внутренний 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 } }


За исключением фигурных скобок, оба варианта практически одинаковы. Поэтому было решено не тратить время и силы на разработку внешнего DSL, а создать внутренний DSL.


Реализация базовой структуры DSL

Приступим к разработке объектной модели

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


Лямбды

Сначала давайте посмотрим на класс AcceptanceContext . Он предназначен для хранения набора элементов маршрута и используется для представления всей диаграммы, а также parallel блоков.

Методы addStep и parallel принимают лямбду с получателем в качестве параметра.


Лямбда-выражение с получателем — это способ определить лямбда-выражение, имеющее доступ к определенному объекту-получателю. Внутри тела функционального литерала объект-получатель, переданный в вызов, становится неявным this , так что вы можете получить доступ к членам этого объекта-получателя без каких-либо дополнительных квалификаторов или получить доступ к объекту-получателю с помощью выражения this .


Кроме того, если последним аргументом вызова метода является лямбда, то лямбда может быть помещена вне круглых скобок. Вот почему в нашем DSL мы можем написать такой код:

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


Это эквивалентно коду без синтаксического сахара:

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


Лямбды с получателями и лямбда вне круглых скобок — это функции Kotlin, которые особенно полезны при работе с DSL.


Объявление объекта

Теперь давайте посмотрим на acceptance сущности. acceptance — это объект. В Котлине объявление объекта — это способ определить синглтон — класс только с одним экземпляром. Таким образом, объявление объекта одновременно определяет и класс, и его единственный экземпляр.


перегрузка оператора «вызова»

Кроме того, для объекта accreditation перегружен оператор invoke . Оператор invoke — это специальная функция, которую вы можете определить в своих классах. Когда вы вызываете экземпляр класса, как если бы это была функция, invoke операторная функция вызова. Это позволяет вам обращаться с объектами как с функциями и вызывать их как функции.


Обратите внимание, что параметр метода invoke также является лямбдой с получателем. Теперь мы можем определить маршрут приемки…

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


… и пройти через него

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


Добавление деталей

Инфиксные функции

Взгляните на этот код

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


Мы можем реализовать это следующим образом:

 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)


Класс ExecutorCondition позволяет нам задать несколько возможных исполнителей задач. В ExecutorCondition мы определяем инфиксную функцию or . Инфиксная функция — это особый вид функции, который позволяет вызывать ее, используя более естественную инфиксную запись.


Без использования этой особенности языка нам пришлось бы писать так:

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


Инфиксные функции также используются для установки необходимого состояния протокола и времени с часовым поясом.

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


Функции расширения

String.timezone — это функция расширения. В Kotlin функции расширения позволяют добавлять новые функции к существующим классам без изменения их исходного кода. Эта функция особенно полезна, когда вы хотите расширить функциональность классов, над которыми у вас нет контроля, например классов из стандартных или внешних библиотек.


Использование в DSL:

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


Здесь "2022-12-08 08:00" — это объект-получатель, для которого вызывается timezone функции расширения, а PST — параметр. Доступ к объекту-получателю осуществляется с помощью this ключевого слова.

Перегрузка оператора

Следующая функция Kotlin, которую мы используем в нашем DSL, — это перегрузка операторов. Мы уже рассмотрели перегрузку оператора invoke . В Котлине можно перегружать другие операторы, в том числе арифметические.

 addStep { ... +canChange }


Здесь унарный оператор + перегружен. Ниже приведен код, который это реализует:

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


Завершающий штрих

Теперь мы можем описать маршруты приема на нашем DSL. Однако пользователи DSL должны быть защищены от возможных ошибок. Например, в текущей версии допустим следующий код:

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


addStep внутри addStep выглядит странно, не правда ли? Давайте разберемся, почему этот код успешно компилируется без ошибок. Как упоминалось выше, методы acceptance#invoke и AcceptanceContext#addStep принимают лямбда-выражение с получателем в качестве параметра, а объект-получатель доступен по ключевому слову this . Итак, мы можем переписать предыдущий код следующим образом:

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


Теперь вы можете видеть, что [email protected] вызывается оба раза. Специально для таких случаев в Котлине есть аннотация DslMarker . Вы можете использовать @DslMarker для определения пользовательских аннотаций. К получателям, отмеченным одной и той же такой аннотацией, нельзя получить доступ друг в друге.

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


Теперь код

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


не будет компилироваться из-за ошибки 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

Ссылки

Ниже приведены ссылки на официальную документацию Kotlin по возможностям языка, рассмотренным в этой статье:



Заключение

Языки, ориентированные на предметную область, предлагают мощные средства повышения производительности, уменьшения количества ошибок и улучшения совместной работы, предоставляя специализированный и выразительный способ моделирования и решения проблем в конкретной области. Kotlin — это современный язык программирования со множеством функций и синтаксическим сахаром, поэтому он отлично подходит в качестве основного языка для внутреннего DSL.