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", но мы всё равно корректно распарсили, потому что работаем через .content → toIntOrNull().
Массивы: 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, а дефолт подставлять только на уровне бизнес‑решения, где вы можете объяснить, почему это корректно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ