Привет, Хабр! Меня зовут Антон Николаев, я senior android-разработчик в Okko, работаю в команде «Молодость» — занимаюсь мультипрофилем и всем, что связано с детским контентом.
Сегодня расскажу о JSON Schema и как мы используем эту спецификацию на проекте, а также о библиотеке kotlinx.serialization и том, как она упростила нам работу со схемами. Статья будет полезна разработчикам, которые интересуются библиотекой kotlinx.serialization и хотят глубже узнать её устройство. В ней обсудим:
Что такое JSON Schema
как используем на проекте
с какими проблемами столкнулись
какой вариант решения выбрали
Теорию kotlinx.serialization
пример сериализации
что такое KSerializer
как сериализуются примитивы и сложные структуры
почему нам не подошла дефолтная сериализация
Как использовали библиотеку на проекте для решения проблемы
пример кода
что получили в итоге
Побежали!
Что такое JSON Schema
JSON Schema и проблемы стандарта
JSON Schema — это распространенный стандарт описания структуры данных, в нашем случае — джейсонов. Почитать больше о спецификации стандарта и популярных примерах его использования можно на официальном ресурсе.
Схема создана для описания JSON-данных, но при этом и сама по себе является JSON-объектом. С помощью ключевых слов в ней создаются правила валидации структуры объекта и типов его полей.
Приведем пример JSON-схемы и валидного для нее JSON:

На схеме слева можно увидеть поля, которые и должен содержать наш JSON: id, name, список collectionItems. Справа же — корректный JSON, который валиден для нашей схемы.
Как мы используем JSON Schema на проекте и с какими проблемами столкнулись при использовании?
Примерно так же, как GraphQL: просто прописываем, как должен выглядеть ответ от сервера, а сервер присылает ответы в нужном виде. В JSON-схеме указываем поля, которые хотим получить от бэкенда, деплоим это на сервер, а бэкенд возвращает ответы на наши запросы в соответствии с нашей схемой.
Рассмотрим, как это выглядит в приложении.

Слева расположены пункты меню, справа — главная с карточками, по которой можно навигироваться вправо и вниз. Чтобы отобразить главную, мы используем сразу три запроса:
X — запрос пунктов меню и первой страницы главной;
Y — запрос элементов рейла при горизонтальном скролле;
Z — запрос рейлов (строк) при вертикальной пагинации.
Так как на главной мы хотим отображать одни и те же элементы карточек, запросы тоже должны возвращать одни и те же поля. Если какой-то запрос не вернул поле — возникнут баги. Например, если не вернется title, то мы не сможем отобразить заголовок или карточки будут отличаться внешне.
Рассмотрим, как выглядит ответ бекенда для одного из запросов. На скрине ниже — ответ из Charles Proxy на запрос topmainmenu. В структуре можно увидеть, что он состоит из элементов и выглядит довольно запутанным из-за большой вложенности. Но удобное отображение в чарлике позволяет сориентироваться и понять, как будет выглядеть ответ.

Все три запроса, приведённых выше, мы описываем с помощью json-схемы, каждая из которых содержит примерно 1,5 тысячи строк. Причин у этого две:
величина запроса сама по себе,
большая вложенность, которая создается из-за специфичных для json-схем полей — property, type, items и других.
Из этого возникают три проблемы:
Сложно ориентироваться в схеме из-за большой вложенности и множества лишних полей.
Невозможно переиспользовать части схемы в других схемах. А так как мы хотим, чтобы все три запроса возвращали одни и те же элементы, было бы круто вынести какие-то куски в отдельную схему.
Добавление новых полей в схемы (в несколько запросов) отнимает много времени.
Варианты решения проблем
Вариант 1. Вынести куски схем в отдельные файлы и мапить их с помощью скрипта, допустим, на Python, в единую схему
Решает проблему с переиспользованием, но не облегчает чтение, а добавлять новые поля в элементы по-прежнему сложно.
Вариант 2. Использовать другой формат записи — например, YAML
Если сочетать с предыдущим пунктом — решает вопрос переиспользования, но оставляет специфичные для json-схем поля, которые усложняют чтение.
Вариант 3. Не редактировать json-схему непосредственно, а описывать все в data-классах и сериализовать схему
Сделать это можно либо через рефлексию, либо с использованием библиотеки сериализации. Способ решает обе наши проблемы — им мы и воспользовались.
Для сериализации можно использовать любую библиотеку:
kotlinx.serialization
Jackson
Moshi
Gson
Мы уже использовали на проекте kotlinx.serialization, поэтому остановили свой выбор на ней.
Как будет выглядеть итоговый вариант решения?
Хотим описывать наши схемы в data-классах и генерировать схемы в виде json.
Опишем схему так, как показано на скрине справа - в data классах. Запустим генерацию — получим то, что слева, в виде json. Даже такой простой пример показывает, что чтение упростилось: меньше строк, запись лаконична, видны привычные для каждого kotlin-разработчика классы.

kotlinx.serialization — теория
Пример сериализации
Сериализация данных — это процесс преобразования объектов программы в формат, который можно сохранить или передать по сети, а потом восстановить в исходное состояние.
Рассмотрим небольшой пример сериализации и десериализации с помощью библиотеки kotlinx.serialization на примере класса Data.
import kotlinx.serialization.Serializable
@Serializable
data class Data(val a: Int, val b: String)
Для сериализации необходимо создать объект класса и для него вызвать функцию encodeString. В результате получим json, который может быть передан далее по сети.
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
@Serializable
data class Data(val a: Int, val b: String)
fun main() {
val json = Json.encodeToString(Data(42, "str"))
println(json) // {"a":42, "b":"str"}
}
Для десериализации нужно для json вызвать функцию decodeFromString. В результате получим объект класса Data.
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
@Serializable
data class Data(val a: Int, val b: String)
fun main() {
val obj = Json.decodeFromString<Data>("""{"a":42, "b": "str"}""")
println(obj) // Data(a=42, b="str")
}
Что такое KSerializer
Для каждого класса в библиотеке есть также класс KSerializer. Он уже есть в библиотеке для примитивов и некоторых сложных структур, а для классов на нашем проекте, помеченных аннотацией serializable, он будет сгенерирован.

Каждый KSerializer содержит функции serialize и deserialize, а также важную сущность — descriptor. В будущем она нам понадобится для генерации.
KSerializer содержит важную информацию о классе:
serialName — имя сериализуемого класса,
kind — тип дескриптора (PrimitiveKind, StructureKind),
elementCount — количество дочерних элементов, если это структура,
getElementDescriptor(index: Int) — получение дочерних дескрипторов,
getElementName(index: Int) — получение имен дочерних элементов.
Какие типы данных можно сериализовать?

Не указываем здесь классы, потому что для них создаём отдельный сериализатор, когда обозначаем их аннотацией serializable.
Как сериализуется примитив
В качестве примера возьмём сериализатор для Int. У него есть свой дескриптор, который имеет тип PrimitiveKind.INT. Функция serialize вызывает у энкодера encodeInt. Функция deseralize, в свою очередь, вызывает декодер.

Как сериализуется сложная структура
Разберёмся на примере классов. Чтобы сериализовать или десериализовать класс, нужно пробежаться по всем дочерним элементам. Когда мы десериализуем класс, мы, во-первых, вызываем структуру beginStructure — она сообщает декодеру, что мы начали десериализацию сложного объекта.

После этого пробегаемся по всем элементам и декодируем их. В нашем случае, в цикле while мы декодируем все элементы, а по окончании цикла вызовем функцию endStructure, чтобы сообщить декодеру или энкодеру, что кодирование класса закончено.
Как использовали библиотеку на проекте для решения проблемы
Почему нам не подходит дефолтная сериализация
Потому что она сериализует только конкретные объекты. Посмотрим на пример: в дефолтной сериализации мы берём инстанс класса Element, сериализуем его и получаем json.

Нам же нужно взять сам класс и сгенерировать из него json-схему. В полученной схеме уже не будет конкретных значений полей, только описание класса, поля id, type (string), name.

Таким образом, кастомная сериализация позволяет:
сериализовать не инстанс класса, а саму его структуру;
добавлять дополнительные поля, специфичные для JSON Schema — version, properties, items.
Описание кода генерации
Шаг 1: запускаем генерацию
Есть главная функция, которую мы вызываем, когда начинаем сериализовать класс. Она создаёт json-объект, в который мы кладём версию нашей схемы и вызываем функцию acceptInternal.

Шаг 2: обрабатываем все нужные типы
acceptInternal проверяет тип нашего дескриптора (примитив, класс, лист) и, в зависимости от того, чем является дескриптор, вызовет соответствующую функцию.

Шаг 3: обрабатываем примитивы
Для примитивов функция вернёт json-объект, который содержит тип поля и его значения — typeBoolean, typeInt, typeNumber и так далее.

Шаг 4: обрабатываем классы
Это задача с маленькой звёздочкой. Функция для класса также вернет json-объект и, чтобы его создать, нужно пройтись по всем полям класса, получить json-объекты для каждого поля и положить всё в properties. Для получения json-объекта поля класса мы вызываем функцию acceptInternal, то есть — используем рекурсию.
Почему делаем это с помощью рекурсии? Дело в том, что поля могут быть не только примитивами, но и сложными классами. Функция acceptInternal проверит, чем именно является поле, и вернёт нужный json-объект.

Что получилось в итоге
На примере слева видим, как будет выглядеть описание схемы для topmainmenu. Если запустим генерацию, то получим описание в виде json-схемы справа.

Здесь хорошо видно разницу в количестве строк. Слева data-классы занимают всего 500 строк, справа json schema — около 1,5 тысяч. Кроме того, так как мы описали всё в котлиновских классах, мы сможем использовать автокомплиты, подсказки от Android Studio, что значительно облегчит работу. А использование классов kotlin позволит использовать наследование, выносить классы в отдельные файлы и переиспользовать их в схемах.
Полезное
https://github.com/nikolajew-a-a/JsonSchemaGenerator — код сделанного мной генератора
https://youtu.be/FlBkE4V7VGI?si=a5Xj2yeRvVD\_YNoB — здесь показали, как с помощью kotlinx.Serialization сделать мапперы из data в domain классы
https://jpoint.ru/archive/2022/talks/a859ba80bcb8d00e168dbfe41c045b84/ — просто интересный доклад, в котором достаточно глубоко рассказывается про библиотеку
Комментарии (2)

hddn
31.10.2025 12:44Начал помню было изучать это всё, даже понравилось сначала концептуально.
Но потом захотел поиграться с форматом Amazon Ion - и оказалось, что kotlinx.serialization вообще не знает и не дружит с таким. Не жсоном единым всё-таки живём. И сразу стало не интересно.
Причём, как оказалось, нет быстрого способа подружить/прикрутить Ion к kotlinx.serialization. Количество приседаний оказалось огромным и просто неоправданным.
Вобщем, продолжаем жить на Jackson для JSON и специализированных Java-либах для всего остального. И потихоньку переползаем с Kotlin обратно на Java (я про бэкенд). Как бы ни было печально.
tbl
Какие-то простые json-ы в
kotlinx.serializationлегко туда-сюда гонять. Чуть что-то сложнее, например, хранить дерево с дополнительными атрибутами у узлов, уже практически нельзя. Если в jackson можно использовать@JsonAnyGetter/@JsonAnySetter, то тут забудьте об этом. Какие-то кастомныеCollection-типы, имплементящие стандартные коллекции java (Например,EnumSet/EnumMap) - забудьте об этом. Либо у вас в коде будет куча ненужного байтоперекладывания и грязных хаков и/или бойлерплейта, как в этой статье, обходящих эти ограничения.Глубина работы с вложенными объектами ограничена глубиной программного стека (внутри используются рекурсивные вызовы вместо хранения стека в хипе)
Нет возможности создать кастомный сериализатор/десериализатор сложнее, чем просто записать/прочитать pojo-like объект
Отвратительный API, например, нельзя просто так отнаследоваться от
SerialDescriptor, а все его полезные имплементации либоinternal, либоfinal, документация в возможных точках расширения бьет по рукам ("not intended for external implementation", "no guarantee" и т.п.), а без этого нельзя описать рекурсивно ссылающиеся типы, плоскийbuildClassSerialDescriptorдля этого просто не предназначен.А про работу с протобуфом - лучше бы ее не было, просто запихнули имплементацию узкого подмножества языка описания proto-схем "потому что, а почему бы и нет"
kotlinx.serializationкак концепт - вполне ок, но он разбивается об суровую действительность, хотелось бы видеть развитие проекта