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, записали новий файл, а потім намагаєтеся читати його старою версією програми або іншою частиною проєкту. Тому ставтеся до налаштувань як до частини формату: якщо ви їх змінюєте — це зміна договору, а не «рефакторинг без наслідків».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ