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
Опитування
JSON + kotlinx.serialization, рівень 47, лекція 4
Недоступний
JSON + kotlinx.serialization
JSON + kotlinx.serialization
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ