Kotlin Serialization  —  это платформенно независимая библиотека сериализации и десериализации Kotlin. Дерево объектов сериализуется ею в распространенные форматы со встроенной поддержкой Kotlin. Библиотека характеризуется высокой степенью расширяемости, пригодна практически для всех бизнес-сценариев. Ей не нужна рефлексия, и производительность библиотеки превосходна. На данный момент она считается лучшим инструментом сериализации на языке Kotlin.

Форматы, поддерживаемые kotlinx.serialization:

Чаще используется JSON, о нем и поговорим.

Сериализация в Kotlin подразделяется на два процесса: первый  —  преобразование дерева объектов в последовательность базовых типов данных, второй  —  ее кодирование и вывод в соответствии с форматом.

Интеграция

Инструмент сериализации Kotlin находится в отдельном компоненте, который состоит из:

  • плагина компиляции Gradle: org.jetbrains.kotlin.plugin.serialization;
  • библиотеки зависимостей времени выполнения.

Сначала добавим плагин компиляции Gradle:

plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.23'
}

Затем библиотеку зависимостей:

dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}

Кроме того, в kotlinx.serialization имеется собственная, не синхронизированная с Kotlin версия. Вот конкретные версии.

Кодирование Json

Процесс преобразования данных в заданный формат называется кодированием. В рамках сериализации Kotlin кодирование осуществляется с помощью функции-расширения Json.encodeToString:

@Serializable
class Project(val name: String, val language: String)

fun main() {
val data = Project("kotlinx.serialization", "Kotlin")
println(Json.encodeToString(data))
}

При сериализации в Kotlin не используется рефлексия, поэтому класс с поддержкой сериализации/десериализации обозначается аннотацией @Serializable.

Декодирование Json

Противоположный процесс называется декодированием. Функцией-расширением Json.decodeFromString строка JSON декодируется в объект, желаемый тип результата указывается в параметре типа этой функции:

@Serializable
data class Project(val name: String, val language: String)

fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":"Kotlin"}
""")
println(data)
}

Аннотация @Serializable

Этой аннотацией обозначается сериализуемый класс. Правила сериализации такие:

  • в процессе сериализации задействуются только свойства с резервными полями, никаких прокси-свойств или свойств с get/set;
  • параметры в главном конструкторе должны быть свойствами объекта;
  • для сценариев, где перед завершением сериализации требуется проверка данных, параметры проверяются в блоке инициализации класса.

Дополнительные свойства

Если у строки Json в классе отсутствует свойство, ее десериализация в объект не выполняется. Чтобы этого избежать, добавляем к свойству значение по умолчанию:

@Serializable
data class Project(val name: String, val language: String = "Kotlin")

fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization"}
""")
println(data)
}

@Required

Декорированное аннотацией @Required свойство должно быть непустым в процессе десериализации.

Эта аннотация применяется, если в поле имеется значение по умолчанию и во входных данных JSON во время десериализации ожидается наличие этого свойства. Если после ее применения свойство в JSON отсутствует, десериализация не выполняется.

@Transient

Этой аннотацией декорируется не подлежащее сериализации свойство, в котором должно иметься значение по умолчанию.

Если в сериализованной строке в свойстве, помеченном как @Transient, содержится свойство с таким же названием, при выполняемой в этот объект десериализации по умолчанию сообщается об ошибке.

Чтобы избежать ошибок, при создании Json задаем ignoreUnknownKeys значение true. Смысл ignoreUnknownKeys  —  в игнорировании неизвестных полей в сериализованной строке. По умолчанию его значение  —  false, поэтому если в десериализованной строке содержатся дополнительные поля, то при десериализации об ошибке не сообщается.

Значения по умолчанию в сериализации не задействуются

Если в свойстве имеется значение по умолчанию, и значение этого свойства в данном объекте тоже значение по умолчанию, то это значение в сериализации не задействуется:

@Serializable
data class User(val firstname: String, val lastname: String = "Zhang")

fun main() {
val data = User("Ke")
println(Json.encodeToString(data))
// вывод: {"firstname":"Ke"}
}

Поскольку свойство language в созданном data  —  значение по умолчанию, lastname в сериализованные данные не включается.

Включается же в сериализацию, если задать для lastname не значение по умолчанию:

fun main() {
val data = User("Ke", "Li")
println(Json.encodeToString(data))
// Вывод: {"firstname":"Ke","lastname":"Li"}
}

Конечно, имеется способ обойти и это.

@EncodeDefault

Этой аннотацией проблема решается: в сериализации задействуется и значение по умолчанию.

Кроме того, параметром EncodeDefault.Mode поведение самой аннотации изменяется на противоположное.

Названия последовательных полей

В Kotlin Serialization поддерживаются настраиваемые сериализация и десериализация названий свойств, реализуемые с помощью аннотации @SerialName.

Перечисление

В Kotlin Serialization поддерживаются классы перечислений. Чтобы после сериализации настроить название свойства, аннотация @Serializable добавляется и задается через @SerialName. Другой необходимости применять ее в этих классах нет.

Уже имеющиеся типы

В Kotlin Serialization имеется встроенная поддержка не только String и базовых типов данных, но и составных: pair, triple, массивов, cписков, множеств, карт, unit, классов Singleton, duration, nothing.

Сериализация/десериализация Unit и класса Singleton одинаковы. Поскольку Unit  —  это тот же класс Singleton, в их сериализации содержится пустая строка Json.

Сериализаторы Serializer

Напомним: сериализацией называется преобразование объекта в базовый тип данных. Процесс же сериализации контролируется сериализатором Serializer.

Типам, для которых в Kotlin Serialization имеется встроенная поддержка, предоставляется соответствующий сериализатор Serializer базовых типов*.*

Сериализатор базовых типов

Получаем такой сериализатор, напрямую используя функцию-расширение:

val intSerializer: KSerializer<Int> = Int.serializer()
println(intSerializer.descriptor)
// вывод: PrimitiveDescriptor(kotlin.Int)

Предупакованные сериализаторы

Предупакованные сериализаторы Kotlin получаем с помощью функции верхнего уровня serializer():

enum class Status { SUPPORTED }

val pairSerializer: KSerializer<Pair<Int, Int>> = serializer()
val statusSerializer: KSerializer<Status> = serializer()
println(pairSerializer.descriptor)
println(statusSerializer.descriptor)
// вывод:
// kotlin.Pair(first: kotlin.Int, second: kotlin.Int)
// com.zhangke.algorithms.Status(SUPPORTED)

Чтобы получить сериализатор заданного типа, функцией serializer() принимается обобщенный параметр. С помощью этой функции мы фактически получаем сериализатор всех сериализуемых классов.

Сериализатор, генерируемый плагином компилятора

Добавив аннотацию @Serializable классу, через функцию-расширение его объекта получаем соответствующий сериализатор, автоматически генерируемый плагином компилятора:

@Serializable
class User(val name: String)

val userSerializer:KSerializer<User> = User.serializer()
println(userSerializer.descriptor)
// вывод: com.zhangke.algorithms.User(name: kotlin.String)

Сериализатор обобщенного класса, генерируемый плагином компилятора

В обобщенных классах с поддержкой сериализации дженерик-типу тоже необходимо поддерживать сериализацию, функция serializer() таких классов должна передаваться в его сериализатор:

@Serializable
class Box<T>(val contents: T)

val userSerializer:KSerializer<Box<User>> = Box.serializer(User.serializer())
println(userSerializer.descriptor)
// вывод: com.zhangke.algorithms.Box(contents: com.zhangke.algorithms.User)

Сериализатор типов коллекций

Так же, как в обобщенных классах, в сериализатор трех типов коллекций  —  ListSerializer(), MapSerializer(), SetSerializer()  —  передаются параметры:

val stringListSerializer: KSerializer<List<String>> = ListSerializer(String.serializer())
println(stringListSerializer.descriptor)

Настраиваемый сериализатор

Обычно аннотаций и предустановленного сериализатора достаточно. Но, чтобы контролировать процесс сериализации или сериализовывать классы без добавления аннотаций, сериализатор настраивают. Для этого создаем класс, которым реализуется интерфейс KSerializer:

class Color(val rgb: Int)

object ColorAsStringSerializer : KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING)

override fun serialize(encoder: Encoder, value: Color) {
val string = value.rgb.toString(16).padStart(6, '0')
encoder.encodeString(string)
}

override fun deserialize(decoder: Decoder): Color {
val string = decoder.decodeString()
return Color(string.toInt(16))
}
}

Переопределенное свойство descriptor  —  дескриптор этого сериализатора. Реализуем SerialDescriptor самостоятельно или создаем базовый тип PrimitiveSerialDescriptor, как в этом коде.

Две другие функции очевидны: сериализация и десериализация.

Делегированный сериализатор

Завершение процесса сериализации/десериализации делегируется одним сериализатором другому. Например, сначала преобразуем Color в целочисленный массив, затем делегируем в IntArraySerializer:

class ColorIntArraySerializer : KSerializer<Color> {
private val delegateSerializer = IntArraySerializer()
override val descriptor = SerialDescriptor("Color", delegateSerializer.descriptor)

override fun serialize(encoder: Encoder, value: Color) {
val data = intArrayOf(
(value.rgb shr 16) and 0xFF,
(value.rgb shr 8) and 0xFF,
value.rgb and 0xFF
)
encoder.encodeSerializableValue(delegateSerializer, data)
}

override fun deserialize(decoder: Decoder): Color {
val array = decoder.decodeSerializableValue(delegateSerializer)
return Color((array[0] shl 16) or (array[1] shl 8) or array[2])
}
}

Сценарии применения сериализаторов

Задается непосредственно в классе

@Serializable(with = ColorAsStringSerializer::class)
class Color(val rgb: Int)

Задается в атрибутах

@Serializable
class Table(
@Serializable(with = ColorAsStringSerializer::class) val color: Color
)

Используется при сериализации

Передаем сериализатор первым параметром в функцию Json.encodeToString:

Json.encodeToString(ColorAsStringSerializer, Color(0xFF0000))

Задается для дженериков

Для типов-дженериков применяется аннотация @Serializable:

@Serializable
class ProgrammingLanguage(
val name: String,
val releaseDates: List<@Serializable(DateAsLongSerializer::class) Date>
)

Указывается для файла

В Kotlin сериализатор задается и для файла:

@file:UseSerializers(DateAsLongSerializer::class)

При такой настройке сериализатор в этом файле применяется классами автоматически.

Контекстная сериализация

Пока процесс сериализации полностью статичен. Но иногда для одного и того же класса приходится динамически выбирать разные сериализаторы.

Например, при сериализации класса Date в разные стандартные строки. Однако это зависит от разных версий интерфейса. Известно лишь, какой стандартный сериализатор следует использовать во время выполнения.

В этом случае не задаем конкретный сериализатор для Date, а добавляем тег @Contextual. Этим свойством определится нужный сериализатор на основе контекста в Json:

@Serializable
class ProgrammingLanguage(
val name: String,
@Contextual
val stableReleaseDate: Date
)

Контекст получается созданием экземпляра SerializersModule, в котором описывается, каким сериализатором во время выполнения сериализуются классы, помеченные как Contextual:

private val module = SerializersModule {
contextual(Date::class, DateAsLongSerializer)
}
val format = Json { serializersModule = module }

Теперь информация контекстного модуля сохраняется в объекте format. Пока этот объект используется, класс Date сериализуется сериализатором DateAsLongSerializer.

После настройки объект format задействуется в сериализации:

fun main() {
val data = ProgrammingLanguage("Kotlin", SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00"))
println(format.encodeToString(data))
}

Сериализация полиморфных классов

Что касается полиморфизма классов с отношениями наследования, здесь при сериализации наблюдаются проблемы, которые в Kotlin Serialization тоже решаются.

Запечатанные классы

Одно из решений  —  использовать запечатанные классы, их сериализация в Kotlin поддерживается. Снова добавляем аннотацию @Serializable:

@Serializable
sealed class Project {
abstract val name: String
}

@Serializable
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
println(Json.encodeToString(data)) // Сериализация данных типа «Project» времени компиляции
}

// вывод: {"type":"com.zhangke.OwnedProject","name":"kotlinx.coroutines","owner":"kotlin"}

Чтобы решить в Kotlin проблему сериализации полиморфных классов, в сериализованном содержимом добавляется поле type для конкретного типа объекта. Во время десериализации на основе этого типа создается соответствующий объект.

Значение поля type настраивается, название пакета по умолчанию заменяется своим:

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

Регистрация подклассов

Другое решение для сериализации полиморфных классов  —  регистрация подклассов.

Для этого создается модуль сериализации в Json с корреляцией между интерфейсом и подклассами, так сериализатору указываются подклассы для сериализации:

@Serializable
abstract class Project {
abstract val name: String
}

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

@Serializable
class JavaProject(override val name: String): Project()

val module = SerializersModule {
polymorphic(
baseClass = Project::class,
actualClass = OwnedProject::class,
actualSerializer = serializer(),
)
polymorphic(
baseClass = Project::class,
actualClass = JavaProject::class,
actualSerializer = serializer(),
)
}

val format = Json { serializersModule = module }

fun main() {
val list = listOf(
OwnedProject("kotlinx.coroutines", "kotlin"),
JavaProject("sun.java")
)
println(format.encodeToString(list))
}
// вывод: [{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"},{"type":"com.zhangke.algorithms.JavaProject","name":"sun.java"}]

Сериализация интерфейса

Регистрация подклассов доступна для сценария абстрактных классов, но интерфейсы остаются несериализуемыми: в них не добавляется аннотация @Serializable. В Kotlin применяется стратегия PolymorphicSerializer, и интерфейсы сериализуются неявно.

Вместо добавления аннотации @Serializable просто регистрируем подклассы, как показано выше:

interface Project {
val name: String
}

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project

@Serializable
class JavaProject(override val name: String) : Project

val module = SerializersModule {
polymorphic(
baseClass = Project::class,
actualClass = OwnedProject::class,
actualSerializer = serializer(),
)
polymorphic(
baseClass = Project::class,
actualClass = JavaProject::class,
actualSerializer = serializer(),
)
}

val format = Json { serializersModule = module }

fun main() {
val list = listOf(
OwnedProject("kotlinx.coroutines", "kotlin"),
JavaProject("sun.java")
)
println(format.encodeToString(list))
}
// вывод: [{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"},{"type":"com.zhangke.algorithms.JavaProject","name":"sun.java"}]

Конфигурация Json

Сериализатор как часть процесса сериализации мы изучили, рассмотрим теперь процесс кодирования/декодирования Json. Оно выполняется с помощью класса Json в kotlinx. Чтобы получить глобально уникальный стандартный объект Json, вызываем Json напрямую либо создаем собственный объект Json.

В классе Json возможно внутреннее кэширование. Поэтому, учитывая потенциальные проблемы производительности, рекомендуется сохранять и переиспользовать созданные объекты Json, не создавать для каждого применения новый.

Форматирование вывода

По умолчанию Json выводится в одну строку. Сделаем вывод удобнее для восприятия, задав true для prettyPrint:

val format = Json { prettyPrint = true }

Теперь выводится аккуратная строка Json:

{
"name": "kotlinx.serialization",
"language": "Kotlin"
}

Нестрогий парсинг

По умолчанию парсинг в Json выполняется по строгим стандартам: обязательные кавычки для ключей, ограничения для целочисленных и строковых типов и т. д. Нестрогий режим активируется параметром isLenient = true.

В этом режиме закавыченные значения парсятся как целые числа, если соответствующий тип в объекте Kotlin  —  целое число, ключи тоже оставляются незакавыченными.

Игнорирование неизвестных ключей

По умолчанию, если в процессе десериализации обнаруживаются неизвестные ключи, в Json возвращается ошибка. Например, если в строке Json имеется поле id, но в десериализуемом целевом классе нет атрибута id, вызывается ошибка.

Чтобы избегать этих ошибок и выполнять обычный парсинг, задаем ignoreUnknownKeys = true.

Замена названий Json

Задав название поля в Json с помощью @SerialName, мы больше не сможем спарсить исходное название. Проблема решается аннотацией @JsonNames, для поля задается несколько названий, при этом исходное продолжает парситься:

@Serializable
data class Project(@JsonNames("title") val name: String)

fun main() {
val project = Json.decodeFromString<Project>("""{"name":"kotlinx.serialization"}""")
println(project)
val oldProject = Json.decodeFromString<Project>("""{"title":"kotlinx.coroutines"}""")
println(oldProject)
}

Поддержка аннотации @JsonNames контролируется флагом JsonBuilder.useAlternativeNames. В отличие от большинства флагов конфигурации, этот включен по умолчанию.

Принудительное использование значений по умолчанию

С помощью coerceInputValues = true недопустимые входные данные преобразуются в значения по умолчанию.

Сейчас поддерживается только два типа таких данных:

  • Пустой ввод для значений, не допускающих значения null.
  • Неизвестные значения для перечислений.

То есть, если в каком-то поле класса имеется значение по умолчанию и соответствующее поле в строке Json удовлетворяет одному из двух этих условий, значение по умолчанию этого свойства задействуется в десериализации:

val format = Json { coerceInputValues = true }

@Serializable
data class Project(val name: String, val language: String = "Kotlin")

fun main() {
val data = format.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":null}
""")
println(data)
}
// вывод: Project(name=kotlinx.serialization, language=Kotlin)

Отображение значений «null»

По умолчанию в Json кодируются и значения null. Задавая explicitNulls = false, мы предотвращаем их сериализацию в Json.

Структурированные ключи Json

Структурированные ключи  —  обычно это строки  —  не поддерживаются самим форматом JSON.

Нестандартная поддержка включается свойством allowStructuredMapKeys = true:

val format = Json { allowStructuredMapKeys = true }

@Serializable
data class Project(val name: String)

fun main() {
val map = mapOf(
Project("kotlinx.serialization") to "Serialization",
Project("kotlinx.coroutines") to "Coroutines"
)
println(format.encodeToString(map))
}

Карты со структурированными ключами  —  это массивы JSON с элементами: [key1, value1, key2, value2,...].

[{"name":"kotlinx.serialization"},"Serialization",{"name":"kotlinx.coroutines"},"Coroutines"]

Другие

В Json доступны дополнительные настройки для самых разных сценариев:

  • allowSpecialFloatingPointValues: специальные значения с плавающей точкой вроде NaN и infinity;
  • classDiscriminator: задание названия ключа типов для полиморфных данных;
  • decodeEnumsCaseInsensitive: декодирование перечислений без учета регистра;
  • namingStrategy: стратегия глобального именования, используя JsonNamingStrategy.SnakeCase, парсим поля из верблюжьего регистра в змеиный.

Элемент Json

Кроме того, что Json  —  инструмент кодирования/декодирования, им самим предоставляются внутренние классы JsonElement и инструменты.

Объект JsonElement парсится с помощью функции Json.parseToJsonElement.

Содержимое класса JsonElement здесь и в Gson, а также большинстве других инструментов Json практически идентично, не будем подробно на них останавливаться.

Постоитель элементов Json

Для создания JsonElement в Json задействуется несколько предметно-ориентированных языков:

fun main() {
val element = buildJsonObject {
put("name", "kotlinx.serialization")
putJsonObject("owner") {
put("name", "kotlin")
}
putJsonArray("forks") {
addJsonObject {
put("votes", 42)
}
addJsonObject {
put("votes", 9000)
}
}
}
println(element)
}

Затем функцией Json.decodeFromJsonElement он непосредственно десериализуется в объект:

@Serializable
data class Project(val name: String, val language: String)

fun main() {
val element = buildJsonObject {
put("name", "kotlinx.serialization")
put("language", "Kotlin")
}
val data = Json.decodeFromJsonElement<Project>(element)
println(data)
}

Преобразования Json

В Json имеется возможность настраивать кодирование/декодирование данных Json, что сказывается на полученном после сериализации содержимом Json.

Возьмем, например, класс User и добавим в сериализованных данных Json поле time, которым обозначается время сериализации:

@Serializable
class User(
val name: String,
val age: Int,
)

object UserSerializer : JsonTransformingSerializer<User>(User.serializer()) {
override fun transformSerialize(element: JsonElement): JsonElement {
return buildJsonObject {
element.jsonObject.forEach { key, value ->
put(key, value)
}
put("time", System.currentTimeMillis())
}
}
}
fun main() {
val user = User("zhangke", 18)
println(format.encodeToString(UserSerializer, user))
}
// вывод: {"name":"zhangke","age":18,"time":1711556475153}

Здесь мы сначала определяем UserSerializer, затем в возвращаемом JsonObject добавляем исходные поля и поле time.

И на этом пока все. Подробнее  —  на официальном сайте.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи ZhangKe: Introduction to using Kotlin Serialization

Предыдущая статьяСоздание кастомизированного кругового загрузчика в Jetpack Compose: изучение Android Canvas и анимации
Следующая статьяРеальные возможности WASM