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), для сериализатора это unknown key. И если вы не включили ignoreUnknownKeys, чтение может упасть. То есть @Transient — про исключение из схемы, а терпимость к мусору/лишним полям — это настройка Json.
Ошибка №6: хранить в сериализуемой модели то, что не должно жить в файле.
Это уже не столько про компиляцию, сколько про здравый смысл. Отладочные заметки, временные флаги, кеши, производные поля — всё это лучше либо вычислять на лету, либо помечать @Transient с безопасным дефолтом. Иначе вы сами себе создадите «исторический архив мусора», который потом придётся поддерживать ради совместимости.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ