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("Bad money format: '$text'")
    }
    val majorText = text.substring(0, dot)
    val minorText = text.substring(dot + 1)
    if (minorText.length != 2) {
        throw SerializationException("Bad money cents: '$text'")
    }
    val major = majorText.toLongOrNull()
        ?: throw SerializationException("Bad money major: '$text'")
    val minor = minorText.toLongOrNull()
        ?: throw SerializationException("Bad money minor: '$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 = "Coffee", amount = Money(299))

    println(json.encodeToString(e))
    // {
    //     "title": "Coffee",
    //     "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":"Coffee","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» — вы почти наверняка смешали задачи. Лучше выбрать один строгий формат, а если нужно поддерживать несколько — делать это отдельным слоем нормализации входных данных до сериализации.

1
Задача
Kotlin SELF, 48 уровень, 3 лекция
Недоступна
Центы на экран
Центы на экран
1
Задача
Kotlin SELF, 48 уровень, 3 лекция
Недоступна
Безопасный парсер
Безопасный парсер
1
Задача
Kotlin SELF, 48 уровень, 3 лекция
Недоступна
Парсер с ошибкой
Парсер с ошибкой
1
Задача
Kotlin SELF, 48 уровень, 3 лекция
Недоступна
Money строкой
Money строкой
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ