Зачем писать собственный DSL?

Привет народ. Хочу поделиться с вами своим первым опытом написания DSL решения в Kotlin. В частности, для построения / генерации объектов JSON без какой-либо конкретной структуры. Таким образом, его можно использовать для создания любого типа JSON в целом.

Поводом для этого была потребность в механизме в коде моего Android-приложения для передачи объекта JSON в API SDK без использования длинных строк (в нескольких строках кода), объявления классов данных с определенной структурой, требуемой API, или построения добавить объект JSON с уже существующими библиотеками JSON, такими как org.json и т. д.

Идея

Поэтому я начал его реализовывать, сначала взглянув на официальную документацию Kotlin по написанию DSL. Цель состояла в том, чтобы использовать функциональные литералы с приемником Kotlin и таким образом создать желаемые объекты JSON путем вызова связанных функций. Это должно было выглядеть примерно так:

val jsonString = json {
    // build up the JSON by calling the related DSL functions
}

В моем путешествии по написанию DSL для JSON я понял, что мне нужно использовать функции с параметрами, чтобы указать ключ и значения для объекта JSON, потому что невозможно объявлять функции во время выполнения и в то же время иметь тип безопасность во время компиляции. Поэтому мне пришлось пойти на компромисс и выбрать такую ​​структуру DSL:

val jsonString = json {
    objectElement("key1") {
        stringElement("key2", "value2")
    }
    booleanElement("key3", true)
    numberElement("key4", 1234.5678)
    arrayElement("key5") {
        stringElement("arrayElement1")
        // ...
    }
}

В приведенном выше примере кода создание объекта JSON выполняется путем вызова функций DSL objectElement(), stringElement(), numberElement(), booleanElement() и arrayElement(). Сами функции objectElement() и arrayElement() могут принимать в свои замыкания соответственно дополнительные пары ключ-значение или отдельные элементы, как предлагает спецификация JSON.

Мысли и шаги по реализации

Прежде всего, нам нужно реализовать корневую функцию для DSL, json(), которая должна возвращать объект JSON в виде строки (без межстрочного интервала или отступа). Кроме того, функции требуется лямбда в качестве параметра, который должен получать экземпляр JsonObjectElement - реализацию для элемента объекта JSON, который будет отвечать за хранение дочерних элементов и их рендеринг в виде строк. Вот как выглядит функция json:

fun json(body: JsonObjectElement.() -> Unit): String {
    val jsonObjectElement = JsonObjectElement()
    val stringBuilder = StringBuilder()
    jsonObjectElement.body()
    jsonObjectElement.render(stringBuilder)
    return stringBuilder.toString()
}

Обратите внимание, что JsonObjectElement имеет функцию получения объекта StringBuilder, который должен использоваться дочерними элементами для построения дерева JSON. Вот первая идея того, как может выглядеть реализация класса JsonObjectElement и метода render():

@DslMarker
class JsonObjectElement {
    private val children = mutableMapOf<String, Any>()
    fun render(builder: StringBuilder) = builder
        .append("{")
        .append(children.entries.fold(StringBuilder(), { acc, entry ->
            acc.append(/* render the children */)
        .append("}")
        .toString()
// ...
}

Вы, вероятно, также поняли, что существует потребность в общем интерфейсе для всех типов элементов JSON (строковых, логических и т. Д.), Скажем, JsonElement, который будет включать метод render(). Карта children будет содержать JsonElement в качестве значений, и мы сможем завершить реализацию метода render() и рендеринг дочерних элементов последовательно. Кстати, JsonObjectElement теперь может также реализовать интерфейс JsonElement:

interface JsonElement {
    fun render(builder: StringBuilder): String
}
@DslMarker
class JsonObjectElement : JsonElement {
    private val children = mutableMapOf<String, JsonElement>()
    override fun render(builder: StringBuilder) = builder
        .append("{")
        .append(children.entries.foldIndexed(StringBuilder(), { index, acc, entry ->
            acc.apply {
                append("\"${entry.key}\":${entry.value.render(StringBuilder())}")
                if (index < children.size - 1) {
                // add commas between the elements
                    append(",")
                }
            }
        }).toString())
        .append("}")
        .toString()
// ...
}

Теперь мы можем сделать шаг вперед и реализовать методы для добавления дочерних элементов в дерево JSON:

@DslMarker
class JsonObjectElement : JsonElement {
// ...
    fun objectElement(name: String, body: JsonObjectElement.() -> Unit) {
        val jsonObjectElement = JsonObjectElement()
        jsonObjectElement.body()
        children[name] = jsonObjectElement
    }
    fun arrayElement(name: String, body: JsonArrayElement.() -> Unit) {
        val jsonArrayElement = JsonArrayElement()
        jsonArrayElement.body()
        children[name] = jsonArrayElement
    }
    fun stringElement(name: String, value: String) {
        children[name] = JsonStringElement(value)
    }
    fun booleanElement(name: String, value: Boolean) {
        children[name] = JsonBooleanElement(value)
    }
    fun numberElement(name: String, value: Number) {
        children[name] = JsonNumberElement(value)
    }
    fun nullElement(name: String) {
        children[name] = JsonNullElement
    }

Вы, наверное, обратили внимание на классы JsonArrayElement, JsonStringElement, JsonBooleanElement, JsonNumberElement и JsonNullElement и догадались, что они также являются подклассами JsonElement. Важно отметить, что методы objectElement() и arrayElement() имеют в качестве параметров лямбда-выражения, аналогично методу json(), поскольку с их помощью мы хотим включить больше элементов в дерево. Другие методы - это «завершение», добавление только значений определенных типов и завершение дерева JSON в этой точке. Вот реализация «завершающих» классов значений:

@DslMarker
class JsonStringElement(private val value: String) : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append("\"$value\"").toString()
}
@DslMarker
class JsonBooleanElement(private val value: Boolean) : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append(value).toString()
}
@DslMarker
class JsonNumberElement(private val value: Number) : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append(value).toString()
}
@DslMarker
object JsonNullElement : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append("null").toString()
}

Пока все довольно просто. Немного интереснее реализация реализации JsonArrayElement, которая также очень похожа на JsonObjectElement. Единственная разница практически состоит в том, что нам не нужны имена в качестве ключей к содержащим дочерние значения. Поэтому мы используем MutableList для их хранения и не нуждаемся в параметрах для имен в методах добавления потомков:

@DslMarker
class JsonArrayElement : JsonElement {
    private val children = mutableListOf<JsonElement>()
    override fun render(builder: StringBuilder) = builder
        .append("[")
        .append(children.foldIndexed(StringBuilder(), { index, acc, jsonElement ->
            acc.apply {
                append(jsonElement.render(StringBuilder()))
                if (index < children.size - 1) {
                    append(",")
                }
            }
        }))
        .append("]")
        .toString()
fun objectElement(body: JsonObjectElement.() -> Unit) {
        val jsonObjectElement = JsonObjectElement()
        jsonObjectElement.body()
        children.add(jsonObjectElement)
    }
fun arrayElement(body: JsonArrayElement.() -> Unit) {
        val jsonArrayElement = JsonArrayElement()
        jsonArrayElement.body()
        children.add(jsonArrayElement)
    }
fun stringElement(value: String) {
        children.add(JsonStringElement(value))
    }
fun booleanElement(value: Boolean) {
        children.add(JsonBooleanElement(value))
    }
fun numberElement(value: Number) {
        children.add(JsonNumberElement(value))
    }
fun nullElement() {
        children.add(JsonNullElement)
    }
}

Кроме того, мы можем иметь специальный тип DslMarker аннотации для нашего JSON DSL, который будет гарантировать контроль области видимости, и использовать его во всех уже реализованных объявлениях подкласса JsonElement:

@DslMarker
annotation class JsonElementMarker

Мы завершили базовую реализацию нашего Kotlin JSON DSL и готовы к использованию, как определено в начале статьи:

val jsonString = json {
    objectElement("key1") {
        stringElement("key2", "value2")
    }
    booleanElement("key3", true)
    numberElement("key4", 1234.5678)
    arrayElement("key5") {
        stringElement("arrayElement1")
    }
}
System.out.print(jsonString)

Приведенный выше код распечатает следующую строку JSON:

{"key1":{"key2:"value2"},"key3":true,"key4":1234.5678,"key5":["arrayElement1"]}

Полная реализация

Вот полная реализация:

import java.lang.StringBuilder
interface JsonElement {
    fun render(builder: StringBuilder): String
}
@DslMarker
annotation class JsonElementMarker
@JsonElementMarker
class JsonObjectElement : JsonElement {
    private val children = mutableMapOf<String, JsonElement>()
    override fun render(builder: StringBuilder) = builder
        .append("{")
        .append(children.entries.foldIndexed(StringBuilder(), { index, acc, entry ->
            acc.apply {
                val renderedChild = "\"${entry.key}\":${entry.value.render(StringBuilder())}"
                append(renderedChild)
                if (index < children.size - 1) {
                    append(",")
                }
            }
        }).toString())
        .append("}")
        .toString()
fun objectElement(name: String, body: JsonObjectElement.() -> Unit) {
        val jsonObjectElement = JsonObjectElement()
        jsonObjectElement.body()
        children[name] = jsonObjectElement
    }
fun arrayElement(name: String, body: JsonArrayElement.() -> Unit) {
        val jsonArrayElement = JsonArrayElement()
        jsonArrayElement.body()
        children[name] = jsonArrayElement
    }
fun stringElement(name: String, value: String) {
        children[name] = JsonStringElement(value)
    }
fun booleanElement(name: String, value: Boolean) {
        children[name] = JsonBooleanElement(value)
    }
fun numberElement(name: String, value: Number) {
        children[name] = JsonNumberElement(value)
    }
fun nullElement(name: String) {
        children[name] = JsonNullElement
    }
}
@JsonElementMarker
class JsonArrayElement : JsonElement {
    private val children = mutableListOf<JsonElement>()
    override fun render(builder: StringBuilder) = builder
        .append("[")
        .append(children.foldIndexed(StringBuilder(), { index, acc, jsonElement ->
            acc.apply {
                append(jsonElement.render(StringBuilder()))
                if (index < children.size - 1) {
                    append(",")
                }
            }
        }))
        .append("]")
        .toString()
fun objectElement(body: JsonObjectElement.() -> Unit) {
        val jsonObjectElement = JsonObjectElement()
        jsonObjectElement.body()
        children.add(jsonObjectElement)
    }
fun arrayElement(body: JsonArrayElement.() -> Unit) {
        val jsonArrayElement = JsonArrayElement()
        jsonArrayElement.body()
        children.add(jsonArrayElement)
    }
fun stringElement(value: String) {
        children.add(JsonStringElement(value))
    }
fun booleanElement(value: Boolean) {
        children.add(JsonBooleanElement(value))
    }
fun numberElement(value: Number) {
        children.add(JsonNumberElement(value))
    }
fun nullElement() {
        children.add(JsonNullElement)
    }
}
@JsonElementMarker
class JsonStringElement(private val value: String) : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append("\"$value\"").toString()
}
@JsonElementMarker
class JsonBooleanElement(private val value: Boolean) : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append(value).toString()
}
@JsonElementMarker
class JsonNumberElement(private val value: Number) : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append(value).toString()
}
@JsonElementMarker
object JsonNullElement : JsonElement {
    override fun render(builder: StringBuilder) =
        builder.append("null").toString()
}
fun json(body: JsonObjectElement.() -> Unit): String {
    val jsonObjectElement = JsonObjectElement()
    val stringBuilder = StringBuilder()
    jsonObjectElement.body()
    jsonObjectElement.render(stringBuilder)
    return stringBuilder.toString()
}

Резюме

Хотя вариант использования реализации DSL для JSON, вероятно, очень ограничен и существует множество альтернатив для работы с объектами JSON для Java и Kotlin, мне все же было интересно реализовать его как проект выходного дня, по крайней мере, в учебных целях. Надеюсь, вам понравилась статья, и даже вы сочли ее полезной и использованной в своем коде. Спасибо за чтение!