DSL и функции с получателем в Kotlin
DSL и функции с получателем
Напоминание: общие понятия функций в программе — функции в коде.
Когда вы пишете в Ktor:
routing {
get("/health") { call.respond(mapOf("status" to "ok")) }
}
блок routing { } выглядит как мини-язык конфигурации. На самом деле это обычный Kotlin: лямбда с получателем и extension-функции. Такой стиль называют DSL (предметно-ориентированный язык внутри языка).
Примеры в продакшене:
- Ktor;
- Gradle
build.gradle.kts; - HTML-генераторы;
- тестовые билдеры.
Теория синтаксиса: синтаксические конструкции. Стандартные apply / run: встроенные функции.
Словарь терминов
| Термин | Простыми словами |
|---|---|
| DSL | API, который читается как конфиг, а не как цепочка вызовов с точками. |
| Лямбда | Анонимная функция: { x -> x * 2 }. |
| Receiver (получатель) | Внутри блока доступен this как у метода класса. |
| Extension-функция | Функция "как будто метод" существующего типа: fun String.lastChar(). |
@DslMarker | Защита от вызова "чужих" функций во вложенных DSL-блоках. |
Обычная лямбда и лямбда с получателем
Обычная — аргумент передаётся явно:
val greet: (String) -> Unit = { name -> println("Hi, $name") }
greet("Ann")
С получателем — внутри блока this имеет тип T:
fun buildString(block: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.block() // вызов extension-лямбды на sb
return sb.toString()
}
val text = buildString {
append("Hello")
append(", ")
append("Kotlin")
}
Разбор:
StringBuilder.() -> Unit— тип "функция без аргументов, но с receiverStringBuilder".- Внутри
{ append("Hello") }компилятор подставляетthis.append— как если бы вы писали методы уStringBuilder. - Стандартная библиотека даёт те же приёмы —
buildList,buildMap,apply,run,with.
val config = mutableMapOf<String, Any>().apply {
put("host", "localhost")
put("port", 8080)
}
apply возвращает сам объект после настройки.
Свой мини-DSL — меню
Код ITЗагрузка примера кода…
| Шаг | Что видит новичок |
|---|---|
menu { } | Вызов функции с лямбдой |
item("Суп") | Как будто ключевое слово языка |
| Реальность | Метод MenuBuilder.item на неявном this |
Ktor делает то же с Route: внутри routing { } receiver — объект маршрутизатора, get — его метод.
@DslMarker — не перепутать вложенные блоки
Без маркера во вложенном DSL можно случайно вызвать функцию "соседнего" уровня. Аннотация ограничивает видимость:
Код ITЗагрузка примера кода…
Вложенный body { p("…") } корректен; вызов p на уровне html { } компилятор отклонит.
Infix и операторы
infix fun Int.days(unit: String) = "$this $unit"
val period = 7 days "дней"
Разбор:
infix funразрешает вызов функции в форме бинарной операции.Int.days(...)— extension-функция, где левый операнд становится receiver.- Запись
7 days "дней"читается как мини-DSL и улучшает читаемость доменного кода. - Infix-подход лучше применять к операциям с однозначным смыслом для команды.
infix позволяет писать вызов без точки и скобок — для читаемости DSL.
Операторы для своих типов:
data class Vec2(val x: Int, val y: Int)
operator fun Vec2.plus(other: Vec2) = Vec2(x + other.x, y + other.y)
val sum = Vec2(1, 2) + Vec2(3, 4)
Разбор:
operatorсвязывает функциюplusс синтаксисом оператора+.- Метод возвращает новый
Vec2, сохраняя неизменяемость исходных значений. - Выражение
a + bкомпилятор разворачивает вa.plus(b). - Такой стиль оправдан в математических доменах, где смысл
+очевиден.
Используйте + только там, где сложение векторов ожидаемо — иначе DSL перестаёт быть понятным.
Упрощённый аналог Ktor routing
Код ITЗагрузка примера кода…
Разбор:
RouteConfigвыступает как билдер и собирает описание маршрутов в список.get(path, handler)добавляет декларацию маршрута без запуска HTTP-сервера.routing(block: RouteConfig.() -> Unit)принимает receiver-лямбду и формирует DSL-стиль.apply(block)выполняет конфигурацию и возвращает тот же объект с накопленным состоянием.- Переменная
appхранит структуру, которую отдельный движок сможет интерпретировать.
Реальный Ktor добавляет call, HTTP-методы, плагины — но скелет тот же.
Gradle Kotlin DSL
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
Разбор:
dependencies { ... }— конфигурационный блок Gradle с receiver-контекстом.implementation(...)добавляет библиотеку в classpath компиляции и выполнения.- Координаты
group:artifact:versionопределяют конкретный артефакт в репозиториях. - Это практический пример того же DSL-механизма на базе функций с получателем.
dependencies { } — функция с receiver типа DependencyHandlerScope. Вы "настраиваете" объект Gradle, а не вызываете десятки статических методов подряд.
Как проектировать DSL пошагово
Рабочий порядок для своего DSL:
- Описать итоговую запись, которую хотите получить (
route("/x") { ... }). - Выделить receiver-класс с минимальными методами.
- Добавить верхнеуровневую функцию-билдер.
- Ограничить вложенности через
@DslMarker. - Проверить читаемость на 2-3 реальных сценариях, а не на одном примере.
Такой путь защищает от "академического" DSL, где синтаксис красивый, а поддержка дорогая.
Ошибки проектирования DSL
| Ошибка | Что происходит | Как исправить |
|---|---|---|
| Слишком много магии | непонятно, откуда берутся методы | добавить явные типы и короткую документацию рядом |
| Перегрузка операторов без смысла | код читается как ребус | оставить обычные функции с именами |
| Нет валидации | неправильный DSL проходит, ломается позже | валидировать конфиг в build() |
| Смешаны уровни ответственности | сетевой код внутри билдера | DSL только описывает структуру, выполнение отдельно |
DSL и тестируемость
Тестируйте два уровня отдельно:
- DSL-парсинг или сборку структуры.
- Поведение движка, который использует эту структуру.
@Test
fun menuBuilderProducesThreeItems() {
val menu = menu {
item("Суп")
item("Салат")
item("Чай")
}
assertEquals(listOf("Суп", "Салат", "Чай"), menu)
}
Разбор:
@Testпомечает функцию как unit-тест для тестового раннера.- В тесте DSL-конструкция собирается тем же способом, как в рабочем коде.
assertEquals(...)проверяет контракт билдера: состав и порядок элементов.- Такой тест фиксирует ожидаемое поведение DSL и защищает рефакторинг от регрессий.
Этот подход ускоряет рефакторинг DSL в больших конфигурационных проектах (Gradle-плагины, серверные роуты, генераторы).
Дополнительный сниппет — вложенный билдер
Код ITЗагрузка примера кода…
Разбор:
QueryBuilderхранит внутреннее состояние построения строки запроса.- Методы
selectиfromвыступают DSL-операторами, которые читаются как язык предметной области. vararg fieldsпозволяет передавать переменное число колонок в одном вызове.apply(block)выполняет receiver-лямбду в контексте билдера и возвращает настроенный объект.build()завершает сборку и выдаёт финальный результат, отделяя описание от исполнения.
Когда свой DSL избыточен
| Ситуация | Лучше |
|---|---|
| Три поля конфигурации | data class + именованные аргументы |
| Одноразовый тест | обычные функции |
| Команда слабо знает Kotlin | JSON/YAML конфиг |
DSL окупается при иерархической настройке (маршруты, дерево UI, вложенные теги) и повторном использовании.
Связанные материалы
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.