1. Введение
Когда вы только начинаете работать с JSON, хочется верить в простую сказку: «JSON всегда соответствует моей модели». Это отличная сказка, пока вы пишете формат сами или у вас строгий контракт, и никто не присылает вам “id”: "10" вместо 10. Но как только формат становится «схемы нет», «версия плавает», «поля иногда отсутствуют», вы начинаете либо плодить десятки nullable-полей, либо ловить ошибки декодирования, либо делать вид, что это не ваша проблема (спойлер: станет вашей).
JsonElement — это другой подход: мы не пытаемся сразу превратить вход в строгую Kotlin-модель. Вместо этого мы сначала превращаем JSON в дерево. А уже потом решаем, что из этого дерева нам нужно, и как с этим жить дальше.
Официально JsonElement — это sealed class, представляющий один JSON-элемент, и он может быть примитивом, массивом или объектом. При этом toString() печатает дерево обратно как валидный JSON. (kotlinlang.org)
2. JSON как дерево: идея DOM без HTML
Если вы когда-нибудь видели DOM в браузере, то идея будет знакомой: документ (или данные) — это дерево узлов. У узлов есть тип, и у разных типов — разные «внутренности». У объекта есть поля по ключам, у массива — элементы по индексам, у примитива — значение.
Важно привыкнуть к мысли: в JSON нет гарантии, что корень — объект. Корень может быть массивом ([ ... ]), числом (123), строкой ("ok"), булевым (true) или null. Поэтому базовый рефлекс «сразу кастану в JsonObject» — это как выходить на улицу без зонта, потому что «вроде небо ничего так». Иногда работает. Иногда — вы мокнете.
Небольшая схема того, как мы будем мыслить:
flowchart TD
A["JSON-текст (String)"] --> B["Json.parseToJsonElement(...)"]
B --> C["JsonElement (корень)"]
C --> D{"Какой тип узла?"}
D -->|JsonObject| E["Поля: key -> JsonElement"]
D -->|JsonArray| F["Элементы: [JsonElement]"]
D -->|JsonPrimitive| G["Значение (как примитив)"]
D -->|JsonNull| H["Явное null-значение"]
3. Типы узлов дерева JsonElement
Чтобы уверенно ходить по дереву, нужно знать «породы деревьев». В kotlinx.serialization у JSON-дерева есть несколько основных типов.
По документации, JsonElement может быть JsonPrimitive, JsonArray или JsonObject.
Отдельно существует JsonNull — это объект-одиночка (object), представляющий JSON null.
Сведём это в таблицу, чтобы мозг перестал паниковать:
| Тип узла | Пример в JSON | Как думать | На что похож |
|---|---|---|---|
|
|
«словарь/карта полей» | |
|
|
«список элементов» | |
|
|
«одно значение» | контейнер для примитивов |
|
|
«null как узел» | singleton-объект |
Обратите внимание на маленькую психологическую ловушку: JsonNull — это не отсутствие поля. Это именно значение null, которое явно присутствует в JSON. Про разницу поговорим отдельно чуть ниже.
4. Парсинг строки в дерево: Json.parseToJsonElement(...)
Сейчас будет момент «всё было сложно, а стало одной строчкой». Чтобы превратить JSON-текст в дерево, мы используем:
- Json.parseToJsonElement(string: String): JsonElement
Документация говорит прямо: функция десериализует строку JSON в соответствующее представление JsonElement и бросает SerializationException, если строка невалидна как JSON.
Минимальный пример — максимально короткий и честный:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
fun main() {
val text = """{"id": 10, "name": "Alice"}"""
val root: JsonElement = Json.parseToJsonElement(text)
println(root) // {"id":10,"name":"Alice"}
}
Заметьте важную штуку: мы не используем @Serializable и decodeFromString. Мы просто строим дерево.
Если вы читаете JSON откуда-то «снаружи» (файл, пользовательский ввод, чужой API), лучше сразу привыкнуть к try/catch, потому что вход может быть не JSON, а “почти JSON” (это отдельный жанр искусства).
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
fun tryParseJson(text: String): JsonElement? {
return try {
Json.parseToJsonElement(text)
} catch (e: SerializationException) {
null
}
}
5. Базовая навигация по дереву JsonElement
Когда вы ходите по JsonElement, вы всё время делаете одно и то же: предполагаете тип узла, проверяете его, переходите дальше. Поэтому удобно иметь маленькую функцию, которая хотя бы умеет описать, что перед нами.
Проверка типа узла через when
when по типу узла — это прям базовый фонарик в тёмном лесу:
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun describe(el: JsonElement): String = when (el) {
is JsonObject -> "object(fields=${el.size})"
is JsonArray -> "array(items=${el.size})"
is JsonPrimitive -> "primitive"
is JsonNull -> "null"
}
Тут есть аккуратная деталь: JsonNull технически является JsonPrimitive (в иерархии он наследник примитива), поэтому в реальном коде вы часто будете различать эти случаи внимательнее. Но как стартовая карта местности — годится.
Навигация: объект → поле → массив → элементы
А теперь покажем навигацию «объект → поле → массив → элементы». Делаем это максимально прямолинейно, но уже с безопасными приведениями as?:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun main() {
val root = Json.parseToJsonElement("""{"tags":["kotlin","json"]}""")
val obj = root as? JsonObject ?: return
val tags = obj["tags"] as? JsonArray ?: return
for (tagEl in tags) {
val tag = (tagEl as? JsonPrimitive)?.content
println(tag) // kotlin затем json
}
}
Это ещё не «красивый» код, но он очень учебный: каждый шаг явно показывает, где мы можем ошибиться по типу. В следующей лекции мы будем превращать такие куски в аккуратные helper-функции, чтобы не писать одно и то же 200 раз (потому что так обычно и начинается выгорание).
Почему JsonObject["key"] возвращает JsonElement?
Когда вы берёте поле у JsonObject, вы пишете что-то вроде obj["name"]. И тут начинается интересная часть: результат — nullable. И это не издевательство, а правильная модель мира.
Причина простая: поле может отсутствовать. И отсутствие поля — это не то же самое, что поле со значением null.
Покажем на примере. У нас есть два JSON:
1) Поля нет вообще: {}
2) Поле есть, но равно null: {"nickname": null}
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
fun main() {
val obj1 = Json.parseToJsonElement("""{}""") as JsonObject
val obj2 = Json.parseToJsonElement("""{"nickname": null}""") as JsonObject
val a = obj1["nickname"]
val b = obj2["nickname"]
println(a == null) // true (поля нет)
println(b is JsonNull) // true (поле есть, но значение = null)
}
Эту разницу важно держать в голове, потому что дальше (в валидации и в «безопасном извлечении») вы будете принимать решения типа: «если поля нет — это ошибка», «если поле есть и null — разрешаем», «если поле строка — ок, если массив — ругаемся».
Сегодня наша цель проще: научиться видеть эти случаи и не путать их.
Как читать дерево «в глубину» и не превращать код в кашу
На этом этапе очень легко сделать код нечитаемым. Новички часто пишут так: «сразу цепочкой, без остановок». В итоге получается что-то вроде obj["x"]!!.jsonObject["y"]!!.jsonArray[0]..., и оно падает при первом же отклонении структуры.
Сегодня мы пока не строим идеальные helper-функции (это следующая лекция), но можем взять базовый стиль, который уже делает код спокойнее: двигаться маленькими шагами и давать переменным нормальные имена.
Вот пример «достаём массив items из объекта» — без фанатизма, но читаемо:
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
fun getItemsArray(rootObj: JsonObject): JsonArray? {
val itemsEl = rootObj["items"] ?: return null
return itemsEl as? JsonArray
}
В этом месте многие спрашивают: «почему не as JsonArray?» Потому что мы сейчас работаем с динамической структурой. И если мы ошиблись — пусть это будет null, а не авария. А как именно реагировать на null — решение более высокого уровня (и оно появится в лекции про безопасное извлечение и валидацию).
6. Корень JSON не обязан быть объектом
На уровне привычки очень важно перестать думать: «корень = объект». В мире JSON это не обязано быть так. И если вы пишете код, который падает на "[" в первом символе, то поздравляю: ваш парсер живёт в мире строгих моральных принципов, а данные — нет.
Давайте посмотрим три варианта корня.
Корень — массив
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
fun main() {
val root = Json.parseToJsonElement("""[1, 2, 3]""")
val arr = root as? JsonArray ?: return
println(arr.size) // 3
}
Корень — примитив
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
fun main() {
val root = Json.parseToJsonElement("42")
val p = root as? JsonPrimitive ?: return
println(p.content) // 42
}
Корень — null
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
fun main() {
val root = Json.parseToJsonElement("null")
println(root is JsonNull) // true
}
Да, JSON может быть просто null. И иногда это даже «валидный контракт» (хотя звучит как мем).
7. Мини-инспектор JSON для консольного приложения
Чтобы не оставлять знания в вакууме, давайте добавим в наше учебное консольное приложение маленькую фичу-отладчик. Напомню общий сюжет курса: мы уже делали хранение данных в JSON, но там формат был стабильный и мы декодировали в модели. Теперь добавим режим, который принимает JSON-текст и говорит: «что это вообще такое по структуре?».
Это не «полноценная обработка», а именно инспектор. Он пригодится, когда ваш файл/ввод не соответствует ожиданиям, и вы хотите быстро понять, где сломалась реальность.
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun inspectJson(text: String): String {
val root: JsonElement = try {
Json.parseToJsonElement(text)
} catch (e: SerializationException) {
return "Это не JSON"
}
return when (root) {
is JsonObject -> "Корень: объект, полей=${root.size}"
is JsonArray -> "Корень: массив, элементов=${root.size}"
is JsonPrimitive -> "Корень: примитив (${root.content})"
is JsonNull -> "Корень: null"
}
}
И крошечный main, чтобы увидеть это вживую:
fun main() {
val text = readln()
println(inspectJson(text))
// Пример ввода: {"id":1,"name":"Ann"}
// Вывод: Корень: объект, полей=2
}
Да, это выглядит просто. Но именно такие «простые штуки» потом экономят часы отладки: вы хотя бы понимаете, что вы парсите, и почему ваш код, ожидающий объект, получил массив.
8. Типичные ошибки при знакомстве с JsonElement
Ошибка №1: считать, что корень JSON всегда объект.
Это самая частая ловушка, потому что большинство «красивых» примеров JSON в интернете начинается с {...}. На практике корень может быть массивом, примитивом или null, а JsonElement как раз и существует, чтобы не делать ложных предположений. Проверяйте тип корня явно, особенно в местах, где JSON приходит извне.
Ошибка №2: делать принудительные приведения as JsonObject без проверок.
Принудительное приведение — это ставка «я на 100% уверен». При нестабильном JSON это почти всегда ставка против себя. Гораздо безопаснее начинать с as? и раннего выхода (return), пока вы строите понимание структуры. И уже потом, когда вы добавите слой валидации, вы сможете делать более строгие решения.
Ошибка №3: путать отсутствие поля и null-значение поля.
obj["key"] возвращает null, если поля нет, а если поле есть и оно равно JSON null, вы получите JsonNull. Эти два случая часто означают разные вещи для бизнес-логики: «не прислали» и «прислали, но пусто» — это не одно и то же.
Ошибка №4: думать, что parseToJsonElement — это “безопасно и никогда не падает”.
Парсинг падает на невалидном JSON и кидает SerializationException. Это нормально, это честно, и это нужно обрабатывать. Особенно если вы даёте пользователю вводить JSON руками (а люди умеют печатать, но иногда добавляют лишнюю запятую из чистой любви к искусству).
Ошибка №5: пытаться «сразу сделать всё» в одной функции.
Новички часто смешивают парсинг, навигацию, извлечение, дефолты, проверки диапазонов и печать ошибок в одном огромном блоке. Получается код, который невозможно сопровождать. Сегодня мы сознательно ограничились первой ступенькой: представили JSON как дерево и научились делать базовую навигацию. Дальше будет безопасное извлечение и отдельный слой валидации — и именно там появится аккуратная архитектура.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ