20 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| DSL | Из чего собирается предметно-ориентированный язык на Kotlin и когда он оправдан |
|
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 { }доступны методыTagoperator 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 thisT.() -> UnitXML/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на методах внутри класса-контекста работает, но предупреждения в больших блоках теряются.
Далее: Паттерны проектирования