Проблема большого объёма JSON

Представим, что мы отображаем на экране несколько однотипных элементов, например, список акций.

Представим SDUI-разметка для данного экрана. Акции отображаются с помощью компонента DataView, обёрнутого в StackView, где:

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

  • StackView выступает в роли контейнера, который способен отображать несколько элементов как вертикально, так и горизонтально, напоминая LinearLayout в Android.

Ниже представлен JSON-код, отображающий два элемента DataView из пяти. Остальные элементы аналогичны, за исключением изменений в полях "value" и "url".

JSON для акций
{
 "rootElement": {
   "type": "StackView",
   "content": {
     "axis": "vertical",
     "alignment": "fill",
     "children": [
       {
         "type": "DataView",
         "content": {
           "dataContent": {
             "title": {
               "textContentKind": "plain",
               "value": "Московская Биржа",
               "color": "textColorPrimary",
               "typography": "ParagraphPrimaryMedium"
             }
           },
           "iconView": {
             "backgroundIcon": {
               "url": "http://stock_echange.png"
             },
             "size": "small",
             "shape": "superellipse"
           }
         }
       },
       {
         "type": "DataView",
         "content": {
           "dataContent": {
             "title": {
               "textContentKind": "plain",
               "value": "Газпром",
               "color": "textColorPrimary",
               "typography": "ParagraphPrimaryMedium"
             }
           },
           "iconView": {
             "backgroundIcon": {
               "url": "http://gazprom.png"
             },
             "size": "small",
             "shape": "superellipse"
           }
         }
       },
       // Остальные 3 DataView
     ]
   }
 }
}

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

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

Решение: шаблонизация

Что должна делать функция?

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

В структуре JSON присутствуют ключи и значения. Чтобы определить, что значение является динамическим, его нужно пометить. В нашем случае такая метка — это "$", которая ставится не на само значение, а на ключ, в то время как в значении описывается путь к данным. Пример:

{
  "$text": "${data.stock.text}"
}

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

{
    "data": {
         "stock": {
               "text": "Акция"
         }
    }
}

Реализация шаблонизации

Сначала обозначим наши бизнес-данные в виде списка объектов и добавим их в секцию data:

{
   "data": {
      "stocks": [
         {
            "title": "Московская Биржа",
            "icon": "http://stoc_exchange.png"
         },
         {
            "title": "Газпром",
            "icon": "http://gazprom.png"
         }
         // Другие объекты...
      ]
   }
}

Теперь установим сам шаблон:

{
  "template": {
   "stock": {
     "type": "DataView",
     "content": {
       "dataContent": {
         "title": {
           "textContentKind": "plain",
           "$value": "${source.title}",
           "color": "textColorPrimary",
           "typography": "ParagraphPrimaryMedium"
         }
       },
       "iconView": {
         "backgroundIcon": {
           "$url": "${source.icon}"
         },
         "size": "small",
         "shape": "superellipse"
       }
     }
   }
 }
}

Функцию шаблонизации назовем applyTemplate. Существующая функция map идеально подходит для использования вместе с applyTemplate. Суть applyTemplate заключается в подстановке данных, трансформации шаблона, а map помогает пройтись по списку элементов и преобразовать один список в другой. Все функции находятся в отдельной секции computed. Вот как выглядят эти функции вместе:

{
   "computed": {
       "stockTemplate": {
           "type": "map",
           "$source": "${data.stocks}",
           "$function": {
               "type": "applyTemplate",
               "$source": "${it}",
               "$template": "${template.stock}"
           }
       }
   }
}

Основная идея этой конструкции заключается в том, чтобы пройтись по всем элементам из data.stocks и применить к ним шаблон template.stock. Так как applyTemplate ничего не знает о map, ей явно необходимо указать о существовании некоего ключа it, по которому лежат данные функции map.

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

  1. Функция ApplyTemplate, которая принимает и валидирует данные.

  2. Сущность JsonExpressionProcessor для обхода шаблона и замены динамических ключей их значениями.

  3. Сущность JsonExtractor, которая находит значение в JSON по указанному пути и это значение возвращает.

Техническая реализация

В упрощенном виде код сущности JsonExtractor выглядит так:

JsonExtractor
internal object JsonExtractor {


   fun extract(jsonElement: JsonElement, jsonPath: String): JsonElement {
          val path = parseJsonPath(jsonPath)
       return jsonElement.extract(path)
   }


   private fun JsonElement.extract(remainingPath: ArrayDeque<String>): JsonElement {
       if (remainingPath.isEmpty()) return this
       return when (this) {
           is JsonObject -> extract(remainingPath)
           is JsonArray -> extract(remainingPath)
           else -> throw IllegalArgumentException()
       }
   }


   private fun JsonObject.extract(remainingPath: ArrayDeque<String>): JsonElement {
       val value = this.get(remainingPath.removeFirst())
       return value.extract(remainingPath, fullPath)
   }


   private fun JsonArray.extract(remainingPath: ArrayDeque<String>): JsonElement {
       val index = remainingPath.removeFirst()
       return this.get(index).extract(remainingPath, fullPath)
   }
}

В текущей реализации JsonExpressionProcessor основывается на библиотеке GSON. Однако в будущем планируется его переработать, чтобы заменить зависимость от библиотеки на собственные абстракции.

Основные сущности библиотеки GSON
  • JsonElement— абстрактный базовый класс с наследниками JsonObject JsonArray, JsonPrimitive и JsonNull, которые позволяют отображать различные типы JSON-данных.

  • JsonObject моделирует JSON-объект и предоставляет методы для доступа к его свойствам.

  • JsonArray отображает массив JSON, предоставляя доступ к его элементам по индексу.

  • JsonPrimitive используется для базовых типов данных, таких как строки, числа и логические значения.

  • JsonNullобозначает значениеnull в JSON.

Возвращаясь к JsonExtractor, рассмотрим основные методы детальнее и начнем с первого — extract:

private fun JsonElement.extract(remainingPath: ArrayDeque<String>): JsonElement {
    if (remainingPath.isEmpty()) return this
    return when (this) {
        is JsonObject -> extract(remainingPath)
        is JsonArray -> extract(remainingPath)
        else -> throw IllegalArgumentException("Invalid path")
    }
}

Эта функция управляет рекурсивным поиском элемента в JSON-структуре, используя очередь для отслеживания пути. Если очередь пуста, возвращается текущий элемент. Для JsonObject и JsonArray поиск продолжается, а для JsonPrimitive или JsonNull выбрасывается исключение, так как они могут быть только конечной точкой пути.

Рассмотрим подробнее оставшиеся функции extract:

private fun JsonObject.extract(remainingPath: ArrayDeque<String>): JsonElement {
    val value = this.get(remainingPath.removeFirst())
    return value.extract(remainingPath, fullPath)
}


private fun JsonArray.extract(remainingPath: ArrayDeque<String>): JsonElement {
    val index = remainingPath.removeFirst()
    return this.get(index).extract(remainingPath, fullPath)
}

Эти функции предназначены для извлечения данных из соответствующих JSON-структур.

  • В функции для JsonObject извлечение осуществляется по ключу. Функция извлекает значение и удаляет фрагмент пути из очереди.

  • В функции для JsonArray извлечение происходит по индексу. Она также удаляет первый элемент (индекс) из очереди и с его помощью извлекает элемент из массива.

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

Также стоит упомянуть функцию parseJsonPath, реализация которой опущена для сокращения примера. Эта функция разбивает строку пути (например, "${source.title}") на элементы и превращает её в список — в нашем случае ["title"], который затем используется в качестве очереди. При этом "source" в данном контексте игнорируется. Позже станет понятно почему.

В конечном счете задача JsonExtractor — найти значение для пути и это значение вернуть.

Теперь рассмотрим следующую сущность — JsonExpressionProcessor:

JsonExpressionProcessor
internal object JsonExpressionProcessor {


   fun processElement(
       template: JsonElement,
       source: JsonElement,
       expressionValueExtractor: ExpressionValueExtractor,
   ): JsonElement {
       return when (template) {
           is JsonObject -> processJsonObject(template, source, expressionValueExtractor)
           is JsonArray -> processJsonArray(template, source, expressionValueExtractor)
           is JsonPrimitive -> processJsonPrimitive(template, source, expressionValueExtractor)
           else -> template
       }
   }


   private fun processJsonObject(
       jsonObject: JsonObject,
       source: JsonElement,
       expressionValueExtractor: ExpressionValueExtractor,
   ): JsonObject {
       return JsonObject().apply {
           jsonObject.entrySet().forEach { (key, value) ->
               add(
                   key.removePrefix(EXPRESSION_FIELD_PREFIX),
                   processElement(value, source, expressionValueExtractor)
               )
           }
       }
   }


   private fun processJsonArray(
       jsonArray: JsonArray,
       source: JsonElement,
       expressionValueExtractor: ExpressionValueExtractor,
   ): JsonArray {
       return JsonArray().apply {
           jsonArray.forEach { element ->
               add(processElement(element, source, expressionValueExtractor))
           }
       }
   }


   private fun processJsonPrimitive(
       jsonPrimitive: JsonPrimitive,
       source: JsonElement,
       expressionValueExtractor: ExpressionValueExtractor,
   ): JsonElement {
       if (!jsonPrimitive.isExpression()) return jsonPrimitive


       val expression = jsonPrimitive.asString
       val (rootKey, fullPath) = requireNotNull(pathRegex.find(expression)?.destructured)


       return when (rootKey) {
           SOURCE_ROOT_KEY -> JsonExtractor.extract(source, fullPath).deepCopy()
           TEMPLATE_ROOT_KEY -> {
                  val innerTemplate = expressionValueExtractor.extract(expression)
               processElement(innerTemplate, source, expressionValueExtractor)
           }
           else -> expressionValueExtractor.extract(expression)
       }
   }
}

Начнём по порядку с функции processElement :

fun processElement(
   template: JsonElement,
   source: JsonElement,
   expressionValueExtractor: ExpressionValueExtractor,
): JsonElement {
   return when (template) {
       is JsonObject -> processJsonObject(template, source, expressionValueExtractor)
       is JsonArray -> processJsonArray(template, source, expressionValueExtractor)
       is JsonPrimitive -> processJsonPrimitive(template, source, expressionValueExtractor)
       else -> template
   }
}

Здесь processElement принимает в качестве входных данных шаблон (template), основной источник извлечения данных (source) и дополнительный источник (expressionValueExtractor). Основной источник указан в функции applyTemplate.

Дополнительный источник используется, когда в шаблоне содержатся ссылки на другие секции (на что-то кроме source). Эта функция вызывается рекурсивно для каждого элемента в шаблоне и делегирует обработку конкретного типа элемента (JsonObject, JsonArray, или JsonPrimitive) соответствующей функции.

Следующая на очереди processJsonObject :

private fun processJsonObject(
   jsonObject: JsonObject,
   source: JsonElement,
   expressionValueExtractor: ExpressionValueExtractor,
): JsonObject {
   return JsonObject().apply {
       jsonObject.entrySet().forEach { (key, value) ->
           add(
               key.removePrefix(EXPRESSION_FIELD_PREFIX),
               processElement(value, source, expressionValueExtractor)
           )
       }
   }
}

Функция processJsonObject обрабатывает JSON-объекты, содержащие пары ключ-значение, создавая при этом новый JsonObject. Новый JsonObject создается для того, чтобы каждый шаблон был независимым экземпляром, не связанным с исходным шаблоном. Для каждого элемента из исходного объекта удаляется префикс “$” из ключа, поскольку значениями станут данные, полученные по соответствующим путям.

Теперь рассмотрим processJsonArray :

private fun processJsonArray(
    jsonArray: JsonArray,
    source: JsonElement,
    expressionValueExtractor: ExpressionValueExtractor,
): JsonArray {
    return JsonArray().apply {
       jsonArray.forEach { element ->
           add(processElement(element, source, expressionValueExtractor))
       }
    }
}

Здесь ничего примечательного не происходит: создаётся новый массив и каждый его элемент дополнительно обрабатывается ранее упомянутой функциейprocessElement.

И последняя на очереди — самая интересная функция processJsonPrimitive:

private fun processJsonPrimitive(
    jsonPrimitive: JsonPrimitive,
    source: JsonElement,
    expressionValueExtractor: ExpressionValueExtractor,
): JsonElement {
    if (!jsonPrimitive.isExpression()) return jsonPrimitive


    val expression = jsonPrimitive.asString
    val (rootKey, fullPath) = pathRegex.find(expression).destructured


    return when (rootKey) {
        SOURCE_ROOT_KEY -> JsonExtractor.extract(source, fullPath).deepCopy()
        TEMPLATE_ROOT_KEY -> {
            val innerTemplate = expressionValueExtractor.extract(expression)
            processElement(innerTemplate, source, expressionValueExtractor)
        }
        else -> expressionValueExtractor.extract(expression)
    }
}

Здесь происходит несколько вещей:

  1. Если jsonPrimitive не является выражением (не содержит синтаксис “${…}”), он возвращается без изменений.

  2. Если jsonPrimitive выражение, то сначала выражение разделяется на ключ ссылки (rootKey) и путь (fullPath) с помощью несложной регулярки. Например, для выражения “${source.title}”, секция — source, а путь — title.

После определения ссылки начинается поиск значения с возможными сценариями:

  1. При ссылке на source используется JsonExtractor, который извлекает значение из пути.

  2. При ссылке на другой шаблон возникает ситуация при которой один шаблон обращается к другому. В таком случае сначала с помощью expressionValueExtractor находится сам шаблон, а затем повторно вызывается processElement, чтобы объединить шаблоны в один.

  3. Когда ссылка указывает на другие источники, ответственность передается expressionValueExtractor, который возвращает соответствующее значение пути. Мы не будем подробно рассматривать его работу в контексте шаблонизации, но в целом он осуществляет поиск в других доступных местах и возвращает найденное значение. Это тема для отдельной статьи в рамках SDUI Dynamics.

Осталось самое простое — рассмотреть сущность самой функции applyTemplate.

ApplyTemplateFunction
internal class ApplyTemplateFunction() : BinaryFunction<JsonElement, JsonElement>(), HighOrderArgName {


    override val type: ComputedFunctionType = ComputedFunctionType.APPLY_TEMPLATE

    override fun getComputedArgs(dto: ApplyTemplateDto): List<ComputedArg> {
        return listOf(
            ComputedArg.createPlain(dto.sourceExpression, dto.sourceValue),
            ComputedArg.createPlain(dto.templateExpression, dto.templateValue)
        )
    }

    override fun evaluate(
        source: JsonElement,
        template: JsonElement,
    ): Any {
        require(template is JsonObject || template is JsonArray) {
            getIllegalArgumentExceptionMessage(template)
        }
        return JsonExpressionProcessor.processElement(template, source)
    }


    override fun getArgs(
        dto: ApplyTemplateDto,
        argumentExtractor: ComputedFunctionArgumentExtractor,
        context: ComputedFunctionContext,
    ): Pair<JsonElement, JsonElement> {
        val (source, template) = getComputedArgs(dto)
        val sourceArg = argumentExtractor.tryExtractArgument(source.expression, source.constant, context)
        val templateArg = argumentExtractor.tryExtractArgument(template.expression, template.constant, context)
        return sourceArg, templateArg
    }
}

Начнем рассматривать с getArgs:

override fun getArgs(
    dto: ApplyTemplateDto,
    argumentExtractor: ComputedFunctionArgumentExtractor,
    context: ComputedFunctionContext,
 ): Pair<JsonElement, JsonElement> {
    val (source, template) = getComputedArgs(dto)
    val sourceArg = argumentExtractor.tryExtractArgument(source.expression, source.constant, context)
    val templateArg = argumentExtractor.tryExtractArgument(template.expression, template.constant, context)
    return sourceArg, templateArg
}

В этой функции осуществляется извлечение аргументов с помощью сущностиComputedFunctionArgumentExtractor, которые будут предоставляться функции evaluate. Подробности работы этой сущности мы также рассматривать не будем, но если кратко, она извлекает аргументы: если это выражение (как в нашем примере), то возвращает соответствующее значение; если это объект, то возращает тот же объект. Таким образом, всегда получается ненулевой JsonElement. Также у нас есть аргумент ComputedFunctionContext, который необходим для получения результата из вышестоящей функции, в нашем примере это map.

Рассмотрим функцию evaluate:

override fun evaluate(
    source: JsonElement,
    template: JsonElement,
): Any {
    require(template is JsonObject || template is JsonArray) {
        getIllegalArgumentExceptionMessage(template)
    }
    return JsonExpressionProcessor.processElement(template, source)
}

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

В конечном итоге из функции evaluate мы получаем обработанный шаблон в виде JsonObject или JsonArray, который будет готов к десериализации в более привычный объект дизайн системы, который можно отрисовать на экране.

Так выглядит финальный json:

Финальный json акций
{
    "data": {
        "stocks": [
            {
                "title": "Московская Биржа",
                "icon": "http://stoc_exchange.png"
            },
            {
                "title": "Газпром",
                "icon": "http://gazprom.png"
            },
            // другие акции
        ]
    },
    "computed": {
        "stockTemplate": {
            "type": "map",
            "$source": "${data.stocks}",
            "$function": {
                "type": "applyTemplate",
                "$source": "${it}",
                "$template": "${template.stock}"
            }
        }
    },
    "template": {
        "stock": {
            "type": "DataView",
            "content": {
                "dataContent": {
                    "title": {
                        "textContentKind": "plain",
                        "$value": "${source.title}",
                        "color": "textColorPrimary",
                        "typography": "ParagraphPrimaryMedium"
                    }
                },
                "iconView": {
                    "backgroundIcon": {
                        "$url": "${source.icon}"
                    },
                    "size": "small",
                    "shape": "superellipse"
                }
            }
        }
    },
    "rootElement": {
        "type": "StackView",
        "content": {
            "axis": "vertical",
            "alignment": "fill",
            "$children": "${computed.stockTemplate}"
        }
    }
}

Каковы же результаты?

Шаблонизация существенно уменьшила объём JSON при работе с однотипными элементами. Результат для нашего примера:

  • Сокращение на 5 элементах составило около 45%.

  • При 100 элементах — примерно 74% (учитывая количество символов, а не строк).

Но помимо уменьшения объёма, JSON стал более читабельным благодаря четкому разделению данных и статичной разметки. Делаю вывод, что усилия не были напрасны.


В статье также упоминались другие функции, сущности и SDUI Dynamics в целом. Если эта тема вам интересна, оставьте комментарий, и мы подробно рассмотрим её в следующей статье.

Рекомендуемая литература:

  • «Компиляторы. Принципы, технологии и инструментарий», Ахо, Ульман, Лам.

  • «Теория вычислений для программистов» — Том Стюарт.

Подробнее о SDUI можно узнать из наших статей:

Эволюция Server-Driven UI: динамические поля, хэндлеры и многошаг
Server-Driven UI (SDUI) — это подход для динамичного и гибкого пользовательского интерфейса, когда с...
habr.com
Как катить фичи без релизов. Часть 2: про низкоуровневый Server Driven UI
Привет, меня зовут Елена Яновская , 5 лет в iOS-разработке, TechLead iOS в Альфа-Банке. Участвую в р...
habr.com

Подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.

Комментарии (10)


  1. zartdinov
    18.02.2025 11:23

    Красивое решение, понятно, что с protobuf свои заморочки, но было интересно сравнить с
    Content-Encoding: gzip. В теории сервер сам сожмет исходные данные намного лучше.


    1. LesleySin
      18.02.2025 11:23

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

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


      1. zartdinov
        18.02.2025 11:23

        Просто превью содержит такой текст:

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

        Качество связи мы не можем контролировать, а вот уменьшить количество передаваемой информации посредством сокращения JSON, — вполне.

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


        1. Denai
          18.02.2025 11:23

          сжатая версия после таких манипуляций будет на 20% тяжелее для сокращённого варианта конкретно в этой ситуации. т.е. сжатию такое преобразование определённо вредит.


  1. MACTEPyc
    18.02.2025 11:23

    8 лет назад аналогичное решение Gliechtenstein уже реализовывал в своих проектах. Рекомендую обратить внимание на его проект ST (JSON Selector + Transformer).

    Гифка


    1. nauruz07 Автор
      18.02.2025 11:23

      Да, очень даже похожий подход :)


  1. sshmakov
    18.02.2025 11:23

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

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


  1. Spinoza0
    18.02.2025 11:23

    Какие метрики ещё собирались кроме размера? Выглядит немного как преждевременная оптимизация


    1. nauruz07 Автор
      18.02.2025 11:23

      Цифры конкретные сейчас уже не приведу, но метрики измерял: время загрузки экрана, при медленном интернете было ощутимо; потребляемые ресурсы, всё это не бесплатно, конечно, но на производительности особо не сказывается.
      Если есть какие-то предложения по тому, что можно измерить, буду рад послушать)


  1. ryder0
    18.02.2025 11:23

    По размеру понятно. А как это сказалось на времени декодинга?