Перейти к основному содержимому

DSL и функции с получателем в Kotlin

Разработчику Архитектору

DSL и функции с получателем

Напоминание: общие понятия функций в программе — функции в коде.

Когда вы пишете в Ktor:

routing {
get("/health") { call.respond(mapOf("status" to "ok")) }
}

блок routing { } выглядит как мини-язык конфигурации. На самом деле это обычный Kotlin: лямбда с получателем и extension-функции. Такой стиль называют DSL (предметно-ориентированный язык внутри языка).

Примеры в продакшене:

Теория синтаксиса: синтаксические конструкции. Стандартные apply / run: встроенные функции.


Словарь терминов

ТерминПростыми словами
DSLAPI, который читается как конфиг, а не как цепочка вызовов с точками.
ЛямбдаАнонимная функция: { 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")
}

Разбор:

  1. StringBuilder.() -> Unit — тип "функция без аргументов, но с receiver StringBuilder".
  2. Внутри { append("Hello") } компилятор подставляет this.append — как если бы вы писали методы у StringBuilder.
  3. Стандартная библиотека даёт те же приёмы — 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:

  1. Описать итоговую запись, которую хотите получить (route("/x") { ... }).
  2. Выделить receiver-класс с минимальными методами.
  3. Добавить верхнеуровневую функцию-билдер.
  4. Ограничить вложенности через @DslMarker.
  5. Проверить читаемость на 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 + именованные аргументы
Одноразовый тестобычные функции
Команда слабо знает KotlinJSON/YAML конфиг

DSL окупается при иерархической настройке (маршруты, дерево UI, вложенные теги) и повторном использовании.


Связанные материалы


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.