Files
obsidian/Kotlin/Функциональный Kotlin/DSL.md
T

445 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)