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, записали новый файл, а потом пытаетесь читать его старой версией программы или другой частью проекта. Поэтому относитесь к настройкам как к части формата: если вы их меняете — это изменение договора, а не “рефакторинг без последствий”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ