1. Навіщо потрібен власний серіалізатор
Якщо раніше вам здавалося, що серіалізація — це «натиснув кнопку — отримав JSON», то сьогодні зробімо крок до більш дорослого розуміння: JSON — це зовнішній договір (контракт). Іноді цей договір вимагає формату, який незручно (або небезпечно) напряму виражати через поля data class. Саме тут і з’являється власний серіалізатор — інструмент, який пояснює бібліотеці, як саме ваш тип перетворюється на JSON і назад.
Уявіть просту життєву ситуацію: ви пишете консольний застосунок (наш навчальний мініпроєкт — умовний ExpenseTracker), у якому зберігаєте витрати. Усередині коду вам хочеться тримати суму як ціле число в копійках/центах, щоб не страждати від Double. А в JSON хочеться бачити суму як рядок "12.34", бо так читабельніше й це справді схоже на гроші, а не на «внутрішню математику».
Якщо залишити все «за замовчуванням», ви отримаєте щось на кшталт:
{ "cents": 1234 }
А вам потрібно:
"12.34"
І це якраз той випадок, коли стандартна серіалізація «об’єкт → об’єкт» не підходить — потрібен власний серіалізатор.
Невелика таблиця, щоб зафіксувати різницю (формат — це частина контракту):
| Питання | Стандартна серіалізація | Власний серіалізатор |
|---|---|---|
| Хто визначає формат? | Бібліотека — за структурою класу | Ви — вручну |
| Чи можна зберігати тип як рядок/число замість об’єкта? | Зазвичай ні (якщо тип — клас) | Так |
| Ризик зламати зворотне читання | Середній (у разі змін полів) | Високий, якщо не зберігати симетрію encode/decode |
| Коли застосовувати | «Звичайні» моделі | Коли формат має бути особливим |
2. Мінікейс: гроші як рядок у JSON, але ціле число всередині
Плавно прив’яжімо теорію до практики. Продовжімо мислити в стилі проєкту: є витрата, у неї є опис і сума. У коді суму дуже хочеться зберігати в мінімально «крихкому» вигляді — наприклад, у копійках/центах (Long). Не тому, що програмісти люблять страждати, а тому що Double любить сюрпризи: сьогодні у вас 0.1 + 0.2, а завтра раптом 0.30000000000000004. І бухгалтерія дивиться на вас як на людину, яка «оптимізувала» реальність.
Створімо тип Money і модель витрати:
import kotlinx.serialization.Serializable
@Serializable
data class Money(val cents: Long)
@Serializable
data class Expense(
val title: String,
val amount: Money,
)
Тепер питання: що буде в JSON? За замовчуванням Money — це об’єкт, і сума потрапить як { "cents": 1234 }. Для машин це нормально, а для людей (і часом для інтеграцій) — так собі. Ми хочемо компактніше: нехай Money зберігається як рядок "12.34".
Так, це той самий момент, коли програміст із серйозним обличчям каже: «Я не ускладнюю. Я роблю контракт стабільним». І це правда.
Як під’єднати власну серіалізацію: @Serializable(with = ...)
Перш ніж писати серіалізатор, важливо зрозуміти, як узагалі kotlinx.serialization «дізнається», що ви хочете зробити все нестандартно. Найпростіший спосіб для новачків — указати серіалізатор прямо в анотації @Serializable(with = ...). Це схоже на фразу: «Для цього типу не треба вгадувати — ось інструкція, як його читати й записувати».
Виглядає це так:
import kotlinx.serialization.Serializable
@Serializable(with = MoneyAsStringSerializer::class)
data class Money(val cents: Long)
Тут поки що є загадкова назва MoneyAsStringSerializer. За кілька хвилин ми її напишемо.
Важливо вловити ідею: тип Money лишається звичайним Kotlin-типом — зі своїми перевірками, методами й зручністю. Але JSON-форма більше не зобов’язана повторювати структуру полів. Це і є «відокремлення внутрішнього подання від зовнішнього формату».
3. Інтерфейс KSerializer<T>: descriptor, serialize і deserialize
Зараз буде момент, коли може здатися, ніби ми ліземо в «нутрощі». Не лякайтеся: свій JSON-парсер ми будувати не будемо (слава бібліотекам). Ми лише пояснимо kotlinx.serialization, як подати значення. Для цього і використовується інтерфейс KSerializer<T>.
У серіалізатора є три ключові частини:
flowchart TD
A["Kotlin-обʼєкт Money"] -->|serialize| B["Encoder"]
B --> C["JSON-значення (рядок)"]
C -->|deserialize| D["Decoder"]
D --> E["Kotlin-обʼєкт Money"]
1) descriptor — опис того, яким видом даних серіалізується тип (рядок, число, об’єкт тощо).
2) serialize(encoder, value) — як записати значення в JSON.
3) deserialize(decoder) — як прочитати JSON і відновити Kotlin-об’єкт.
І ще один практичний момент: deserialize має бути суворим і чесним. Якщо рядок прийшов пошкодженим, краще викинути зрозумілу помилку, ніж мовчки «підправити», а потім годину шукати, чому суми раптом стали нульовими. Це і є fail-fast підхід, який ви вже бачили раніше через require/check і винятки.
4. Пишемо серіалізатор: Money як рядок "12.34"
Зробімо це поступово, невеликими кроками (щоб не перетворити лекцію на полотно на 200 рядків, яке ніхто не прочитає). Почнімо з каркаса та descriptor.
Каркас і descriptor: кажемо «я — рядок»
Серіалізатор зручно робити як object, бо він не зберігає стану (це просто набір правил):
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
object MoneyAsStringSerializer : KSerializer<Money> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Money", PrimitiveKind.STRING)
}
Ключова ідея: PrimitiveKind.STRING означає «у JSON я буду збережений як рядок». Не об’єкт, не масив і не число — саме рядок.
serialize: із cents робимо рядок
Тепер навчімо серіалізатор записувати значення. Нам потрібно перетворити 1234 на "12.34". Тут корисні цілочисельне ділення й остача, а ще padStart, щоб копійки завжди були двома цифрами.
import kotlinx.serialization.encoding.Encoder
override fun serialize(encoder: Encoder, value: Money) {
val major = value.cents / 100
val minor = (value.cents % 100).toString().padStart(2, '0')
encoder.encodeString("$major.$minor")
}
Зверніть увагу: ми взагалі не використовуємо Double. Це свідоме рішення: гроші краще тримати в цілих одиницях мінімальної валюти. І так, це той випадок, коли «я не параноїк — я просто вже бачив 0.30000000000000004».
deserialize: із рядка робимо Money(cents = ...)
Тепер найважливіше — зворотне перетворення. Тут і з’являється поняття «безпечний розбір». Нам потрібно:
1) прибрати пробіли,
2) знайти крапку,
3) переконатися, що до крапки й після крапки є цифри,
4) переконатися, що після крапки рівно 2 цифри (якщо ми обрали такий контракт),
5) розібрати числа через toLongOrNull(),
6) зібрати cents.
Почнімо з основи:
import kotlinx.serialization.encoding.Decoder
override fun deserialize(decoder: Decoder): Money {
val s = decoder.decodeString().trim()
return parseMoneyOrThrow(s)
}
Винесімо розбір в окрему функцію, щоб код було легше читати.
5. Безпечний розбір рядка: чому split — пастка
Зараз буде важлива розмова. У коді новачків часто трапляється таке: val parts = s.split("."). І ось тут починається комедія: спочатку смішно, а потім ви плачете в try/catch.
Проблема №1: у деяких мовах "." — це регулярний вираз «будь-який символ». У Kotlin є перевантаження split, і дуже легко написати код, який працює не так, як ви очікуєте.
Проблема №2: навіть якщо розбиття спрацювало, ви не перевірили, що частин дві; що друга частина має довжину 2; що там справді цифри; що рядок не "12.", не ".34", не "12.3", не "12.345", не "12,34" тощо.
Тому ми робимо розбір максимально прямолінійно: шукаємо крапку через indexOf('.'). Це нудно, але нудно — добре. Нудний код зазвичай не будить вас о 3 ночі.
Ось функція розбору:
import kotlinx.serialization.SerializationException
fun parseMoneyOrThrow(text: String): Money {
val dot = text.indexOf('.')
if (dot <= 0 || dot == text.lastIndex) {
throw SerializationException("Поганий формат грошей: '$text'")
}
val majorText = text.substring(0, dot)
val minorText = text.substring(dot + 1)
if (minorText.length != 2) {
throw SerializationException("Погані центи у грошах: '$text'")
}
val major = majorText.toLongOrNull()
?: throw SerializationException("Погана ціла частина грошей: '$text'")
val minor = minorText.toLongOrNull()
?: throw SerializationException("Погана дробова частина грошей: '$text'")
return Money(major * 100 + minor)
}
Так, тут більше перевірок, ніж рядків коду про «щасливий шлях». Але це нормально: розбір введення — це завжди «перевірки, перевірки й ще раз перевірки». Ви вже бачили такий стиль під час обробки користувацького введення (toIntOrNull(), валідації, require/check).
Ще один нюанс: ми обрали контракт строго 2 знаки після крапки. Це зручно й однозначно. Якщо ви хочете підтримати "12.3" як "12.30", так теж можна зробити. Але тоді контракт стане менш суворим, і вам потрібно буде явно прописати правило нормалізації.
6. Збираємо все разом: Money, серіалізатор і JSON витрат
Тепер зберімо цілісний приклад — як це виглядає для нашого ExpenseTracker. Ми створимо витрату, серіалізуємо її й одразу подивимося на JSON.
Модель і під’єднання серіалізатора
import kotlinx.serialization.Serializable
@Serializable(with = MoneyAsStringSerializer::class)
data class Money(val cents: Long)
@Serializable
data class Expense(val title: String, val amount: Money)
Кодування в JSON
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val json = Json { prettyPrint = true }
val e = Expense(title = "Кава", amount = Money(299))
println(json.encodeToString(e))
// {
// "title": "Кава",
// "amount": "2.99"
// }
}
Ви помітили головне: amount став рядком "2.99". І тепер файл, який ви зберігаєте на диску (або кудись передаєте), виглядає як документ для людей, а не як внутрішня бухгалтерія в копійках.
Декодування назад
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val json = Json { ignoreUnknownKeys = true }
val s = """{"title":"Кава","amount":"2.99"}"""
val e = json.decodeFromString<Expense>(s)
println(e.amount.cents) // 299
}
Зверніть увагу: усередині ми знову отримали cents = 299. Тобто ви можете безпечно рахувати суми, додавати їх, порівнювати — і не торкатися Double.
Симетрія формату: що записали — те мають уміти прочитати
Є одне правило, яке варто повторювати як мантру, коли ви пишете власний серіалізатор: decode(encode(x)) == x. Якщо це не виконується, ви майже гарантовано рано чи пізно спіймаєте «привида бага».
Наприклад, якщо в serialize ви завжди пишете 2 знаки після крапки, а в deserialize приймаєте будь-яку кількість знаків і округлюєте, у вас з’явиться несиметричність. Користувач збереже "2.999", ви прочитаєте це як 299 (або як 300), а потім збережете назад як "2.99" (або як "3.00"). У найкращому разі дані «поїдуть», у найгіршому — ви отримаєте скаргу «програма вкрала мої гроші».
Тому в цій лекції ми спеціально зробили суворий контракт: або рядок формату NNN.NN, або помилка. Помилка неприємна, але чесна й діагностована. А «тихо якось виправили» — це як заклеїти лампочку check engine ізоляційною стрічкою: тривожний сигнал зник, а проблема в двигуні — ні.
7. Типові помилки
Помилка №1: серіалізація й десеріалізація несиметричні.
Дуже поширена пастка: ви гарно серіалізуєте "12.30", але під час читання дозволяєте "12.3" і перетворюєте це на 123 (або на 120), а потім під час запису знову отримуєте "12.30". Ніби «і так нормально», але на реальних даних це перетворюється на дрейф формату й неочікувані зміни під час повторного збереження.
Помилка №2: розбір рядка написано «на авось», без перевірок.
Коли в deserialize стоїть щось на кшталт val parts = s.split('.') і одразу parts[0].toInt(), програма починає жити у світі, де всі користувачі ідеальні. А потім прилітає рядок " 12.34 " або "12,34" або "12.", і ви ловите виняток не там, де можете показати зрозуміле повідомлення, а там, де «просто все впало».
Помилка №3: використання Double усередині серіалізатора «для зручності».
Іноді хочеться зробити так: val d = s.toDouble(); val cents = (d * 100).toLong(). Це зручно рівно до першої проблеми з бінарною точністю. Якщо тип Money задуманий як точний (через Long cents), то й розбір має бути точним — через цілі числа та контроль кількості знаків.
Помилка №4: кидається занадто «загальний» виняток без контексту.
Якщо ви кидаєте error("bad"), то під час налагодження ви самі собі ворог. Краще кидати SerializationException із текстом, який містить вихідний рядок. Винятки — це інструмент, і їх корисно робити інформативними, а не загадковими.
Помилка №5: серіалізатор починає «лагодити бізнес-дані».
Серіалізатор має відповідати за формат, а не за зміст. Якщо гроші у вас не можуть бути від’ємними — це бізнес-правило. Його можна перевіряти в моделі (наприклад, init { require(cents >= 0) }) або на рівні валідації даних. Але не варто перетворювати серіалізатор на «таємного бухгалтера», який раптово робить abs() і мовчить.
Помилка №6: занадто великий власний серіалізатор замість невеликого й зрозумілого.
Якщо серіалізатор розростається до монстра, який уміє 12 форматів, розуміє коми, пробіли, валюти, суфікси й «як в Excel», — ви майже напевно змішали задачі. Краще обрати один суворий формат. А якщо потрібно підтримувати кілька, робіть це окремим шаром нормалізації вхідних даних перед серіалізацією.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ