Что такое DSL и зачем он вам нужен?
Представьте, что вы пишете не код, а инструкцию на понятном языке, которая сама превращается в работающую программу. Именно так работают предметно-ориентированные языки (DSL — Domain Specific Language). В отличие от универсальных языков (Java, Python, C#), DSL создаётся для решения одной конкретной задачи — будь то конфигурация сервера, описание бизнес-правил или построение сложных SQL-запросов.
Kotlin — идеальный кандидат для создания DSL. Благодаря лямбдам с ресивером, расширениям и type-safe builders, вы можете писать код, который выглядит как родной синтаксис. В этой статье мы разберём не просто теорию, а создадим реальный DSL для тестового фреймворка — и вы увидите, насколько это просто и увлекательно.
Почему Kotlin — король DSL?
Kotlin завоевал любовь разработчиков не только из-за совместимости с Java. Его синтаксические возможности позволяют создавать внутренние DSL — языки, которые живут внутри хост-языка и используют его синтаксис. Вот три кита, на которых держится любой Kotlin DSL:
- Лямбды с ресивером — позволяют обращаться к методам объекта без явного указания имени. Это основа builder-паттерна.
- Расширения — добавляют новые функции к любому классу, даже если вы не можете его изменить.
- Инфиксные функции — позволяют писать код в стиле естественного языка:
should be equalTo 5.
Эти возможности превращают написание DSL в конструктор Lego: вы берёте готовые блоки — лямбды, функции, свойства — и собираете из них понятный и безопасный язык.
Создаём простой DSL для тестов: шаг за шагом
Давайте напишем DSL для написания тестов. Цель — чтобы тесты выглядели как читаемое описание, а не как набор вызовов assert:
test("Калькулятор должен складывать числа") {
val result = Calculator.add(2, 3)
result should beEqualTo 5
result shouldNot beNull
}
Согласитесь, это почти английский язык. Теперь разберём, как это работает.
Шаг 1. Создаём контекст теста
Нам нужна функция test, которая принимает имя и блок кода. Блок выполняется в контексте, где доступны should и shouldNot:
class TestContext {
fun Any.should(matcher: Matcher) {
if (!matcher.matches(this)) {
throw AssertionError("Expected ${matcher.expected}, but got $this")
}
}
fun Any.shouldNot(matcher: Matcher) {
if (matcher.matches(this)) {
throw AssertionError("Expected not ${matcher.expected}, but got $this")
}
}
}
fun test(name: String, block: TestContext.() -> Unit) {
println("Running test: $name")
try {
TestContext().block()
println("✓ Passed")
} catch (e: AssertionError) {
println("✗ Failed: ${e.message}")
}
}
Ключевой момент: TestContext.() -> Unit — это лямбда с ресивером. Внутри блока this ссылается на объект TestContext, поэтому методы should и shouldNot становятся доступны неявно.
Шаг 2. Реализуем матчеры
Теперь создадим простые матчеры. beEqualTo и beNull — это функции, возвращающие объекты Matcher:
interface Matcher {
val expected: String
fun matches(value: Any?): Boolean
}
fun beEqualTo(expected: Any) = object : Matcher {
override val expected = "equal to $expected"
override fun matches(value: Any?) = value == expected
}
val beNull = object : Matcher {
override val expected = "null"
override fun matches(value: Any?) = value == null
}
Обратите внимание: beEqualTo — это обычная функция, но благодаря инфиксному стилю (result should beEqualTo 5) она выглядит как часть языка. Инфиксной функцией является should, потому что она объявлена с модификатором infix.
Шаг 3. Добавляем вложенные блоки (скоуп-контроль)
Настоящая мощь DSL раскрывается, когда нужно создавать вложенные структуры. Добавим поддержку групп тестов:
class GroupContext {
private val tests = mutableListOf<Pair<String, TestContext.() -> Unit>>()
fun test(name: String, block: TestContext.() -> Unit) {
tests.add(name to block)
}
fun runAll() {
tests.forEach { (name, block) ->
test(name, block)
}
}
}
fun describe(name: String, block: GroupContext.() -> Unit) {
println("\n=== $name ===")
val group = GroupContext()
group.block()
group.runAll()
}
Теперь можно писать:
describe("Калькулятор") {
test("сложение") {
Calculator.add(2, 3) should beEqualTo 5
}
test("вычитание") {
Calculator.subtract(5, 2) should beEqualTo 3
}
}
Каждый блок describe создаёт свой контекст, где функция test доступна только внутри группы. Это называется скоуп-контролем — вы не сможете случайно вызвать test вне describe.
Продвинутые техники: делаем DSL ещё круче
1. Расширения для существующих типов
Допустим, вы хотите, чтобы String имела метод shouldStartWith. Просто добавьте расширение:
infix fun String.shouldStartWith(prefix: String) {
if (!this.startsWith(prefix)) {
throw AssertionError("'$this' should start with '$prefix'")
}
}
// Использование:
"Hello World" shouldStartWith "Hello"
2. Type-safe builders для сложных структур
Kotlin поддерживает type-safe builders — это когда компилятор проверяет, что вы используете правильные типы в правильном порядке. Пример — построение HTML:
fun html(block: HTML.() -> Unit): HTML {
val html = HTML()
html.block()
return html
}
class HTML {
private val children = mutableListOf<String>()
fun body(block: BODY.() -> Unit) {
val body = BODY()
body.block()
children.add("<body>${body.render()}</body>")
}
fun render() = "<html>${children.joinToString("")}</html>"
}
class BODY {
private val elements = mutableListOf<String>()
fun h1(text: String) = elements.add("<h1>$text</h1>")
fun p(text: String) = elements.add("<p>$text</p>