JavaRush /Курсы /Kotlin SELF /JsonElement как дерево JSON

JsonElement как дерево JSON

Kotlin SELF
49 уровень , 0 лекция
Открыта

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 Как думать На что похож
JsonObject
{"id": 1, "name": "Ann"}
«словарь/карта полей»
Map<String, JsonElement>
JsonArray
[1, 2, 3]
«список элементов»
List<JsonElement>
JsonPrimitive
"hi", 42, true, null
«одно значение» контейнер для примитивов
JsonNull
null
«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 как дерево и научились делать базовую навигацию. Дальше будет безопасное извлечение и отдельный слой валидации — и именно там появится аккуратная архитектура.

1
Задача
Kotlin SELF, 49 уровень, 0 лекция
Недоступна
Сканер корня
Сканер корня
1
Задача
Kotlin SELF, 49 уровень, 0 лекция
Недоступна
Каталог ключей
Каталог ключей
1
Задача
Kotlin SELF, 49 уровень, 0 лекция
Недоступна
Сумма массива
Сумма массива
1
Задача
Kotlin SELF, 49 уровень, 0 лекция
Недоступна
Статус полей
Статус полей
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ