JavaRush /Курси /Kotlin SELF /Власний серіалізатор

Власний серіалізатор

Kotlin SELF
Рівень 48 , Лекція 3
Відкрита

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», — ви майже напевно змішали задачі. Краще обрати один суворий формат. А якщо потрібно підтримувати кілька, робіть це окремим шаром нормалізації вхідних даних перед серіалізацією.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ