JavaRush /Курси /Kotlin SELF /Безпечне видобування даних із JSON-дерева

Безпечне видобування даних із JSON-дерева

Kotlin SELF
Рівень 49 , Лекція 1
Відкрита

1. Навігація в JsonElement без падінь

Коли ви вперше бачите JSON, хочеться думати про нього як про добре вихований об’єкт: «завжди є id, завжди число, завжди не null». Це відчуття приблизно як віра в те, що Wi‑Fi в аудиторії буде стабільним. Теоретично це можливо, але краще не будувати на цьому диплом.

Проблема в тому, що JSON без схеми часто приходить із зовнішніх джерел, де структура може змінюватися: поле перейменували, тип змінили, вкладеність зсунули, десь додали null, десь видалили ключ. І якщо в коді зробити один необережний рух (наприклад, !! або as), ви отримаєте падіння під час виконання замість зрозумілої й керованої поведінки.

Головна мета сьогоднішньої лекції — виробити звичку «кожен крок — це перевірка» і навчитися пакувати ці перевірки в маленькі helper‑функції, щоб код не перетворювався на ліс із if і return.

is, as? і кроки навігації

Якщо в Kotlin ви робите as JsonObject, то прямо кажете: «Я клянуся, що це об’єкт». Якщо ви помилилися — буде виняток. Якщо ж ви робите as? JsonObject, то кажете: «Я сподіваюся, що це об’єкт, але якщо ні — поверни null, і я впораюся сам». І ось це «впораюся сам» — наш стиль дня.

Уявіть собі JSON як лабіринт, а JsonElement — як кімнату. Щоб перейти в наступну кімнату, вам потрібно: зрозуміти тип кімнати, акуратно відчинити двері й лише потім рухатися далі. У коді це виглядає як ланцюжок кроків «очікуваний тип → безпечне приведення → наступний крок».

Міні‑приклад «поганий» (крихкий) і «хороший» (стійкий) — на одному й тому самому JSON:


import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject

fun main() {
    val root = Json.parseToJsonElement("""{"user":{"name":"Ann"}}""")

    // ПОГАНО: jsonObject кидає виняток, якщо root не об’єкт
    val obj1: JsonObject = root.jsonObject

    // ДОБРЕ: безпечно, якщо root раптом виявиться масивом
    val obj2: JsonObject = root as? JsonObject ?: return

    println("fields=${obj2.size}") // fields=1
}

Сенс простий: поки ми працюємо з нестабільними даними, примусові перетворення й «гарантії на чесному слові» — наші вороги.

JsonObject["key"]: чому результат — JsonElement?

Коли ви робите obj["age"], результат має тип JsonElement?, тобто nullable. Kotlin ніби наперед попереджає вас: «Поле може бути відсутнім — і це нормально». І це справді нормально: відсутність поля — типовий випадок.

Тут важливо розрізняти два сценарії: «поля немає» і «поле є, але дорівнює null». У першому випадку obj["x"] == null. У другому випадку obj["x"] поверне не null, а вузол JsonNull. Якщо переплутати ці сценарії, можна зробити дуже дивні висновки про дані.

Давайте перевіримо це вручну:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject

fun main() {
    val root = Json.parseToJsonElement("""{"a":null}""") as? JsonObject ?: return

    val a = root["a"]          // НЕ null, це JsonNull
    val b = root["b"]          // null, поля немає

    println(a is JsonNull)     // true
    println(b == null)         // true
}

Це важлива цеглинка для майбутньої валідації, але вже зараз вона впливає на те, як ми пишемо «безпечне видобування».

3. JsonPrimitive і безпечні перетворення

Чому ми часто беремо рядок, а не «одразу Int»

Новачку природно хотіти: «Якщо поле — число, хочу Int». Але в дереві JSON примітиви влаштовані хитріше: JsonPrimitive — це контейнер «рядок/число/булеве значення», а ви вже вирішуєте, як інтерпретувати вміст.

Так, JsonPrimitive уміє багато, але в навчальному стилі корисно мислити так: спочатку беремо текст (рядок), потім безпечно перетворюємо. Це дисциплінує й допомагає пережити випадки, коли число прийшло рядком: "21" замість 21.

Ключовий момент: небезпечне перетворення (toInt()) кидає виняток, безпечне (toIntOrNull()) — повертає null. Саме це й потрібно для стійкості на зовнішніх даних.

Перетворення String -> Int?, String -> Double?, String -> Boolean?

Щойно ми вміємо отримувати рядок із примітива, можемо додавати безпечні перетворення. І тут є тонкість: булеве значення теж може бути «брудним». Наприклад, приходить "TRUE" або "yes". Валідацію таких речей ми обговорюватимемо пізніше, але навіть на етапі видобування корисно одразу вибрати стратегію.

Сьогодні візьмемо сувору стратегію: вважаємо true/false валідними, усе інше — null. Для цього зручно використовувати toBooleanStrictOrNull() (суворе перетворення, не вгадує „yes“). Якщо у вашій версії Kotlin воно недоступне, можна тимчасово зробити lowercase() і порівняти вручну, але в Kotlin/JVM зазвичай усе працює.

import kotlinx.serialization.json.JsonObject

fun getIntOrNull(obj: JsonObject, key: String): Int? {
    val text = getStringOrNull(obj, key) ?: return null
    return text.toIntOrNull()
}

fun getDoubleOrNull(obj: JsonObject, key: String): Double? {
    val text = getStringOrNull(obj, key) ?: return null
    return text.toDoubleOrNull()
}

fun getBooleanOrNull(obj: JsonObject, key: String): Boolean? {
    val text = getStringOrNull(obj, key) ?: return null
    return text.toBooleanStrictOrNull()
}

Сенс такий: ми відокремили «видобування даних» від «інтерпретації» — і зробили це без винятків.

4. Helper‑функції для безпечного читання

Коли ви пишете «безпечне видобування» вручну, швидко з’являється копіпаста: те саме obj[key], те саме as?, те саме ?: return null. У цей момент логічно винести це в маленькі helper‑функції.

Важливо: helper‑функції мають бути нудними. Без «магії», без прихованих винятків, без спроб «вгадати, що мав на увазі автор JSON». Їхня сила — у передбачуваності.

Примітиви та рядки

Почнемо з двох простих:

import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

fun getPrimitiveOrNull(obj: JsonObject, key: String): JsonPrimitive? {
    val el: JsonElement = obj[key] ?: return null
    return el as? JsonPrimitive
}

fun getStringOrNull(obj: JsonObject, key: String): String? {
    return getPrimitiveOrNull(obj, key)?.content
}

Тут контракт кристально чесний: немає поля або це не примітив — повернули null. Жодних «порожніх рядків за замовчуванням», жодних 0. Ми не приховуємо проблему — ми робимо її керованою.

Вкладені об’єкти: getObjectOrNull

Щойно JSON стає «живим», у ньому з’являються вкладені структури. Наприклад:

{
  "expense": {
    "id": "10",
    "title": "Coffee",
    "amount": 250
  }
}

Якщо ви щоразу писатимете ((root as? JsonObject)?.get("expense") as? JsonObject), ви дуже швидко почнете ненавидіти JSON як жанр мистецтва. Тож додамо helper для об’єктів:

import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

fun getObjectOrNull(obj: JsonObject, key: String): JsonObject? {
    val el: JsonElement = obj[key] ?: return null
    return el as? JsonObject
}

І використаємо:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject

fun main() {
    val root = Json.parseToJsonElement("""{"expense":{"amount":"250"}}""")
    val rootObj = root as? JsonObject ?: return

    val expenseObj = getObjectOrNull(rootObj, "expense") ?: return
    val amount = getIntOrNull(expenseObj, "amount")

    println(amount) // 250
}

Зверніть увагу: amount у нас прийшов рядком "250", але ми все одно коректно розпарсили його, бо працюємо через .contenttoIntOrNull().

Масиви: JsonArray і безпечні індекси

Трохи пізніше (на наступних лекціях) ми збиратимемо звіти й оброблятимемо дані, а там масиви — звична справа: список витрат, список користувачів, список «чого завгодно».

JsonArray поводиться як список (List<JsonElement>). Отже, у нас є хороші інструменти зі стандартної бібліотеки: наприклад, getOrNull(index), щоб не отримати IndexOutOfBoundsException.

Зробімо helper:

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

fun getArrayOrNull(obj: JsonObject, key: String): JsonArray? {
    val el: JsonElement = obj[key] ?: return null
    return el as? JsonArray
}

І приклад читання першого елемента:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject

fun main() {
    val root = Json.parseToJsonElement("""{"items":[{"id":1},{"id":2}]}""") as? JsonObject ?: return
    val items = getArrayOrNull(root, "items") ?: return

    val first = items.getOrNull(0) as? JsonObject
    val id = first?.let { getIntOrNull(it, "id") }

    println(id) // 1
}

Тут ми не припускаємо, що масив непорожній, і не припускаємо, що перший елемент — об’єкт. Усе перевіряємо.

5. Практичний приклад: імпорт витрати

Міні‑алгоритм безпечного читання

Щоб у голові це не перетворилося на кашу, зручно тримати короткий алгоритм. Він майже завжди однаковий: «дістали вузол → перевірили тип → дістали вміст → перетворили».

Можна уявити це як мініблок-схему:

flowchart TD
    A["JsonObject['key']"] --> B{Елемент є?}
    B -->|ні| N["null (немає поля)"]
    B -->|так| C{Тип вузла підходить?}
    C -->|ні| N
    C -->|так| D["JsonPrimitive.content"]
    D --> E{Конверсія успішна?}
    E -->|ні| N
    E -->|так| OK["Повернули значення T"]

Головний сенс: ми не «боремося» з помилками через винятки — ми робимо неможливим падіння на етапі видобування.

Парсинг Expense із JsonObject

Зробімо маленький шматочок нашого навчального застосунку. Нехай у нас є сутність витрати, і ми хочемо імпортувати її з JSON‑дерева (без @Serializable, бо формат може бути нестабільним).

Візьмемо просту модель:

data class Expense(
    val id: Int,
    val title: String,
    val amount: Int,
)

Тепер напишемо функцію «спробувати видобути» витрату з JsonObject. Зверніть увагу: це ще не повноцінна валідація бізнес‑правил (наприклад, «amount > 0»), це саме безпечне видобування: «якщо змогли — повернули, якщо ні — null».

import kotlinx.serialization.json.JsonObject

data class Expense(val id: Int, val title: String, val amount: Int)

fun parseExpenseOrNull(obj: JsonObject): Expense? {
    val id = getIntOrNull(obj, "id") ?: return null
    val title = getStringOrNull(obj, "title") ?: return null
    val amount = getIntOrNull(obj, "amount") ?: return null

    return Expense(id = id, title = title, amount = amount)
}

Тепер приклад використання з реальним JSON, де типи «танцюють»:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject

fun main() {
    val text = """
        {
          "expenses": [
            {"id": "1", "title": "Coffee", "amount": 250},
            {"id": 2, "title": "Taxi", "amount": "bad-number"}
          ]
        }
    """.trimIndent()

    val root = Json.parseToJsonElement(text) as? JsonObject ?: return
    val arr: JsonArray = getArrayOrNull(root, "expenses") ?: return

    for (el in arr) {
        val expObj = el as? JsonObject ?: continue
        val expense = parseExpenseOrNull(expObj)

        println(expense)
        // Expense(id=1, title=Coffee, amount=250)
        // null
    }
}

Ми отримали важливу поведінку: програма не впала на другому елементі, а спокійно вивела null. Так, потім ми це покращимо й повертатимемо Ok/Error з повідомленням (наступна лекція), але на етапі видобування ми вже досягли головного — стійкості.

Чому на етапі видобування краще null, а не «дефолт»

Коли новачок пише getIntOrNull, часто виникає спокуса: «якщо немає поля — поверну 0». На перший погляд це зручно. Але ця зручність токсична: ви втрачаєте інформацію про проблему.

Якщо «немає поля amount» перетворилося на 0, то далі ваш код може вирішити, що витрата на 0 USDT — це реальна витрата. А потім ви розслідуватимете: «Хто додає нульові витрати?», і виявиться, що це ви — тільки в минулому.

Тому базовий стиль дня такий: helper‑функції «видобування» повертають T?. А рішення «що робити, якщо null» ухвалюються вище — або на рівні валідації, або на рівні сценарію (наприклад, пропустити елемент, показати повідомлення користувачу тощо).

6. Типові помилки

Помилка №1: ланцюжки виду obj["x"]!!.jsonPrimitive.content.
Такий код виглядає «гарно» лише до першого нестабільного JSON. !! падає, якщо поля немає, а jsonPrimitive падає, якщо там не примітив. У результаті ви отримуєте падіння під час виконання, хоча могли б отримати null і спокійно обробити ситуацію.

Помилка №2: використовувати toInt() замість toIntOrNull().
toInt() — це «якщо рядок поганий, я падаю». Для зовнішніх даних це зазвичай неправильна стратегія. Приклад із NumberFormatException добре показує різницю: toIntOrNull() — безпечна альтернатива, що повертає null замість винятку.

Помилка №3: не відрізняти «поля немає» від «поле дорівнює null».
Якщо поле відсутнє, obj["k"] поверне null. Якщо поле є, але значення null, ви отримаєте вузол JsonNull. Це різні випадки, і іноді вони означають різні речі (особливо під час імпорту даних і міграцій форматів).

Помилка №4: робити helper‑функції, які «надто розумні».
Якщо getIntOrNull починає приймати "1 000", "1,000", "about 10", "ten", ви раптово перетворюєте видобування на вгадування. Вгадування породжує тихі помилки. Краще тримати helper‑функції суворими й передбачуваними, а «розумні» перетворення робити окремими функціями — і лише там, де це справді потрібно.

Помилка №5: повертати значення за замовчуванням (0, "", false) замість null надто рано.
Значення за замовчуванням приховують проблему й змішують два стани: «значення справді 0» і «значення невідоме/відсутнє». На етапі видобування майже завжди чесніше повернути null, а дефолт підставляти вже на рівні бізнес‑рішення, де ви можете пояснити, чому це коректно.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ