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 — это контейнер «строка/число/boolean», а вы уже решаете, как интерпретировать содержимое.

Да, JsonPrimitive умеет многое, но в учебном стиле полезно мыслить так: сначала берём текст (строку), потом безопасно конвертируем. Это дисциплинирует и помогает пережить случаи, когда число пришло строкой: "21" вместо 21.

Ключевой момент: unsafe‑конверсия (toInt()) кидает исключение, safe‑конверсия (toIntOrNull()) — возвращает null. Это ровно то, что нам нужно для устойчивости на внешних данных.

Конверсии String -> Int?, String -> Double?, String -> Boolean?

Как только мы умеем получать строку из примитива, мы можем добавлять безопасные конверсии. И тут есть тонкость: 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", вы внезапно превращаете извлечение в угадайку. Угадайка порождает тихие ошибки. Лучше держать helpers строгими и предсказуемыми, а «умные» преобразования делать отдельными функциями и только там, где это действительно нужно.

Ошибка №5: возвращать дефолты (0, "", false) вместо null слишком рано.
Дефолт скрывает проблему и смешивает два состояния: «значение реально 0» и «значение неизвестно/отсутствует». На этапе извлечения почти всегда честнее вернуть null, а дефолт подставлять только на уровне бизнес‑решения, где вы можете объяснить, почему это корректно.

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