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» — вы почти наверняка смешали задачи. Лучше выбрать один строгий формат, а если нужно поддерживать несколько — делать это отдельным слоем нормализации входных данных до сериализации.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ