Часть II из II

Проектирование функциональной библиотеки

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

Contents
* Handling Lists
    * Assign Types
    * Refactor for New Types
* Implementing ListTag
* Functional Takeaways
* Optional Tag Exercise
* Should We Write This Library?

Обработка списков

Наша следующая задача — обработка списков. В частности, наш механизм шаблонов должен иметь возможность генерировать несколько строк, каждая из которых повторяет замену из списка в подшаблон. Мы начнем с написания теста, чтобы прояснить наши ожидания. Это может выглядеть примерно так:

val template = """
    Dear {title} {surname},
    we would like to bring to your attention these task due soon:
    {tasks}  {id} - {taskname} which is due by {due}{/tasks}
    Thank you very much {name}.
""".trimIndent()

val tasks = TODO("some kind of special tag")
val tags = TODO("rest of the tags including tasks")
val text = renderTemplate(template, tags)
val expected = """
    Dear Mr Barbini,
    we would like to bring to your attention these task due soon:
      1 - buy the paint which is due by today
      2 - paint the wall which is due by tomorrow
    Thank you very much Uberto.
""".trimIndent()
expectThat(text).isEqualTo(expected)

Обратите внимание, что теги {tasks} и {/tasks} предназначены для совместной работы. Содержимое между этими тегами должно повторяться для каждого элемента в списке tasks.

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

Реализация этого решения потребует от нас последовательного применения каждого тега (со своим собственным регулярным выражением), вместо того, чтобы выполнять один проход с регулярным выражением для всех тегов. Хотя этот подход может немного замедлить работу, он значительно повысит гибкость. Мы всегда можем подумать об оптимизации производительности позже.

Назначить типы

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

Оставим Renderer как есть. Мы можем создать тип Tag, представляющий собой закрытую иерархию, позволяющую каждому экземпляру некоторым образом преобразовывать шаблон. Вот как это выглядит:

typealias Renderer = (Tags) -> String

typealias Tags = (TagName) -> Tag?
sealed class Tag : (Template) -> Template {
    abstract val name: TagName
}
//TODO: define the tags

Небольшое замечание: класс Tag — это класс invokable. Даже если это может быть не сразу очевидно, каждый тег действует как каррированная функция. Другими словами, это функция, в которую загружаются некоторые данные — имя тега и значение для его замены — которые она использует для преобразования Template в другое Template.

Поскольку мы меняем механизм замены тегов, нам нужно определить разные типы для разных типов тегов. В настоящее время у нас есть два из них: StringTag (который мы использовали до сих пор) и ListTag (для элементов списка).

✏️ Вывод 6. Мы должны стараться моделировать наши типы таким образом, чтобы они точно отражали проблемную область, которую мы хотим решить. Таким образом, наш код может более интуитивно выражать проблему и решение.

StringTag легко реализовать, просто замените текст:

data class StringTag(override val name: TagName, val text: String?) : Tag() {
    override fun invoke(template: Template): Template =
        template.text.replace(name.value, text ?: "")
            .asTemplate()
}

Рефакторинг для новых типов

Но прежде чем приступить к реализации списка ListTag, давайте рефакторим остальной код для работы с новыми типами.

Внутри нашего класса RenderTemplate нам сначала нужно составить список тегов, которые необходимо заменить, которые мы находим с помощью того же регулярного выражения, что и раньше. Затем, используя fold в нашей функции invoke, мы можем заменить каждый из этих тегов в шаблоне один за другим. Если мы встретим тег, которому не можем найти замену, мы должны заменить его пустой строкой (как это делает функция removeMissingTag):

data class RenderTemplate(val template: Template) : Renderer {

    val tagRegex = """\{(.*?)}""".toRegex()

    val tagsToReplace = tagRegex.findAll(template.text)
            .map { TagName(it.value) }.toSet()

    private fun replaceTag(currTempl: Template, 
                           tagName: TagName, tag: Tag?): Template =
        when (tag) {
            null -> removeMissingTag(currTempl, tagName)
            else -> tag(currTempl)
        }

    private fun removeMissingTag(currTempl: Template, tagName: TagName) = 
        currTempl.text.replace(tagName.value, "").asTemplate()

    override fun invoke(tags: Tags): String =
        tagsToReplace.fold(template) { currTempl, tagName ->
            replaceTag(currTempl, tagName, tags(tagName))
        }.text
}

А вот так теперь выглядит счастливый кейс-тест:

@Test
fun `replace simple strings`() {
    val titleTag = StringTag("title".asTagName(), "Mr")
    val surnameTag = StringTag("surname".asTagName(), "Barbini")
    val renderTemplate = RenderTemplate(
        """{title} {surname}""".asTemplate()
    )
    val tags: Tags = { x ->
        when (x) {
            titleTag.name -> titleTag
            surnameTag.name -> surnameTag
            else -> null
        }
    }

    val text = renderTemplate(tags)

    val expected = "Mr Barbini"
    expectThat(text).isEqualTo(expected)
}

Реализация ListTag

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

data class ListTag(override val name: TagName, val subTags: List<Tags>) : Tag(){
    override fun invoke(template: Template): Template = TODO()
}

Разобравшись с сигнатурой типа, мы можем приступить к написанию теста, который будет определять реализацию ListTag.

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

@Test
fun `replace elements from a list`() {
    val renderer = RenderTemplate(
        """{title} {surname} order:
            |{items} {qty} of {itemname} {/items}
            |Total: {total} pieces
        """.trimMargin().asTemplate()
    )

    val itemsTags = listOf(
        " 4" to "glasses",
        "12" to "plates"
    ).map { (qty, name) ->
        tags(
            "qty" tag qty,
            "itemname" tag name
        )
    }
    val tags = tags(
        "title" tag "Mr",
        "surname" tag "Barbini",
        "total" tag "16",
        "items" tag itemsTags
    )

    val text = renderer(tags)

    val expected = """Mr Barbini order:
          |  4 of glasses 
          | 12 of plates 
          |Total: 16 pieces""".trimMargin()
    expectThat(text).isEqualTo(expected)
}

Чтобы этот тест было легко читать, я использовал некоторые функции предметно-ориентированного языка Kotlin (DSL), включая infix функций и varargs аргументов. Поэтому я также определил три небольшие вспомогательные функции:

fun tags(vararg tags: Tag): Tags =
    tags.associateBy { it.name to it }::get

infix fun String.tag(value: String) = StringTag(this.asTagName(), value)
infix fun String.tag(value: List<Tags>) = ListTag(this.asTagName(), value)

✏️ Вывод 7. Использование функций Kotlin DSL может улучшить читабельность вашего кода. Но не злоупотребляйте ими, так как они могут превратить ваш код из лаконичного в загадочный.

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

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

По сути, нам нужно выделить весь текст между открывающим и закрывающим тегами, удалить сами теги, а затем применить подстановку шаблона для каждого вложенного тега:

data class ListTag(override val name: TagName, 
                            val subTags: List<Tags>): Tag(){

    private val strippedTag = name.value.drop(1).dropLast(1)
    
    private val tagRegex = """\{$strippedTag}(.*?)\{/$strippedTag}"""
            .toRegex(RegexOption.DOT_MATCHES_ALL)

    private fun generateMulti(subTemplate: Template): String =
        subTags.joinToString(separator = "\n") {
            RenderTemplate(subTemplate)(it)
        }
    private fun MatchResult.asSubtemplate(): Template =
        value.drop(name.value.length)
            .dropLast(name.value.length + 1).asTemplate()
    
    override fun invoke(template: Template): Template =
        template.text.replace(tagRegex) {
            generateMulti(it.asSubtemplate())
        }.asTemplate()
} 

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

✏️ Вывод 8. Чтобы решать сложные проблемы, разбивайте их на более мелкие и простые подзадачи. Решите их с помощью микрофункций, а затем составьте их вместе. Не забывайте использовать рекурсию, когда это возможно.

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

Вы можете найти полный код на GitHub по адресу https://github.com/uberto/templatefun.

Функциональные выводы

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

Вот краткое изложение всех выводов из частей I и II этого урока по определению функциональной библиотеки:

✏️ Вывод 1: проще создавать чистые функции только с одним параметром.

✏️ Вывод 2. Лучше давать имена всем нашим типам, чем использовать примитивные типы.

✏️ Вывод 3. Давайте пойдем по минимуму. Наша библиотека должна зависеть от как можно меньшего числа других типов.

✏️ Вывод 4.Вызываемые классы предлагают идиоматическое решение Kotlin для каррированных функций.

✏️ Вывод 5. Избегайте обработки ожидаемых ошибок с помощью исключений.

✏️ Вывод 6. Мы должны стараться моделировать наши типы таким образом, чтобы они точно отражали проблемную область, которую мы хотим решить. Таким образом, наш код может более интуитивно выражать проблему и решение.

✏️ Вывод 7. Использование функций Kotlin DSL может улучшить читабельность вашего кода. Но не злоупотребляйте ими, так как они могут превратить ваш код из лаконичного в загадочный.

✏️ Вывод 8. Чтобы решать сложные проблемы, разбивайте их на более мелкие и простые подзадачи. Решите их с помощью микрофункций, а затем составьте их вместе. Не забывайте использовать рекурсию, когда это возможно.

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

Необязательное упражнение с тегами

Определите новый тип Tag, который может отображать или скрывать часть текста в зависимости от значения логической переменной. Например, этот шаблон будет отображать окончательные рождественские пожелания только в том случае, если тег isXmas имеет положительное значение:

val template = """{title} {surname}, 
|thanks for your order.
|{isXmas}Merry Christmas!{/isXmas}""".trimIndent()

Попробуйте! Вы можете найти мое решение в том же репозитории.

Должны ли мы написать эту библиотеку?

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

Итак, почему я выбрал этот путь?

Во-первых, не было библиотеки, которая идеально соответствовала бы дизайну приложения, которое бы ее использовало, поэтому внедрение библиотеки шаблонов потребовало бы внесения изменений, которые мне не нравились — например, иметь слишком много зависимостей или требовать использования аннотаций или генерации кода.

Кроме того, библиотеки с открытым исходным кодом часто довольно велики, тогда как мои фактические требования были довольно ограниченными. Если я могу удовлетворить свои потребности с помощью 100 строк кода, это имеет больше смысла, чем импортировать библиотеку из 100 000 строк кода, загруженную ненужными функциями.

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

И, наконец, фактор времени. Я, вероятно, потратил около 3-4 часов на написание кода для этого поста, что, вероятно, меньше, чем время, которое мне потребовалось бы, чтобы выбрать, установить и научиться использовать существующую структуру шаблонов.

Конечно, как правило, я согласен с тем, что лучше не изобретать велосипед. Если бы была похожая небольшая библиотека с таким же функциональным подходом к шаблонам, я бы точно ей воспользовался. Но использование слишком сложного фреймворка для простой задачи похоже на езду на 18-колесном транспортном средстве за продуктами в супермаркете. Все дело в выборе правильного инструмента для работы.

💬 Если вам интересны подобные публикации, пожалуйста, подпишитесь на меня на Medium или в моем аккаунте в твиттере ramtop.

Если вам интересна тема, обсуждаемая в этом посте, вам следует подумать о покупке моей книги From Objects to Functions,где я более подробно их рассматриваю.