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 як дерево й навчилися робити базову навігацію. Далі буде безпечне витягування й окремий шар валідації — і саме там зʼявиться акуратна архітектура.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ