Kotlin Serialization — это платформенно независимая библиотека сериализации и десериализации Kotlin. Дерево объектов сериализуется ею в распространенные форматы со встроенной поддержкой Kotlin. Библиотека характеризуется высокой степенью расширяемости, пригодна практически для всех бизнес-сценариев. Ей не нужна рефлексия, и производительность библиотеки превосходна. На данный момент она считается лучшим инструментом сериализации на языке Kotlin.
Форматы, поддерживаемые kotlinx.serialization
:
- JSON;
- буферы протокола;
- краткий двоичный объект;
- свойства;
- HOCON, только в виртуальной машине Java.
Чаще используется 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
.
И на этом пока все. Подробнее — на официальном сайте.
Читайте также:
- Полное руководство по Kotlin для Android-разработчиков в 2024 году
- Практическое применение KSP
- Kotlin изнутри: как работают inline-функции
Читайте нас в Telegram, VK и Дзен
Перевод статьи ZhangKe: Introduction to using Kotlin Serialization