445 lines
20 KiB
Markdown
445 lines
20 KiB
Markdown
---
|
||
title: DSL
|
||
description: Из чего собирается предметно-ориентированный язык на Kotlin и когда он оправдан
|
||
tags:
|
||
- kotlin
|
||
- functional
|
||
- dsl
|
||
---
|
||
|
||
DSL (domain-specific language) на Kotlin - это обычный Kotlin-код, который выглядит как декларативное описание. Отдельного механизма для этого нет: DSL собирается из четырёх уже знакомых возможностей.
|
||
|
||
| Возможность | Что даёт |
|
||
|---|---|
|
||
| [лямбда с получателем](Receivers%20и%20context%20parameters.md) `T.() -> Unit` | блок, внутри которого доступны методы объекта |
|
||
| [лямбда последним аргументом](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) | `html { }` вместо `html({ })` |
|
||
| [функции-расширения](Функции-расширения.md) | методы только внутри DSL |
|
||
| [операторы и `infix`](Инфиксные%20и%20операторные%20функции.md) | `"a" to 1`, `x shouldBe y` |
|
||
|
||
## Минимальный пример
|
||
```kotlin
|
||
class Pizza {
|
||
private val toppings = mutableListOf<String>()
|
||
var size = "средняя"
|
||
|
||
fun topping(name: String) { toppings.add(name) }
|
||
|
||
override fun toString() = "$size пицца: ${toppings.joinToString()}"
|
||
}
|
||
|
||
fun pizza(block: Pizza.() -> Unit): Pizza = Pizza().apply(block)
|
||
|
||
fun main() {
|
||
val order = pizza {
|
||
size = "большая"
|
||
topping("сыр")
|
||
topping("грибы")
|
||
}
|
||
println(order) // большая пицца: сыр, грибы
|
||
}
|
||
```
|
||
Три строки инфраструктуры (`fun pizza(block: ...) = Pizza().apply(block)`) превращают класс в DSL. Это весь базовый приём.
|
||
|
||
## Вложенные блоки
|
||
```kotlin
|
||
@DslMarker
|
||
annotation class HtmlDsl
|
||
|
||
@HtmlDsl
|
||
class Tag(private val name: String) {
|
||
private val children = mutableListOf<Tag>()
|
||
private val attributes = mutableMapOf<String, String>()
|
||
private var text: String? = null
|
||
|
||
fun attr(key: String, value: String) { attributes[key] = value }
|
||
|
||
operator fun String.unaryPlus() { text = this }
|
||
|
||
fun tag(name: String, block: Tag.() -> Unit = {}) {
|
||
children.add(Tag(name).apply(block))
|
||
}
|
||
|
||
fun div(block: Tag.() -> Unit) = tag("div", block)
|
||
fun p(block: Tag.() -> Unit) = tag("p", block)
|
||
|
||
fun render(): String {
|
||
val attrs = attributes.entries.joinToString("") { " ${it.key}=\"${it.value}\"" }
|
||
val inner = text ?: children.joinToString("") { it.render() }
|
||
return "<$name$attrs>$inner</$name>"
|
||
}
|
||
}
|
||
|
||
fun html(block: Tag.() -> Unit): String = Tag("html").apply(block).render()
|
||
|
||
fun main() {
|
||
val page = html {
|
||
div {
|
||
attr("class", "container")
|
||
p { +"Первый абзац" }
|
||
p { +"Второй абзац" }
|
||
}
|
||
}
|
||
println(page)
|
||
// <html><div class="container"><p>Первый абзац</p><p>Второй абзац</p></div></html>
|
||
}
|
||
```
|
||
Разбор приёмов:
|
||
- `Tag.() -> Unit` даёт вложенность: внутри `div { }` доступны методы `Tag`
|
||
- `operator fun String.unaryPlus()` даёт синтаксис `+"текст"` для содержимого
|
||
- `@DslMarker` запрещает вызвать `div { }` изнутри `div { }` через внешний получатель
|
||
- значение по умолчанию `block: Tag.() -> Unit = {}` разрешает теги без тела
|
||
|
||
## Типобезопасность
|
||
Главное отличие Kotlin-DSL от строковых шаблонов: ошибки видит компилятор.
|
||
```kotlin
|
||
class Query {
|
||
private var table: String? = null
|
||
private val conditions = mutableListOf<String>()
|
||
private var limit: Int? = null
|
||
|
||
fun from(name: String) { table = name }
|
||
fun where(condition: String) { conditions.add(condition) }
|
||
fun limit(n: Int) { limit = n }
|
||
|
||
fun build(): String {
|
||
val t = requireNotNull(table) { "не указана таблица" }
|
||
return buildString {
|
||
append("SELECT * FROM $t")
|
||
if (conditions.isNotEmpty()) append(" WHERE ${conditions.joinToString(" AND ")}")
|
||
limit?.let { append(" LIMIT $it") }
|
||
}
|
||
}
|
||
}
|
||
|
||
fun query(block: Query.() -> Unit): String = Query().apply(block).build()
|
||
|
||
fun main() {
|
||
println(query {
|
||
from("users")
|
||
where("age > 18")
|
||
where("active = true")
|
||
limit(10)
|
||
})
|
||
// SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10
|
||
|
||
try {
|
||
query { where("x = 1") }
|
||
} catch (e: IllegalArgumentException) {
|
||
println(e.message) // не указана таблица
|
||
}
|
||
}
|
||
```
|
||
Проверка обязательных полей здесь в рантайме. Вынести её в компиляцию можно через типы-состояния:
|
||
```kotlin
|
||
class EmptyQuery {
|
||
fun from(table: String) = TableQuery(table)
|
||
}
|
||
|
||
class TableQuery(private val table: String) {
|
||
private val conditions = mutableListOf<String>()
|
||
|
||
fun where(condition: String): TableQuery = apply { conditions.add(condition) }
|
||
|
||
fun build(): String =
|
||
"SELECT * FROM $table" +
|
||
if (conditions.isEmpty()) "" else " WHERE ${conditions.joinToString(" AND ")}"
|
||
}
|
||
|
||
fun main() {
|
||
println(EmptyQuery().from("orders").where("total > 100").build())
|
||
// SELECT * FROM orders WHERE total > 100
|
||
|
||
// println(EmptyQuery().build()) // ошибка компиляции: метода нет
|
||
}
|
||
```
|
||
Такой приём (phantom types) делает невозможное состояние непредставимым, но усложняет код. Для большинства DSL достаточно `require` в `build()`.
|
||
|
||
## Коллекции внутри DSL
|
||
```kotlin
|
||
class Routes {
|
||
private val routes = mutableListOf<Pair<String, String>>()
|
||
|
||
infix fun String.handledBy(handler: String) { routes.add(this to handler) }
|
||
|
||
fun group(prefix: String, block: Routes.() -> Unit) {
|
||
val nested = Routes().apply(block)
|
||
nested.routes.forEach { (path, handler) -> routes.add("$prefix$path" to handler) }
|
||
}
|
||
|
||
fun render() = routes.joinToString("\n") { "${it.first} -> ${it.second}" }
|
||
}
|
||
|
||
fun routes(block: Routes.() -> Unit) = Routes().apply(block).render()
|
||
|
||
fun main() {
|
||
println(routes {
|
||
"/" handledBy "HomeController"
|
||
group("/api") {
|
||
"/users" handledBy "UsersController"
|
||
"/orders" handledBy "OrdersController"
|
||
}
|
||
})
|
||
// / -> HomeController
|
||
// /api/users -> UsersController
|
||
// /api/orders -> OrdersController
|
||
}
|
||
```
|
||
`infix` расширение внутри класса (`String.handledBy`) существует только внутри блока `routes { }` - снаружи такого метода у `String` нет.
|
||
|
||
## Готовые DSL в stdlib
|
||
```kotlin
|
||
fun main() {
|
||
val text = buildString {
|
||
append("Привет")
|
||
appendLine(", мир")
|
||
append("Вторая строка")
|
||
}
|
||
println(text)
|
||
|
||
val list = buildList {
|
||
add(1)
|
||
addAll(listOf(2, 3))
|
||
}
|
||
println(list) // [1, 2, 3]
|
||
|
||
val map = buildMap<String, Int> {
|
||
put("a", 1)
|
||
putAll(mapOf("b" to 2))
|
||
}
|
||
println(map) // {a=1, b=2}
|
||
}
|
||
```
|
||
Эти функции - ровно тот же приём `T.() -> Unit` + `apply`.
|
||
|
||
Числовые и временные DSL:
|
||
```kotlin
|
||
import kotlin.time.Duration.Companion.seconds
|
||
import kotlin.time.Duration.Companion.minutes
|
||
|
||
fun main() {
|
||
val timeout = 2.minutes + 30.seconds
|
||
println(timeout) // 2m 30s
|
||
println(timeout.inWholeSeconds) // 150
|
||
|
||
println((1..10 step 2).toList()) // [1, 3, 5, 7, 9]
|
||
println((10 downTo 1 step 3).toList()) // [10, 7, 4, 1]
|
||
}
|
||
```
|
||
`2.minutes` - свойство-расширение на `Int`. Такой «единичный» DSL делается одной строкой и заметно улучшает читаемость конфигураций.
|
||
|
||
## Известные DSL в экосистеме
|
||
| DSL | Для чего |
|
||
|---|---|
|
||
| Gradle Kotlin DSL | сборка проекта, `build.gradle.kts` |
|
||
| Ktor | маршруты и конфигурация сервера |
|
||
| kotlinx.html | генерация HTML |
|
||
| Exposed | SQL-запросы с типобезопасностью |
|
||
| Kotest | описание тестов и матчеры |
|
||
| Compose | описание UI |
|
||
|
||
Пример в стиле Gradle - разбор см. [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md):
|
||
```kotlin
|
||
class Dependencies {
|
||
private val list = mutableListOf<String>()
|
||
fun implementation(dep: String) { list.add("implementation $dep") }
|
||
fun testImplementation(dep: String) { list.add("testImplementation $dep") }
|
||
fun render() = list.joinToString("\n")
|
||
}
|
||
|
||
class Project2 {
|
||
private var name = "unnamed"
|
||
private var deps = Dependencies()
|
||
|
||
fun name(value: String) { name = value }
|
||
fun dependencies(block: Dependencies.() -> Unit) { deps = Dependencies().apply(block) }
|
||
|
||
fun render() = "project $name\n${deps.render()}"
|
||
}
|
||
|
||
fun project(block: Project2.() -> Unit) = Project2().apply(block).render()
|
||
|
||
fun main() {
|
||
println(project {
|
||
name("demo")
|
||
dependencies {
|
||
implementation("kotlin-stdlib")
|
||
testImplementation("kotest")
|
||
}
|
||
})
|
||
// project demo
|
||
// implementation kotlin-stdlib
|
||
// testImplementation kotest
|
||
}
|
||
```
|
||
|
||
## Когда DSL оправдан
|
||
| Признак | Вывод |
|
||
|---|---|
|
||
| структура описывается многократно, вид стабилен | DSL уместен |
|
||
| вложенная иерархия (UI, конфиг, разметка) | DSL уместен |
|
||
| писать будут люди, не знающие внутренностей | DSL уместен |
|
||
| альтернатива - длинный конструктор или `Map<String, Any>` | DSL уместен |
|
||
| одно-два места использования | обычный код или `data class` |
|
||
| нужна сериализация/валидация схемы | формат данных (JSON/YAML) + парсер |
|
||
| структура меняется каждую неделю | DSL придётся переписывать |
|
||
|
||
Главная цена DSL - его нужно изучать. Для внутреннего инструмента, который используют трое, именованные аргументы `data class` часто выигрывают:
|
||
```kotlin
|
||
data class ServerConfig(
|
||
val host: String = "localhost",
|
||
val port: Int = 8080,
|
||
val tls: Boolean = false,
|
||
val timeoutSeconds: Int = 30,
|
||
)
|
||
|
||
fun main() {
|
||
val config = ServerConfig(host = "example.com", port = 443, tls = true)
|
||
println(config)
|
||
// ServerConfig(host=example.com, port=443, tls=true, timeoutSeconds=30)
|
||
}
|
||
```
|
||
Это читается не хуже DSL, проверяется компилятором лучше, бесплатно получает `copy`, `equals` и сериализацию. DSL нужен, когда структура **вложенная** и **повторяющаяся** - там, где `data class` требует громоздких вложенных конструкторов.
|
||
|
||
> [!info] Отличия от Java
|
||
> | Java | Kotlin |
|
||
> |---|---|
|
||
> | fluent builder с `return this` | `T.() -> Unit` |
|
||
> | XML/YAML/properties для конфигурации | типобезопасный DSL |
|
||
> | анонимный класс с методами | лямбда с получателем |
|
||
> | строковая конкатенация SQL | DSL с проверкой типов |
|
||
> | нет аналога | `@DslMarker` |
|
||
>
|
||
> **В Java DSL ограничен цепочками методов.** Максимум, что доступно - fluent interface:
|
||
> ```java
|
||
> Html.create()
|
||
> .div().attr("class", "container")
|
||
> .p().text("абзац").end()
|
||
> .end()
|
||
> .render();
|
||
> ```
|
||
> Вложенность приходится выражать парами `.div()`/`.end()`, и компилятор не проверит их баланс. В Kotlin вложенность выражается вложенностью блоков - забыть «закрывающий тег» физически невозможно.
|
||
>
|
||
> **Это причина, по которой Gradle перешёл на Kotlin.** `build.gradle` на Groovy был динамическим: опечатка в имени свойства обнаруживалась при запуске сборки, автодополнения не было. `build.gradle.kts` - обычный Kotlin-код, поэтому IDE знает все доступные методы, а ошибка в имени не компилируется. Цена - более медленная первая сборка (скрипт нужно скомпилировать).
|
||
>
|
||
> **Конфигурация в коде вместо формата данных.** В Java-мире окружение описывают в XML (Spring, Maven) или YAML (Spring Boot). Kotlin-DSL даёт проверку типов и переиспользование (функции, циклы, условия прямо в конфиге), но теряет главное свойство данных: конфигурацию нельзя прочитать и изменить без компилятора. Для конфигурации, которую правят администраторы, формат данных остаётся правильным выбором.
|
||
>
|
||
> **Лямбда с получателем - ключевая возможность, которой нет в Java.** Без неё все остальные приёмы (операторы, расширения) не складываются в DSL, поэтому в Java аналога попросту не существует.
|
||
>
|
||
> Сравнить: [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md), [Интерфейсы в Java](../../Программирование%20на%20Java/ООП/Интерфейсы.md)
|
||
|
||
> [!warning] Подводные камни
|
||
> **Без `@DslMarker` внешний получатель виден изнутри:**
|
||
> ```kotlin
|
||
> class Node {
|
||
> val children = mutableListOf<String>()
|
||
> fun child(name: String) { children.add(name) }
|
||
> fun nested(block: Node.() -> Unit) { children.add(Node().apply(block).children.toString()) }
|
||
> }
|
||
>
|
||
> fun main() {
|
||
> val n = Node().apply {
|
||
> child("a")
|
||
> nested { child("b") }
|
||
> }
|
||
> println(n.children) // [a, [b]]
|
||
> }
|
||
> ```
|
||
> Здесь всё верно, но стоит добавить метод, которого нет у внутреннего уровня, и вызов молча уйдёт на внешний. `@DslMarker` стоит ставить сразу.
|
||
>
|
||
> **DSL скрывает порядок выполнения:**
|
||
> ```kotlin
|
||
> class Steps {
|
||
> private val log = mutableListOf<String>()
|
||
> fun step(name: String) { log.add(name) }
|
||
> fun render() = log.joinToString(" -> ")
|
||
> }
|
||
>
|
||
> fun main() {
|
||
> val s = Steps().apply {
|
||
> step("первый")
|
||
> step("второй")
|
||
> }
|
||
> println(s.render()) // первый -> второй
|
||
> }
|
||
> ```
|
||
> Блок выглядит декларативно, но это императивный код: вызовы выполняются по порядку, и переставить строки - значит изменить результат. Пользователь DSL об этом не догадывается.
|
||
>
|
||
> **Изменяемое состояние в билдере переиспользуется:**
|
||
> ```kotlin
|
||
> class Builder2 {
|
||
> private val items = mutableListOf<String>()
|
||
> fun item(x: String) { items.add(x) }
|
||
> fun build() = items.toList()
|
||
> }
|
||
>
|
||
> fun main() {
|
||
> val b = Builder2()
|
||
> b.item("a")
|
||
> val first = b.build()
|
||
> b.item("b")
|
||
> val second = b.build()
|
||
> println("$first / $second") // [a] / [a, b]
|
||
> }
|
||
> ```
|
||
> Если билдер доступен снаружи, повторный `build()` даёт другой результат. Делайте билдер одноразовым: создавайте его внутри функции-входа и не возвращайте.
|
||
>
|
||
> **`var` в DSL без валидации допускает некорректное состояние:**
|
||
> ```kotlin
|
||
> class Window {
|
||
> var width = 0
|
||
> var height = 0
|
||
> fun area() = width * height
|
||
> }
|
||
>
|
||
> fun window(block: Window.() -> Unit) = Window().apply(block)
|
||
>
|
||
> fun main() {
|
||
> println(window { width = -5; height = 10 }.area()) // -50
|
||
> }
|
||
> ```
|
||
> Нужна проверка в `build()`/`apply` или сеттер с `require`.
|
||
>
|
||
> **Вложенные лямбды с получателем трудно отлаживать.** Стек вызовов состоит из `invoke`-методов сгенерированных лямбд, а точка останова внутри блока не показывает, какому уровню она принадлежит. Для глубоких DSL это заметная проблема.
|
||
>
|
||
> **DSL без документации непригоден.** Пользователь видит `pizza { }` и не знает, какие методы доступны - IDE подскажет, но ревью в браузере и примеры в README необходимы. Это основной скрытый расход на DSL.
|
||
>
|
||
> **Методы DSL попадают в автодополнение не там, где ожидалось.** Если расширение объявлено на верхнем уровне (а не внутри класса DSL), оно появится везде:
|
||
> ```kotlin
|
||
> // плохо: operator fun String.unaryPlus() на верхнем уровне сломает код,
|
||
> // где +"строка" означает что-то другое
|
||
> class Container2 {
|
||
> private val parts = mutableListOf<String>()
|
||
> operator fun String.unaryPlus() { parts.add(this) } // только внутри Container2
|
||
> fun render() = parts.joinToString()
|
||
> }
|
||
>
|
||
> fun container(block: Container2.() -> Unit) = Container2().apply(block).render()
|
||
>
|
||
> fun main() { println(container { +"a"; +"b" }) } // a, b
|
||
> ```
|
||
> Объявляйте операторы DSL **внутри** класса-контекста.
|
||
>
|
||
> **`apply` возвращает билдер, а не результат:**
|
||
> ```kotlin
|
||
> class Doc {
|
||
> private val lines = mutableListOf<String>()
|
||
> fun line(x: String) { lines.add(x) }
|
||
> fun render() = lines.joinToString("\n")
|
||
> }
|
||
>
|
||
> fun docWrong(block: Doc.() -> Unit) = Doc().apply(block) // возвращает Doc
|
||
> fun docRight(block: Doc.() -> Unit) = Doc().apply(block).render() // возвращает String
|
||
>
|
||
> fun main() {
|
||
> println(docWrong { line("a") }::class.simpleName) // Doc
|
||
> println(docRight { line("a") }) // a
|
||
> }
|
||
> ```
|
||
> Утечка билдера наружу позволяет менять «готовый» объект после сборки.
|
||
>
|
||
> **DSL - не замена валидации данных.** Типобезопасность проверяет структуру, а не смысл: `port = 70000` скомпилируется. Проверки диапазонов, обязательных полей и взаимных зависимостей нужно писать вручную. Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md)
|
||
>
|
||
> **DSL тяжело версионировать.** Переименование метода - несовместимое изменение для всех пользователей, а `@Deprecated` на методах внутри класса-контекста работает, но предупреждения в больших блоках теряются.
|
||
|
||
Далее: [Паттерны проектирования](Паттерны%20проектирования.md)
|