1. Чому імена та поля в JSON — це майже публічний API
Якщо ви лише почали програмувати, легко думати так: «Ну модель же моя, файл мій — завтра зміню поле, і все». Але щойно ви зберігаєте JSON на диск, надсилаєте його мережею або просто тримаєте між запусками програми, формат перетворюється на контракт. І от тут починаються пригоди: ви перейменували властивість у Kotlin заради краси — а старі файли раптом перестали читатися.
Уявіть, що JSON — це «анкета», яку заповнюють попередні версії вашої програми. Якщо ви завтра змінили в анкеті запитання "name" на "fullName", старі анкети не стануть чарівним чином іншими. Потрібен механізм сумісності. Саме для цього й існують @SerialName і @Transient.
Наша практична мета на цю лекцію — навчитися змінювати Kotlin‑модель (читабельність, стиль, рефакторинг), не ламаючи формат. І навпаки: підлаштовувати формат під зовнішні вимоги, не перетворюючи Kotlin‑код на набір дивних snake_case‑імен.
2. @SerialName: одне імʼя в Kotlin, інше в JSON
Анотація @SerialName("...") — це спосіб сказати серіалізатору: «У коді властивість називається ось так, але в JSON я хочу бачити інше імʼя». Звучить майже нудно, але це якраз той випадок, коли нудьга = надійність. Ви відокремлюєте внутрішню читабельність коду від зовнішнього контракту формату.
Важливо розуміти: @SerialName — це не про «зробити гарніше», а про «зробити стійкіше». Стійкий формат має один приємний бонус: ви можете рефакторити Kotlin‑код і не боятися, що користувачі (або ви самі за місяць) відкриють програму й побачать «не можу прочитати файл, усе пропало».
Міні‑схема того, що відбувається:
flowchart LR
A[Kotlin-властивість: note] -->|"@SerialName('title')"| B[JSON-поле: 'title']
C["JSON-поле: 'title'"] -->|decode| D[Kotlin-властивість: note]
Тобто @SerialName — це двостороння домовленість: так поле називається і під час запису, і під час читання.
Практика: перейменували поле й не зламали старі JSON
Уявімо, що ми розвиваємо наш консольний застосунок для обліку витрат BudgetBuddy. Учора в нас уже була модель, яку ми серіалізуємо та зберігаємо в JSON.
Нехай раніше витрата виглядала так:
import kotlinx.serialization.Serializable
@Serializable
data class ExpenseV1(
val title: String,
val amount: Int
)
У певний момент ви вирішуєте, що слово title не відображає зміст (це радше «нотатка/опис»). Тож перейменовуєте поле на note:
import kotlinx.serialization.Serializable
@Serializable
data class ExpenseV2(
val note: String,
val amount: Int
)
Тепер уявімо старий JSON (він уже лежить на диску):
val oldJson = """{"title":"Coffee","amount":250}"""
Якщо ви спробуєте прочитати його як ExpenseV2, серіалізатор чесно скаже: «Я не знаю, що таке "title", і не бачу обовʼязкового note». Зазвичай це закінчується винятком.
Виправлення якраз робиться через @SerialName:
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ExpenseV2(
@SerialName("title") val note: String,
val amount: Int
)
Тепер старий JSON і далі читатиметься, але в Kotlin‑коді у вас буде нормальне імʼя note.
Міні‑перевірка: прочитали старе й записали нове у старому форматі
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class Expense(
@SerialName("title") val note: String,
val amount: Int
)
fun main() {
val json = Json { prettyPrint = true }
val e = json.decodeFromString<Expense>("""{"title":"Coffee","amount":250}""")
println(e) // Expense(note=Coffee, amount=250)
println(json.encodeToString(e)) // {"title":"Coffee","amount":250}
}
Зверніть увагу на останній вивід: ми і читаємо, і пишемо поле як "title", тому що це наш контракт формату. Водночас у коді ми живемо з note.
@SerialName для сумісності із зовнішнім JSON
Перейменування заради рефакторингу — не єдина причина використовувати @SerialName. Друга поширена причина — вам приходить JSON із зовнішнього світу, де прийнято snake_case, а в Kotlin — camelCase.
Припустімо, зовнішній формат вимагає:
{
"user_id": 7,
"full_name": "Ada Lovelace"
}
А в Kotlin ми хочемо нормальну модель:
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserDto(
@SerialName("user_id") val userId: Int,
@SerialName("full_name") val fullName: String
)
І це рівно той момент, коли @SerialName робить код читабельним і сумісним одночасно. Без нього ви або тримаєте властивості user_id (що погано для Kotlin‑стилю), або пишете ручний розбір JSON (що зарано й зазвичай боляче).
Маленька таблиця: що коли обирати
| Ситуація | Чого хочеться в Kotlin | Що потрібно в JSON | Рішення |
|---|---|---|---|
| Рефакторинг: перейменували title → note | note | старе імʼя "title" | @SerialName("title") |
| Зовнішній формат у snake_case | createdAt, userId | "created_at", "user_id" | @SerialName("created_at"), @SerialName("user_id") |
| Ви придумали формат самі, і він ніде не «живе» | будь‑які | будь‑які | можна без @SerialName, але краще заздалегідь домовитися про стиль |
3. @Transient: поле потрібне обʼєкту, але не потрібне формату
Якщо @SerialName керує іменем у форматі, то @Transient керує самим фактом присутності властивості у форматі. Тобто властивість існує в Kotlin‑обʼєкті, але не серіалізується й не десеріалізується.
Тут дуже важливий сенс: @Transient — це про межу між «даними» та «зручностями». Наприклад, ви хочете зберігати всередині обʼєкта кеш, налагоджувальну позначку, тимчасове значення для UI або будь‑яке поле, яке не повинно «витікати» у файл. У такому разі @Transient — ваш запобіжник.
Ще одна важлива деталь: для властивості з @Transient майже завжди потрібне значення за замовчуванням. Інакше під час читання JSON обʼєкт буде неможливо створити, адже серіалізатор не братиме значення з JSON. У такому разі коректніше або задати значення за замовчуванням, або переглянути дизайн.
До речі, в екосистемі Kotlin існує кілька варіантів Transient, і можна випадково імпортувати не той. Компілятор та інструменти часто підсвічують такі ситуації як поширену проблему.
Приклад: налагоджувальне поле, яке не повинно потрапляти в збереження
Повернімося до BudgetBuddy. Припустімо, у якийсь момент ви хочете додати у витрату поле debugNote, щоб розуміти, звідки вона взялася: введена вручну, імпортована, відновлена з файла. Це корисно для налагодження, але це точно не те, що повинно жити в JSON‑сховищі вічно.
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class Expense(
val note: String,
val amount: Int,
@Transient val debugNote: String = ""
)
Тепер, навіть якщо ви створите обʼєкт із debugNote, у JSON він не потрапить.
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class Expense(
val note: String,
val amount: Int,
@Transient val debugNote: String = ""
)
fun main() {
val json = Json { prettyPrint = true }
val e = Expense(note = "Coffee", amount = 250, debugNote = "typed by user")
println(json.encodeToString(e))
// {
// "note": "Coffee",
// "amount": 250
// }
}
І так, якщо ви зараз подумали «а можна просто зробити поле private?» — можна, але це інша історія. private керує доступом із коду, а @Transient керує контрактом формату. Це дві різні площини.
Читання JSON: чому потрібен дефолт і де можна впасти
Коли ви читаєте JSON, серіалізатор створює обʼєкт і має заповнити всі властивості, які входять у серіалізацію. Але @Transient до неї не входить, отже значення береться лише зі значення за замовчуванням.
Якщо значення за замовчуванням немає, обʼєкт «не складається». У таких ситуаціях правильніше падати швидко й зрозуміло. У Kotlin це часто робиться через require, check, error — стандартні функції передумов, які викидають винятки за порушення умов.
Міні‑приклад: поганий transient без дефолту
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class Session(
val token: String,
@Transient val cachedLabel: String // немає дефолту — проблема
)
З високою ймовірністю ви отримаєте помилку під час спроби decodeFromString<Session>(...).
Ще одна тонкість: що як поле є в JSON
Уявімо, що в старому JSON чомусь було поле "debugNote", а тепер ви зробили його @Transient. Для серіалізатора це поле стає «невідомим». Якщо у вас Json налаштований як Json { ignoreUnknownKeys = false } (за замовчуванням так і є), читання може впасти через зайве поле.
Тобто @Transient — це не «магічно ігноруй поле», а саме «це поле не частина схеми». Ігнорування невідомих полів — окреме налаштування Json, із яким ми вже знайомі з попередніх лекцій.
Охайний стиль: розділяємо зберігання та зручність
Іноді студентам хочеться скласти в одну @Serializable модель узагалі все: і те, що треба зберігати, і те, що зручно для обчислень, і кеш, і якісь тимчасові прапорці. Це працює… до першої серйозної зміни.
Спокійніший стиль — памʼятати, що серіалізовувана модель схожа на «контракт даних», і вона має бути доволі нудною: прості поля, зрозумілі імена, мінімум сюрпризів. А «зручні штуки» або робляться transient‑полями зі значенням за замовчуванням, або обчислюються окремими функціями, або взагалі живуть окремо.
Поки ми не йдемо у велику архітектуру (це буде окрема велика розмова пізніше), нам достатньо простого правила: якщо поле не зобовʼязане переживати збереження/завантаження, то або робіть його @Transient, або не робіть його частиною @Serializable‑моделі.
4. Типові помилки
Помилка № 1: перейменували властивість у Kotlin і забули, що JSON‑файли вже існують.
Найприкріша помилка — та, що проявляється не одразу. Ви змінили title на note, усе прекрасно компілюється, нові файли зберігаються, але старі більше не читаються. Лікується це дисципліною: якщо формат уже «у світі», стабілізуйте імʼя через @SerialName, а Kotlin‑імʼя змінюйте як хочете.
Помилка № 2: використовувати @SerialName як «спосіб зробити гарно», а не як контракт.
Коли @SerialName ставлять «про всяк випадок» на кожне поле, формат перетворюється на набір випадкових рядків, а міграції стають складнішими. Сенс анотації в тому, щоб закріплювати важливі місця: сумісність зі старими файлами, відповідність зовнішньому API, єдиний стиль іменування (наприклад, snake_case).
Помилка № 3: поставити @Transient, але не дати значення за замовчуванням.
@Transient означає, що поле не читатиметься з JSON. Отже, під час decode воно має звідкись узятися — майже завжди зі значення за замовчуванням. Якщо значення за замовчуванням немає, обʼєкт не вийде створити. І це не «прискіпування», а логіка конструктора: без значення поле не ініціалізоване.
Помилка № 4: імпортувати «не той Transient».
У Kotlin/JVM можна зустріти інші Transient‑анотації, і новачки інколи імпортують їх автоматично через IDE. У підсумку поле не виключається із серіалізації або поводиться не так, як очікувалося. Тому корисно очима перевіряти імпорт: вам потрібен саме kotlinx.serialization.Transient. На практиці це настільки часта історія, що навіть інструменти та інспекції окремо на неї сваряться.
Помилка № 5: вважати, що @Transient автоматично «проковтне» поле з вхідного JSON.
Якщо поле є в JSON, а в моделі його немає (або воно @Transient), для серіалізатора це невідомий ключ. І якщо ви не увімкнули ignoreUnknownKeys, читання може впасти. Тобто @Transient — про вилучення зі схеми, а терпимість до сміття/зайвих полів — це налаштування Json.
Помилка № 6: зберігати в серіалізовуваній моделі те, чому не місце у файлі.
Це вже не стільки про компіляцію, скільки про здоровий глузд. Налагоджувальні нотатки, тимчасові прапорці, кеші, похідні поля — усе це краще або обчислювати «на льоту», або позначати @Transient із безпечним значенням за замовчуванням. Інакше ви самі собі створите «історичний архів сміття», який потім доведеться підтримувати заради сумісності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ