вступление

В этом посте я хотел бы поделиться тем, что мне очень нравится, что Kotlin не только позволяет, но и делает легкий ветерок! Эта «штука» - это создание вашего собственного доменного языка или, для краткости, DSL.

Сама по себе эта статья не даст вам пошагового руководства по применению к вашим собственным проектам, а скорее выделит некоторые особенности Kotlin, которые я использовал для создания DSL в моем собственном проекте. Это оказалось бесценным для меня, и я намерен сказать «Ага!» момент, возможно, открывая новую дверь или способ мышления при настройке собственной архитектуры проекта и утилит.

DSL, который я буду описывать в этой статье, был взят из проекта, который я недавно построил с использованием Kotlin / JS для создания веб-приложения PokeDex. Некоторые разделы могут быть незнакомы из-за различий, но по сути это Kotlin. Если вы знакомы с Kotlin, вы сможете продолжить. Я кратко рассмотрю несколько ключевых моментов, прежде чем мы перейдем к основному содержанию.

Большие отличия от Kotlin / JS, которые имеют отношение к этой статье, заключаются в использовании dynamic, attrs и props.

  • Тип dynamic по сути является проверенной компилятором версией Any. Вы устанавливаете значения для объекта dynamic без обеспечения безопасности типов и должны осторожно обращаться с ним при доступе к этим значениям.
  • attrs - это поле в элементе пользовательского интерфейса, в котором вы применяете такие атрибуты, как цвет текста, ширина и т. Д.
  • props - это функция React, которую можно рассматривать как любые данные, которые вы передаете от родительского к дочернему компоненту, например, аргументы в вызове функции.

Цель

Я хотел бы начать с объяснения, какую проблему решил для меня этот DSL, а затем более подробно остановлюсь на коде, прежде чем снова намотать его, чтобы дать хорошее заключительное заключение.

Я работал в React и пытался найти практичный и эффективный способ передачи props от одного компонента к другому. Есть несколько элементарных способов сделать это, но они очень свободные, и я не люблю их. Мне нравится прыгать в своей кодовой базе и иметь возможность собрать точное поведение без необходимости создавать или запускать код. Имея в виду эту цель, мои требования к DSL были следующими:

  • Способ создания компонента с легким идиоматическим синтаксисом.
  • Имейте набор необходимых свойств, необходимых компоненту.
  • Поставьте любое количество дополнительных опор, которые можно установить или «просверлить» вниз по цепочке.
  • Возможность передать компоненты для рендеринга как дочерние.
  • Будьте как можно более общими и легко сможете сделать классы компонентов совместимыми.

Я хотел иметь возможность вызвать pokemonDetailView, чтобы собрать свой компонент, а затем предоставить то, что необходимо в его рамках. Это соответствует синтаксису и идиомам остальной части оболочки Kotlin / React, которая, как вы можете распознать, очень похожа на пользовательский интерфейс Compose в JetBrain.

У меня было представление о том, как я хочу, чтобы это было спроектировано, и затем я настраивал реализацию, пока не понял, что это нужно. Вот как в итоге выглядело использование: именно то, чего я пытался достичь.

override fun RBuilder.render() {
    browserRouter {
        switch {
            …
            route<IdProps>("/pokemon/:id") { props ->

                //call the component builder
                pokemonDetailView { optionalProps, children ->

                    //supply optional props
                    optionalProps {
                        optionalValue1 = "Testing, 1..2..3.."
                        optionalValue2 = 7357
                    }

                    //provide children to the target component
                    children {
                        div {
                            +"Hello, DSL!"
                        }
                    }

                    //end with returning the required props class
                    PokemonDetailProps(props.match.params.id)
                }
            }
        }
    }
}

Ключевые идеи

Понимание некоторых ключевых концепций:

В следующих разделах подробно описывается DSL, при этом основные концепции, которые необходимо понять, включают:

  • Функции с заданной областью, например String.() -> Unit
  • Общие типы, потолки, параметры типа и т. Д.
  • Передача функций как параметров (::function)
  • Тип Псевдоним

Если вы знакомы со всем этим, не стесняйтесь пропустить следующий обзор и перейти к Component Builder DSL.

В противном случае давайте кратко рассмотрим каждую концепцию.

Функции с ограниченной областью видимости

Kotlin рассматривает сигнатуры функций как типы, и поэтому существует несколько различных вариантов.

Например, взгляните на эти два типа функций и на их различия в использовании.

fun main() {
    
    // Using a typical function argument 
    // with a single parameter, the default 
    // scope capture will be 'it'.
    "Hello It".scopeIt { //it: String
        println(it)
    }

    // Using a function block argument, the 
    // block type will be scoped as this.
    "Hello This".scopeThis { //this: String
        println(length) //this.length
    }
}

fun String.scopeIt(block: (String) -> Unit) {
    block(this)
}

fun String.scopeThis(block: String.() -> Unit) {
    block(this)
}

Вывод:

Hello It
10

Использование this в качестве области лямбда-приемника полезно при присвоении значений в построителе или для быстрого доступа к полям.

Я использовал функции расширения String в качестве простого примера, но есть много преимуществ при работе с вашими собственными пользовательскими классами.

Вот отличное описание того, как функции области видимости, предоставляемые Kotlin, отличаются и работают под капотом: https://www.baeldung.com/kotlin/scope-functions

Параметры типа (универсальные)

Я не знаю, входит ли в эту статью объяснение параметров типа, и, честно говоря, я, вероятно, не лучший человек, который этому научит. Я рекомендую следующий CodeLab или видеообзор для дополнительного контекста.

CodeLab: https://developer.android.com/codelabs/kotlin-bootcamp-generics

Обзор: https://www.youtube.com/watch?v=V-3L2TEdJXs

Передача функций как параметров

Вы можете использовать связанные вызываемые ссылки, чтобы получить доступ к полям и свойствам экземпляра, добавив префикс ::. Есть и другие варианты использования этого оператора, но в случае функции он получит вызываемый экземпляр самой функции, а не вызов функции. Такие как:

fun main() {
    runOutput(::output)
}

fun runOutput(block: (String) -> Unit) {
    block("Hello, world!")
}

fun output(value: String) {
    println(value)
}

Вывод:

Hello, world!

Подробнее на https://kotlinlang.org/docs/whatsnew11.html#bound-callable-references

Тип Псевдоним

Псевдоним типа - это, по сути, просто способ переименования типов. Он не добавляет никаких новых функций, и единственный плюс их использования - очистка кода. Единственным недостатком является то, что их чрезмерное использование или неправильное суждение может фактически сделать ваш код менее читаемым.

Вы можете узнать больше о том, как и зачем использовать псевдоним типа здесь: https://www.baeldung.com/kotlin/type-aliases

Я буду использовать любой предлог, чтобы ссылаться на baeldung.com, качество этого сайта для любого разработчика Java / Kotlin не имеет себе равных.

На этом мы завершаем введение, теперь мы подробно рассмотрим каждую часть DSL.

DSL Entry Point - реализация

Первая задача - обеспечить чистую точку входа в DSL. Я хотел, чтобы сайт звонков был как можно более простым в использовании, при этом соблюдались правила и допускались некоторые контролируемые варианты.

/*
/Provide a function that will be called from 
/the parent component, enforce the DSL, and 
/return the target component.
*/
fun RBuilder.pokemonDetailView(
        block: ComponentBuilderArgs<PokemonDetailProps>
) = child(PokemonDetailView::class) {
    //this: RElementBuilder<P>.() -> Unit
    handler(block)
}

Подобная функция создается для каждого компонента, который должен быть построен. Его имя - это создаваемый элемент, а параметр типа блока - это класс prop, который требуется компоненту, который всегда будет иметь верхний предел RProps.

Два параметра для child - это класс, который нужно построить, и функция с подписью RElementBuilder<P>.() -> Unit. Параметр функции - это то, что позволяет лямбде вызывать handler(block), потому что handler является RElementBuilder функцией расширения, и мы попадаем в нее через RElementBuilder.().

Прежде чем рассматривать обработчик, давайте взглянем на block тип: ComponentBuilderArgs.

Псевдоним типа: ComponentBuilderArgs

Это был громоздкий шрифт, в котором много чего происходило, поэтому я преобразовал его в псевдоним типа для облегчения чтения.

/*
/Type alias for a complex function type. 
/This keeps the function signatures clean.
*/
private typealias ComponentBuilderArgs <P> = (
        optionalProps: (dynamic.() -> Unit) -> Unit,
        children: (RBuilder.() -> dynamic) -> Unit
) -> P

Во-первых, он принимает общий тип <P> и возвращает функцию с несколькими содержащимися функциями, каждая из которых имеет область видимости this, с типом возврата универсального <P>. Я не видел необходимости в дальнейшем разбиении шрифта, но если бы я захотел, это выглядело бы так:

private typealias ComponentBuilderArgs <P> = (
        optionalProps: OptionalProps,
        children: Children
) -> P

private typealias OptionalProps = (dynamic.() -> Unit) -> Unit
private typealias Children = (RBuilder.() -> dynamic) -> Unit

Теперь, понимая, что происходит с этими типами функций, мы можем соединить точки на месте вызова.

DSL Entry Point - использование

pokemonDetailView { optionalProps, children ->
    
    //supply optional props
    optionalProps { //this: Dynamic
        optionalValue1 = "Testing, 1..2..3.."
        optionalValue2 = 7357
        //return Unit
    }
    
    //provide children to the target component
    children { //this: RBuilder
        div {
            +"Hello, DSL!"
        }
        //return Unit
    }
    
    //end with returning the required props class
    (return) PokemonDetailProps(props.match.params.id)
}

Стоит отметить, что Kotlin обрабатывает последнюю строку любой функции как возвращаемое значение, независимо от того, ожидается оно или нет, которое используется в этом DSL.

optionalProps is (dynamic.() -> Unit) -> Unit

children is (RBuilder.() -> dynamic) -> Unit

Поскольку optionalProps и children передаются как параметры без ожидания возвращаемого значения, их использование не требуется. Следующее было бы прекрасно, для чего и был разработан этот DSL.

pokemonDetailView { optionalProps, children ->
    PokemonDetailProps(props.match.params.id)
}

Наша функция дает два именованных значения области, соответствующих типам с ожидаемым возвратом данного параметра типа. В данном случае PokemonDetailProps, как мы видели ранее.

Поняв наш псевдоним типа, мы можем изучить обработчик.

Обработчик - основной DSL

/*
/ Handler that gets called on a newly
/ built component, applying any and all
/ supplied modifications.
*/
fun <P : RProps> RElementBuilder<P>.handler(block: ComponentBuilderArgs<P>) {

    
    // Provide a function that can be called
    // with a dynamic scope, allowing any
    // props to be passed
    fun onOptionalProps(props: dynamic.() -> Unit) {

        // Pass the attributes of this
        // component to the caller
        props(this.attrs)
    }
    
    // Provide a function that can be called
    // with an RBuilder scope, which is
    // the base of each component.
    fun onChildren(children: RBuilder.() -> Unit) {

        // Pass this component in for children
        // to be passed through by the caller
        children(this)
    }
    
    // Call the block with each optional
    // function, and return the required props
    // as defined in ComponentBuilderArgs
    val result = block(::onOptionalProps, ::onChildren)

    // Apply each supplied prop as required
    result.asJsObject().getOwnPropertyNames().forEach {
        attrs.asDynamic()[it] = result.asDynamic()[it]
    }
}

Мы только что рассмотрели это, но напомним, что нашим аргументом для block в этой функции является функция псевдонима подробного типа.

Сначала мне было сложно осмыслить эту часть, но когда я ее разгадал, все начало щелкать.

Поскольку мы вызываем эту функцию со значениями, указанными в блоке, мы не знаем, были ли предоставлены дополнительные свойства или дочерние элементы, и обработчик не зависит от них. Когда вызывается block(), эти функции будут предоставлены месту вызова независимо от того, используются они или нет. Мы получаем необходимые реквизиты в result из вызова block(), а затем можем перебирать их, чтобы применить к атрибутам нашего компонента.

Это может показаться немного запутанным, но когда вы хотите, чтобы при разработке что-то работало определенным образом, это нормально, если что-то станет немного странным под поверхностью.

Теперь, когда мы рассмотрели все части по отдельности, вы можете увидеть полный поток кода ниже.

Полный код DSL и его использование

pokemonDetailView { optionalProps, children ->
    
    optionalProps {
        optionalValue1 = "Testing, 1..2..3.."
        optionalValue2 = 7357
    }
    
    children {
        div {
            +"Hello, DSL!"
        }
    }
    
    PokemonDetailProps(props.match.params.id)
}

private typealias ComponentBuilderArgs <P> = (
        optionalProps: (dynamic.() -> Unit) -> Unit,
        children: (RBuilder.() -> dynamic) -> Unit
) -> P

fun <P : RProps> RElementBuilder<P>.handler(
        block: ComponentBuilderArgs<P>
) {
    
    fun onOptionalProps(props: dynamic.() -> Unit) {
        props(this.attrs)
    }
    
    fun onChildren(children: RBuilder.() -> Unit) {
        children(this)
    }
    
    val result = block(::onOptionalProps, ::onChildren)
    
    result.asJsObject().getOwnPropertyNames().forEach {
        attrs.asDynamic()[it] = result.asDynamic()[it]
    }
}

data class PokemonDetailProps(var pokemonId: Int) : RProps

fun RBuilder.pokemonDetailView(
        block: ComponentBuilderArgs<PokemonDetailProps>
) = child(PokemonDetailView::class) {
    handler(block)
}

Заключение

С написанием этого DSL моя цель достигнута. Теперь всякий раз, когда я создаю новый компонент, я просто создаю вариант функции расширения, например RBuilder.[MyComponent], и просто буду использовать класс типа prop этого компонента в качестве универсального типа. Это позволяет мне составлять просмотры быстро и, что более важно, чисто.

Стоило ли время, потраченное на написание этого DSL, сэкономить время на проекте? Может быть нет. Тем не менее, он научил меня многому о том, как все работает в Kotlin, и о том, какие варианты у меня как у разработчика есть для настройки моей среды в соответствии с моими потребностями. Я надеюсь, что изучение этих концепций заставит вас почувствовать себя уполномоченным вносить изменения в качество жизни на ранней стадии проекта и быть уверенным, что в будущем это окупится с точки зрения эффективности, как это произошло со мной.

Как всегда, спасибо за чтение, и я надеюсь, что вы унесли что-то ценное из этой статьи. Не забывайте периодически проверять это, так как именно здесь я буду размещать весь свой практический контент!

Особая благодарность моему другу и коллеге Кайлу Дану за то, что он помог мне донести до него свои идеи и вычитал мои статьи.
https://www.linkedin.com/in/kdahn/