JavaRush /Курсы /Kotlin SELF /Настройка Json { ... }: читаемость, совместимость, строго...

Настройка Json { ... }: читаемость, совместимость, строгость

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

1. Json { ... } — часть контракта данных

Когда вы впервые видите Json { prettyPrint = true }, есть соблазн подумать: «О, это чтобы было красивенько». Частично да, но на практике настройки Json — это договорённость о формате, почти как «правила дорожного движения» для вашего файла. Одна команда пишет JSON, другая читает JSON, и обе должны одинаково понимать, какие поля могут отсутствовать, что делать с null, и насколько мы терпимы к “кривоватому” входу.

Важно поймать идею: Json — это не «JSON вообще», а конкретный кодек с конкретными правилами. Если в одном месте проекта вы пишете Json.encodeToString(...) (дефолтным объектом), а в другом создаёте Json { ignoreUnknownKeys = true } и читаете им, то вы уже живёте в двух слегка разных мирах. И баги будут из серии «ну у меня же работало…», потому что работало в другом мире.

Давайте заранее выберем правильную привычку: в приложении должен быть один (или очень ограниченное число) экземпляров Json, созданный явно и используемый везде.

Небольшой “скелет” для начала:

import kotlinx.serialization.json.Json

private val appJson = Json {
    prettyPrint = true
}

2. Настройки, которые делают файл удобным для человека

prettyPrint: читаемость и диффы

Когда вы храните JSON в файле (настройки, данные приложения, результаты работы), вы рано или поздно откроете его глазами. Иногда добровольно, иногда потому что «что-то сломалось, а дедлайн горит». И вот тут разница между JSON в одну строку и JSON с отступами — примерно как разница между «всё в одном абзаце без пробелов» и «нормальный текст».

Опция prettyPrint = true включает форматирование: переносы строк и отступы. Смысл данных не меняется, меняется только внешний вид. Это удобно для отладки, ручной проверки, и даже для Git (диффы становятся читабельнее).

Сначала покажем эффект на минимальном примере.

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class User(val id: Int, val name: String)

fun main() {
    val user = User(1, "Ada")

    val compact = Json { prettyPrint = false }
    val pretty = Json { prettyPrint = true }

    println(compact.encodeToString(user)) // {"id":1,"name":"Ada"}
    println(pretty.encodeToString(user))  // (будет с переносами и отступами)
}

Если хочется увидеть результат прямо в консоли аккуратно и без «лесенок» из пробелов в коде, удобно печатать многострочные строки через trimIndent() — это хорошая привычка для любых тройных кавычек, не только для JSON.

Чуть более “прикладной” пример: в нашем учебном приложении‑трекере расходов (условно назовём его BudgetCLI) мы храним данные в файле data/state.json. Когда prettyPrint включён, файл становится «самодокументируемым»: даже новичок видит структуру.

encodeDefaults: писать дефолты явно или экономить место

Kotlin (и особенно data class) любят значения по умолчанию: они делают конструкторы удобными и помогают модели оставаться валидной. А вот JSON может быть записан двумя способами:

  • записывать все поля, даже те, что равны значению по умолчанию;
  • записывать только “существенные” поля, а остальное восстанавливать по умолчанию при чтении.

Опция encodeDefaults управляет этим поведением при кодировании (записи в JSON).

Почему это важно? Потому что это влияет на то, что вы считаете “истиной формата”. Если поле не записано, то оно “как бы” равно значению по умолчанию — но только если читающая сторона тоже знает про это поле и его дефолт. Если же вы хотите, чтобы JSON был максимально явным (например, для конфигураций), то логичнее записывать дефолты.

Посмотрим на маленьком примере “настроек”:

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Settings(
    val theme: String = "light",
    val pageSize: Int = 20
)

fun main() {
    val a = Json { encodeDefaults = false }
    val b = Json { encodeDefaults = true }

    println(a.encodeToString(Settings())) // {}
    println(b.encodeToString(Settings())) // {"theme":"light","pageSize":20}
}

Это тот момент, где новички часто удивляются: «Почему вообще {} — это валидные настройки?» А потому что Kotlin‑модель может восстановиться полностью из дефолтов. И это даже удобно: минимальный файл, меньше шума.

Но есть и обратная сторона. Если вы храните файл как “договор”, который должен быть самодостаточным, то {} выглядит как издевательство над человеком, который открыл файл руками и хотел “посмотреть настройки”. Поэтому выбор encodeDefaults — не про “правильно/неправильно”, а про UX и про стабильность контракта.

Для нашего BudgetCLI обычно разумна такая логика: данные (расходы) мы пишем полностью, а вот какие-нибудь настройки интерфейса/сортировки можно писать более компактно. Но, чтобы не плодить два разных мира, чаще выбирают один стиль на проект и живут спокойно.

explicitNulls: «поле = null» и «поля нет» — это разные состояния

Nullable‑поля — это нормально. Не все любят null, но он упорно возвращается в нашу жизнь, как кот, который “просто посмотрит” и случайно съест вашу рыбу.

В JSON есть два разных состояния:

  • поле присутствует и равно null: "note": null
  • поля вообще нет: (ключ отсутствует)

Для человека это выглядит похоже (“ну тут как бы ничего”), а для программы — иногда принципиально разные ситуации. Например, «пользователь явно сбросил заметку» vs «в старом файле заметок не было вообще».

Опция explicitNulls управляет тем, будет ли null записываться в JSON явно или будет опускаться.

Сделаем модель расхода с nullable‑заметкой:

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Expense(
    val title: String,
    val amount: Double,
    val note: String? = null
)

fun main() {
    val x = Expense(title = "Coffee", amount = 3.5, note = null)

    val a = Json { explicitNulls = true }
    val b = Json { explicitNulls = false }

    println(a.encodeToString(x)) // {"title":"Coffee","amount":3.5,"note":null}
    println(b.encodeToString(x)) // {"title":"Coffee","amount":3.5}
}

Вот тут начинается взрослая архитектура формата: если вы выбираете explicitNulls = false, то в файле меньше мусора, и он выглядит приятнее. Но вы теряете возможность отличить “значение отсутствует, потому что его не было в файле” от “значение отсутствует, потому что мы записали его как null (или решили опустить)”. В нашем простом консольном приложении это чаще всего не критично, но важно понимать саму разницу, иначе потом можно долго спорить с коллегой на тему «почему у меня сбросилось поле».

3. Настройки чтения: совместимость и терпимость

ignoreUnknownKeys: совместимость при чтении старых и новых файлов

Представьте жизненную ситуацию: вы сохранили данные в JSON, потом обновили программу, поменяли модель (например, добавили новое поле), а у пользователя остался старый файл. Или наоборот: файл пришёл из другого места и содержит больше полей, чем ваша текущая модель. В этот момент сериализация превращается в очень принципиального контролёра: «Поле лишнее? Не по форме одеты? До свидания».

Опция ignoreUnknownKeys = true говорит парсеру: «Если в JSON есть поля, которых нет в модели — спокойно пропусти их». Это сильно повышает совместимость. Но, как и любая “поблажка”, она может скрыть проблему: если вы ожидали поле, но ошиблись в названии, то с включённым ignoreUnknownKeys вы не увидите ошибку — поле просто проигнорируется.

Мини‑демонстрация:

import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

@Serializable
data class User(val id: Int, val name: String)

fun main() {
    val input = """{"id":1,"name":"Ada","age":20}"""

    val strict = Json { ignoreUnknownKeys = false }
    val tolerant = Json { ignoreUnknownKeys = true }

    // strict.decodeFromString<User>(input)  // упадёт: поле "age" неизвестно
    val user = tolerant.decodeFromString<User>(input)
    println(user) // User(id=1, name=Ada)
}

Теперь привяжем это к нашему BudgetCLI. Допустим, мы добавили в расходы поле note, но у кого-то файл уже содержит ещё и поле currency (например, его добавлял другой участник команды или старая версия). Если мы включаем ignoreUnknownKeys, то чтение не ломается: мы загружаем то, что понимаем, а остальное игнорируем.

На практике это очень частый “первый тумблер”, который включают в проектах с файлами и длительным хранением данных. Но включать его стоит осознанно: если формат полностью под вашим контролем и вы хотите ловить любые расхождения жёстко, строгий режим тоже имеет право на жизнь.

isLenient: терпимый парсер для импорта, но не для «своего» формата

Иногда данные приходят не из вашего файла, а “откуда-то”: пользователь скопировал JSON из интернета, кто-то отредактировал файл руками, или другая система генерирует почти‑JSON (да, такие системы существуют; видимо, им просто скучно жить по стандарту). В этот момент строгий JSON‑парсер становится вашим врагом: он падает на любом отклонении от формата.

Опция isLenient = true делает парсер более терпимым к некоторым нестрогостям. Смысл именно в том, чтобы прочитать то, что похоже на JSON, даже если оно не идеально по спецификации. Но плата за это — размывание контракта: вы начинаете “принимать что попало”, а потом удивляетесь, почему в файле внезапно встречается странный формат.

В рамках нашего курса хорошее практическое правило такое: для своих файлов (которые вы сами же и записываете) isLenient обычно не нужен. Для импорта внешних данных — иногда полезен, но включать его стоит только там, где вы действительно готовы принимать “грязный” ввод.

Кодовая заготовка выглядит так:

import kotlinx.serialization.json.Json

private val importJson = Json {
    isLenient = true
    ignoreUnknownKeys = true
}

Обратите внимание на комбинацию: “терпимость к формату” плюс “терпимость к лишним ключам” — типичная связка для импорта. Но это именно импорт, а не основная запись вашего файла состояния.

4. Единый Json для приложения и файлового хранилища

Сейчас мы сделаем самый практичный шаг: перестанем создавать Json { ... } “на коленке” в каждом main, и заведём один экземпляр кодека для всего приложения. Это почти как единый стиль кавычек в проекте: вроде мелочь, но потом внезапно оказывается, что это экономит часы жизни.

Представим, что наш BudgetCLI хранит состояние как список расходов.

Минимальная модель:

import kotlinx.serialization.Serializable

@Serializable
data class Expense(
    val title: String,
    val amount: Double,
    val category: String = "other",
    val note: String? = null
)

@Serializable
data class BudgetState(
    val expenses: List<Expense> = emptyList()
)

Теперь — единый кодек. Я выберу набор настроек, который обычно хорошо подходит для “файл на диске, который можно открыть глазами”:

  • prettyPrint = true — читаемость
  • ignoreUnknownKeys = true — совместимость при чтении старых/новых файлов
  • encodeDefaults = true — файл самодостаточный, меньше сюрпризов
  • explicitNulls = false — чтобы не плодить "note": null везде
import kotlinx.serialization.json.Json

private val appJson = Json {
    prettyPrint = true
    ignoreUnknownKeys = true
    encodeDefaults = true
    explicitNulls = false
}

И теперь функции сохранения/загрузки (держим их короткими и “по делу”):

import java.io.File
import kotlinx.serialization.encodeToString

fun saveState(file: File, state: BudgetState) {
    val text = appJson.encodeToString(state)
    file.parentFile?.mkdirs()
    file.writeText(text)
}
import java.io.File
import kotlinx.serialization.decodeFromString

fun loadState(file: File): BudgetState? {
    return try {
        if (!file.exists()) return null
        val text = file.readText()
        appJson.decodeFromString<BudgetState>(text)
    } catch (e: Exception) {
        null
    }
}

И маленький main, который показывает, что всё работает:

import java.io.File

fun main() {
    val file = File("data/state.json")

    val state = BudgetState(
        expenses = listOf(
            Expense(title = "Coffee", amount = 3.5, category = "food"),
            Expense(title = "Book", amount = 12.0, note = "Kotlin")
        )
    )

    saveState(file, state)

    val loaded = loadState(file)
    println(loaded) // BudgetState(expenses=[Expense(...), Expense(...)])
}

Здесь важна не “крутизна приложения”, а дисциплина границ: Json отвечает за форматирование и правила кодирования/декодирования, файл — за транспорт строки, а main — за сценарий. Когда эти слои не смешаны, отлаживать намного проще.

Небольшая схема, чтобы закрепить в голове поток данных:

flowchart TD
    A[BudgetState в памяти] --> B[appJson.encodeToString]
    B --> C[JSON-строка]
    C --> D[file.writeText]
    D --> E[file.readText]
    E --> F[appJson.decodeFromString]
    F --> G[BudgetState в памяти]

5. Типичные ошибки при настройке Json

Ошибка №1: “В одном месте пишу Json.encodeToString, в другом читаю своим Json { ... } — ну и что?”
Проблема в том, что это два разных набора правил. Сегодня они случайно совместимы, а завтра вы включите encodeDefaults или поменяете explicitNulls, и у вас начнут появляться странные ситуации: файл выглядит по‑одному, программа ожидает другое. Спасает простое правило: один appJson на приложение и никаких “случайных” Json в середине кода.

Ошибка №2: включить ignoreUnknownKeys = true и перестать замечать ошибки в ключах.
Эта настройка спасает совместимость, но может скрыть опечатки. Если в JSON поле названо "catrgory" вместо "category", то оно будет проигнорировано, и вы получите значение по умолчанию. Для пользователя это выглядит как “программа потеряла данные”. Поэтому хорошо хотя бы иногда проверять входной JSON глазами или логировать факт загрузки/ошибки (в нашем упрощённом примере мы пока просто возвращаем null).

Ошибка №3: считать, что prettyPrint меняет данные, а не представление.
Иногда студенты начинают думать, что “красивый JSON” — это другой формат и его надо отдельно поддерживать. Нет: это тот же JSON, просто с пробелами и переносами. Он должен читаться обычным парсером точно так же, как и компактная версия.

Ошибка №4: не понимать разницу между “поля нет” и “поле = null”, а потом удивляться поведению explicitNulls.
Если вы не различаете эти состояния, вы можете случайно потерять смысл. Например, “заметка отсутствует, потому что это старая версия файла” и “заметка отсутствует, потому что пользователь её удалил” могут быть разными событиями. explicitNulls влияет на то, сможете ли вы потом отличить эти случаи по файлу.

Ошибка №5: включить isLenient “на всякий случай”.
Это почти всегда плохая идея для собственных файлов приложения. Вы сами же и генерируете JSON, так зачем ослаблять контроль? Терпимость нужна для импорта чужих данных, но там её стоит ограничивать отдельным кодеком или отдельной функцией, чтобы “грязный вход” не проникал в основной формат хранения.

Ошибка №6: менять настройки Json и забывать, что это меняет формат хранения.
Самая неприятная ловушка: вы поменяли encodeDefaults или explicitNulls, записали новый файл, а потом пытаетесь читать его старой версией программы или другой частью проекта. Поэтому относитесь к настройкам как к части формата: если вы их меняете — это изменение договора, а не “рефакторинг без последствий”.

1
Задача
Kotlin SELF, 47 уровень, 4 лекция
Недоступна
Два формата
Два формата
1
Задача
Kotlin SELF, 47 уровень, 4 лекция
Недоступна
Пустые дефолты
Пустые дефолты
1
Задача
Kotlin SELF, 47 уровень, 4 лекция
Недоступна
Следы null
Следы null
1
Задача
Kotlin SELF, 47 уровень, 4 лекция
Недоступна
Терпимый парсер
Терпимый парсер
1
Опрос
JSON + kotlinx.serialization, 47 уровень, 4 лекция
Недоступен
JSON + kotlinx.serialization
JSON + kotlinx.serialization
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ