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

20 KiB
Raw Blame History

title, description, tags
title description tags
DSL Из чего собирается предметно-ориентированный язык на Kotlin и когда он оправдан
kotlin
functional
dsl

DSL (domain-specific language) на Kotlin - это обычный Kotlin-код, который выглядит как декларативное описание. Отдельного механизма для этого нет: DSL собирается из четырёх уже знакомых возможностей.

Возможность Что даёт
лямбда с получателем T.() -> Unit блок, внутри которого доступны методы объекта
лямбда последним аргументом html { } вместо html({ })
функции-расширения методы только внутри DSL
операторы и infix "a" to 1, x shouldBe y

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

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. Это весь базовый приём.

Вложенные блоки

@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 от строковых шаблонов: ошибки видит компилятор.

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)        // не указана таблица
    }
}

Проверка обязательных полей здесь в рантайме. Вынести её в компиляцию можно через типы-состояния:

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

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

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:

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:

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 часто выигрывают:

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:

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, Интерфейсы в Java

[!warning] Подводные камни Без @DslMarker внешний получатель виден изнутри:

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 скрывает порядок выполнения:

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 об этом не догадывается.

Изменяемое состояние в билдере переиспользуется:

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 без валидации допускает некорректное состояние:

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), оно появится везде:

// плохо: 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 возвращает билдер, а не результат:

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 скомпилируется. Проверки диапазонов, обязательных полей и взаимных зависимостей нужно писать вручную. Подробнее: Проверки и контракты

DSL тяжело версионировать. Переименование метода - несовместимое изменение для всех пользователей, а @Deprecated на методах внутри класса-контекста работает, но предупреждения в больших блоках теряются.

Далее: Паттерны проектирования