Привет, Хабр! Я Андрей Мещеряков, Android-разработчик в команде роста Тинькофф Инвестиций. Мы в Инвесте всегда стараемся пробовать новое и поддерживать современный стек технологий. Не обошли стороной и библиотеку Kotlinx.Serialization, которой сейчас пользуемся. Меня удивило малое количество русскоязычных публикаций по Kotlinx.Serialization, и я решил поделиться опытом Инвеста по миграции с Gson на Kotlinx.

Расскажу о подводных камнях, неочевидных моментах ее использования и проведу сравнение работы этих библиотек.

Что такое Kotlinx.Serialization и чем она хороша

Kotlinx Serialization — мультиплатформенная мультиформатная библиотека сериализации, написанная JetBrains специально под Kotlin. Она состоит из плагина компилятора, core-библиотеки и набора вспомогательных библиотек, поддерживающих различные протоколы сериализации данных.

Поддержка протоколов в Kotlinx Serialization возможна благодаря разделению процесса сериализации на два этапа — преобразование объекта в последовательность примитивов и кодирование полученной последовательности согласно указанному формату. После того как реализовали логику по репрезентации объекта через примитивные типы, к ним уже можно применять разные форматы построения конечного результата (инстансы для работы с наиболее популярными форматами данных поставляются вместе с библиотекой).

Процесс сериализации объектов в Kotlinx.Serialization
Процесс сериализации объектов в Kotlinx.Serialization

Kotlinx.Serialization работает на основе кодогенерации. Во время компиляции для каждого класса, помеченного аннотацией @Serializable, генерируется сериализатор, который помещается в companion object класса.

Плагин компилятора смотрит на классы, аннотированные @Serializable, и говорит IDE о существовании/несуществовании сериализаторов для классов еще до их генерации. Это помогает избежать ошибок с добавлением несериализуемых полей в сериализуемые классы.

В Инвесте для сериализации используется формат Json, поэтому дальше будем говорить только о нем.

До внедрения Kotlinx.Serialization мы по большей части использовали библиотеку Gson, основной недостаток которой — использование рефлексии. Gson при создании объектов использует UnsafeAllocator, выделяя память под объект и заполняя его поля значениями null. Затем поля перезаписываются исходя из входных данных, неполученные данные просто игнорируются. А потом в рантайме происходят NullPointerException.

Интересный факт: хоть Kotlinx.Serialization и не основана на рефлексии, она все же использует ее для доступа к объектам-компаньонам и к сгенерированным сериализаторам.

Главное преимущество Kotlinx.Serialization — полная совместимость с Kotlin. Это подразумевает использование системы типов Kotlin, поддержку non-nullable типов и дефолтных значений полей. В отличие от Gson, Kotlinx.Serialization создает объекты класса при десериализации путем вызова конструктора, что исключает ошибки при использовании полученных объектов. Эти фичи можно реализовать и с помощью Gson, но придется писать кастомные TypeAdapter, что усложнит процесс разработки и в целом ухудшит изящность кода.

Можно выделить более обширную систему исключений Kotlinx.Serialization, чем в Gson с одним JsonParseException. Она позволяет детально разобраться в причинах возникших ошибок. К тому же в версии 1.4.0 часть иерархии исключений стала публичной.

В большинстве случаев Kotlinx.Serialization простая в использовании и удобная для разработчиков. Но есть кейсы, где не обойтись без дополнительной логики. Например, для библиотечных классов придется писать собственные сериализаторы.

Переход на Kotlinx.Serialization и кейсы миграции

Первые шаги по миграции мы сделали в середине 2020 года. Сначала Kotlinx.Serialization завели в нашем экспериментальном приложении и опробовали нестандартные случаи применения — сериализацию enum- и sealed-классов, дженериков, библиотечных классов. Потом начали переход в основном приложении, добавили все необходимое для работы:

  • Провайд объекта Kotlinx Json, который отвечает за кодирование полученной сериализаторами последовательности примитивов в формат Json и содержит принятую на проекте конфигурацию.

  • Собственный сериализатор enum с обработкой дефолтных значений.

  • Основные сериализаторы для часто используемых библиотечных классов.

  • Обертку над gradle-плагином, чтобы упростить подключение библиотеки в модули и отключить предупреждения компилятора от @RequiresOptIn(ExperimentalSerializationApi).

  • Правила R8/ProGuard (поставляются с библиотекой начиная с версии 1.5.0).

  • Lint-правила для сериализуемых классов (у нас большой штат разработчиков, эти правила облегчают контроль за соблюдением подходов, принятых на проекте).

При написании новых фич начали использовать Kotlinx.Serialization и попутно переводить на нее старые. Работу с Firebase Remote Config тоже перевели на Kotlinx.Serialization. Сначала код писали вручную, а после добавления поддержки Kotlinx.Serialization в OpenApi Generator в новых фичах большую часть кода стали генерировать автоматически, исправляя только мелочи в рамках принятых на проекте соглашений.

Кейсы миграции

Kotlin nullable-типы. Для стандартной конфигурации Kotlinx Json характерно поведение, когда опциональность property с точки зрения языка отличается от опциональности с точки зрения десериализатора. Поэтому для nullable properties необходимо добавлять null как default value. Без default value присвоение значения null возможно только при конкретном указании в пришедшем json:{ "name": null }

Отсутствие поля name в ответе приведет к JsonDecodingException. Эту проблему решает флаг конфигурации explicitNulls = false (в стандартной конфигурации его значение true) при создании инстанса Json. Флаг позволяет игнорировать отсутствие полей в ответе для nullable properties.

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

Обработка ошибок при десериализации enum. Проект не стоит на месте — добавляются новые фичи, но старые версии приложения также нужно поддерживать. Мы не можем быть уверены, что enum всегда будут содержать тот же набор значений, что и с самого начала.

Например: 

enum class Bonus {
	A,
	B,
	UNKNOWN;
}

class TestInstance(
	val bonus: Bonus
)

Если от бэкенда придет другое значение, попытка десериализации TestInstance приведет к JsonDecodingException. Решить эту проблему можно, указав при создании инстанса Json флаг конфигурации coerceInputValues = true, который отвечает за поддержку обработки некорректных значений, и добавив дефолтное значение к enum-свойству:

class TestInstance(
	val bonus: Bonus = Bonus.UNKNOWN
)

Такие ситуации обрабатываются стандартными средствами библиотеки, но, когда возникает необходимость получить список enum, Kotlinx.Serialization не имеет встроенных механизмов объявления дефолтного значения:

class TestInstance(
	val bonuses: List<Bonus>
) 

Новое, неизвестное значение Bonus приведет к ошибкам десериализации, и мы не сможем получить TestInstance вообще. Гибкость Kotlinx.Serialization позволяет для любого класса реализовывать собственную логику сериализации, если не хватает стандартных средств библиотеки. Для этого надо предоставить для нужного класса реализацию интерфейса KSerializer:

@Serializable(with = BonusEnumSerializer::class)
enum class Bonus {
	A,
	B,
	UNKNOWN;
}

class BonusEnumSerializer(
    private val fallback: Bonus? = null
) : KSerializer<Bonus> {

    val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor(
            Bonus::class.java.canonicalName,
            PrimitiveKind.STRING
        )

    fun serialize(encoder: Encoder, value: Bonus) {
      encoder.encodeString(value.name)
    }
 
	fun deserialize(decoder: Decoder): Bonus {
        return decoder.decodeJsonElement().jsonPrimitive.content
        	.let { toEnum(it)}
	}
 
	fun toEnum(value: String): Bonus {
	    return Bonus::class.java.enumConstants
        	?.firstOrNull { it.name == value }
    	    ?: fallback
    }
}

Полный код сериализатора

Сериализатор состоит из трех частей: функций serialize() и deserialize() и свойства descriptor. Функции отвечают за преобразование объектов в поток примитивов для кодирования энкодером по необходимому формату данных и за обратный процесс получения объекта из потока примитивов. Descriptor описывает структуру данных в сериализуемом классе, который используется на этапе кодирования для корректной репрезентации объекта.

Десериализация полиморфных структур данных. Иногда объекты разных типов не только различаются значением какого-то свойства, но и содержат разные данные. Такие объекты можно представить в виде полиморфных иерархий с базовым классом-родителем.

Когда тип получаемого объекта заранее неизвестен и определяется в рантайме среди наследников базового класса, для десериализации используется PolymorphicSerializer. Самый простой вариант для создания такой структуры — sealed-классы.

Стандартная имплементация полиморфного сериализатора ищет во входных данных поле type и использует его для определения типа конечного объекта, нужно просто во всех наследниках указать аннотацией @SerialName, какие значения в поле type к каким классам относить.

Можно изменить classDiscriminator, чтобы он использовал для определения типа другое поле, просто переписав значение classDiscriminator при создании инстанса Json. Но это повлияет на все полиморфные структуры, использующие этот инстанс.

Начиная с версии 1.3.0 можно добавить полиморфной структуре аннотацию @JsonClassDiscriminator, которая аналогична полю classDiscriminator и распространяется только на конкретную структуру.

Если добавить property в класс, аналогичное объявленному в classDiscriminator, будет конфликт при десериализации. Но бывают ситуации, когда поле нужно для какой-то бизнес-логики. Чтобы разрешить конфликт, можно пометить property аннотацией @Transient, которая скажет об игнорировании соответствующего значения во входных данных, и вручную для каждого подтипа прописать необходимое значение свойства.

interface BaseInstance<T>(
	val bonus: Bonus
            val data: T?
)
 
@SerialName(value = "a")
class TypeAInstance(
	@Transient
	val bonus: Bonus = Bonus.A
            val data: TypeAData?
)
 
@SerialName(value = "unknown")
class UnknownInstance(
	@Transient
	val bonus: Bonus = Bonus.UNKNOWN
    @Transient
    val data: UnknownData? = null
)

При использовании абстрактных классов и интерфейсов вместо sealed-классов сериализатор не может автоматически знать обо всех наследниках, поэтому при создании инстанса Json их необходимо указать явно:

serializersModule = serializersModule.overwriteWith(

    SerializersModule {
        polymorphic(BaseInstance::class) {
       	subclass(TypeAInstance::class)
        	defaultDeserializer { UnknownInstance.serializer() }
    	}
    }
	classDiscriminator = "bonus"
)

Обеспечить дефолтную обработку в sealed-классах можно с помощью extension-функции SerializersModuleBuilder.polymorphicDefaultSerializer/Deserializer при создании инстанса Json.

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

Еще такой подход не работает с дженериками в классах-наследниках. Хотя Kotlinx.Serialization и поддерживает дженерики, встроенных механизмов полной поддержки дженериков в полиморфных структурах нет. В документации предлагается считать сам параметр этого класса полиморфной структурой и вручную объявлять все классы, которые может принимать параметризованное свойство. Вместо этого можно написать собственный сериализатор.

В Gson для обработки полиморфных структур мы использовали RuntimeTypeAdapterFactory, которую адаптировали для поддержки дефолтных значений.

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

Одним из подходов по поддержке сериализации классов вне списка будет объявление их сериализаторов при создании инстанса Json. Нужно в полях проставить аннотацию @Contextual, тогда плагин будет считать, что мы знаем, что делаем, и в рантайме ContextualSerializer подберет класс из объявленных.

serializersModule = serializersModule.overwriteWith(
	SerializersModule {
    	contextual(BigDecimal::class, BigDecimalAdapter())
    	contextual(OffsetDateTime::class, OffsetDateTimeAdapter())
	}
)

class ContextualInstance(
	@Contextual
	val float: BigDecimal,
	@Contextual
	val date: OffsetDateTime,
)
class BigDecimalSerializer : KSerializer<BigDecimal> {

    val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor(
            “java.math.BigDecimal”,
            PrimitiveKind.STRING
        )

    fun serialize(encoder: Encoder, value: BigDecimal) {
        encoder.encodeString(value.toString())
    }
 
    fun deserialize(decoder: Decoder): BigDecimal {
	    return BigDecimal(decoder.decodeString())
    }
}

class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {

    val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor(
            “java.time.OffsetDateTime”,
            PrimitiveKind.STRING
        )

    fun serialize(encoder: Encoder, value: OffsetDateTime) {
        encoder.encodeString(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value))
    }
 
	fun deserialize(decoder: Decoder): OffsetDateTime {
	    return OffsetDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME)
    }
}

Можно найти open source-решения по сериализации часто используемых классов. Например, на GitHub есть библиотека для классов BigDecimal и BigInteger.

Что получилось после миграции

Для сравнения работы Gson и Kotlinx.Serialization я провел тестирование скорости десериализации и потребляемой памяти у этих двух библиотек.

Сравнение скорости десериализации проводил на тестовом проекте с помощью библиотеки Jetpack Benchmark, тестовый девайс — Samsung Galaxy S22 c Android 12 (One UI 4.1), SoC Qualcomm Snapdragon 8 gen 1. Результаты можно посмотреть в проекте на Github.

Тестирование Kotlinx.Serialization проводилось с включенным и выключенным R8 для оценки влияния оптимизации кода на результаты. Оценивалось время десериализации объектов из json’ов — для этого создали тестовые json в raw resources.

Процесс чтения из ресурса в строку не считался в итоговом времени. Чтобы JVM не удалил эту логику из подсчета как неиспользуемую, результат десериализации сохраняли в переменную.

В конце, чтобы убедиться, что Gson действительно получил поля и десериализация прошла правильно, провели проверку полученных объектов с помощью assertEquals для одного из их полей. Она тоже исключалась из подсчета времени.

Вот список объектов, которые сравнивали:

  1. Простейший случай: объекты, состоящие только из примитивов, и объекты с вложенностью типов, но без дополнительной логики. Оценка влияния вложенности объектов на скорость парсинга (в обоих случаях json состояли из 30 полей).

  2. Десериализация списка объектов, состоящих только из примитивов, — 30 полей vs 300 полей. Оценка влияния размера json’а на скорость парсинга.

  3. Десериализация полиморфных структур данных. Размер общего json — 30 и 300 полей. Оценка влияния размера json’а на скорость парсинга.

  4. Десериализация объектов, состоящих из java-классов, — 30 полей, оценка влияния для Kotlinx.Serialization подбора сериализатора в рантайме.

Результаты собраны в таблицы. Для увеличения точности результатов проведено по 10 запусков бенчмарка для каждого объекта:

Gson оказался медленнее Kotlinx.Serialization с включенной оптимизацией на 49,9% при десериализации маленьких списков и на 63,4% — больших. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 0,1% и 1,5% соответственно
Gson оказался медленнее Kotlinx.Serialization с включенной оптимизацией на 11,1% при отсутствии вложенности и на 22,9% при высокой вложенности. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 0,14% и 3,6% соответственно
Таблица с полными результатами эксперимента

Gson оказался быстрее Kotlinx.Serialization с включенной оптимизацией на 7,6% при десериализации маленьких списков и на 17,6% — больших. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 3,2% и 1,4% соответственно
Gson оказался быстрее Kotlinx.Serialization с включенной оптимизацией на 7,6% при десериализации маленьких списков и на 17,6% — больших. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 3,2% и 1,4% соответственно
Таблица с полными результатами эксперимента

Gson оказался медленнее Kotlinx.Serialization с включенной оптимизацией на 107% при десериализации маленьких списков и на 138,7% — больших. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 2,4% и 3,4% соответственно
Gson оказался медленнее Kotlinx.Serialization с включенной оптимизацией на 107% при десериализации маленьких списков и на 138,7% — больших. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 2,4% и 3,4% соответственно
Таблица с полными результатами эксперимента

Gson оказался быстрее Kotlinx.Serialization с включенной оптимизацией на 47,2%. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 6%
Gson оказался быстрее Kotlinx.Serialization с включенной оптимизацией на 47,2%. Разница в использовании Kotlinx.Serialization с включенной и выключенной оптимизацией R8 составила 6%
Таблица с полными результатами эксперимента

По результатам измерений можно сказать, что в разных сценариях библиотеки показывали разную эффективность. Использование контекстной десериализации у Kotlinx.Serialization приводит к заметному снижению производительности относительно Gson, а вот при десериализации полиморфных структур, наоборот, Kotlinx.Serialization показала себя лучше. При этом оптимизация кода R8 для Kotlinx.Serialization показала значимые улучшения только для контекстной сериализации, в остальных кейсах разница в пределах погрешности.

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

Сравнение потребляемой Памяти. Проводилась десериализация списка из 600 объектов. Результаты получились такие:

Послесловие. Развитие библиотеки

На самом деле бенчмаркинг Kotlinx.Serialization в сравнении с Gson проводили из исследовательского интереса — при выборе библиотеки на первом месте удобство ее использования разработчиками.

Kotlinx.Serialization — гибкий, современный и активно развивающийся инструмент, с преимуществом в чистоте кода и прозрачности логики.

Сейчас актуальная версия Kotlinx.Serialization — 1.5.0, в этом релизе среди прочего добавили: 

  • определение трансформаций, применяемых при сериализации к именам свойств; 

  • поддержку десериализации больших строк с использованием интерфейса ChunkedDecoder (на данный момент его реализует только JsonDecoder); 

  • сериализатор для класса kotlin.Nothing (при попытке сериализации кидает SerializationException, необходим для поддержки в параметризованных классах).

Extension-функции SerializersModuleCollector.polymorphicDefault и PolymorphicModuleBuilder.default теперь помечены как deprecated, вместо них следует использовать функции SerializersModuleBuilder.polymorphicDefaultSerializer/polymorphicDefaultDeserializer и PolymorphicModuleBuilder.defaultDeserializer

Начиная с этой версии правила R8/ProGuard поставляются вместе с библиотекой, больше нет необходимости добавлять их вручную.

Если есть вопросы, замечания или предложения — жду в комментариях!

Комментарии (5)


  1. Rusrst
    13.04.2023 14:27

    Я правильно понял что в итоге generic типы нормально не завелись, если они не в иерархии полиморфной структуры?

    Имеется ввиду ситуация с вложенными generic типами внутри иерархии.


    1. mrzhgn Автор
      13.04.2023 14:27

      Если в каждом модуле, где планируется его использовать, переопределять PolymorphicSerializer для Any, по идее должно заработать, но это явно неочевидно и неоправданно в поддержке, если класс используется по всему проекту. Для локальных историй подойдет


      1. Rusrst
        13.04.2023 14:27

        Я понял, спасибо большое.


    1. mrzhgn Автор
      13.04.2023 14:27

      Правда при написании своих сериализаторов с дженериками на днях тоже столкнулся с интересной проблемой: если к модулю подключен kapt, проект не соберётся, но это баг капта и, надеюсь, когда-нибудь поправят


  1. Sigest
    13.04.2023 14:27

    Сейчас как раз перевожу некоторую логику с Jackson на Kotlinx. Первые впечатления - не понравился. Усложнено все, начиная с того, что каждый сериализуемый класс надо помечать анотацией, когда в джексоне этого не требуется, заканчивая кастомный сериализатор для класса с дженериком. Ну может это где-то в недрах библиотеки и оправдано, но мне помимо data классов и их сериализацию еще и остальную логику надо переводить на мультиплатформу, и вот эти усложнения отнимают время чтобы сидеть и разбираться, хотя для меня, еще со времен jackson сериализации, там не видится какой-то сложности. Ну есть java/kotlin класс, ну странслируй его в json один к одному, в 90% случаев там не требуется особой сложной логики по переводу.

    И кстати у меня enum классы транслируются во что-то enum_field: {u3_1: PRIVATE, u3_1: 0}, хотя jackson их переводил в enum_field: ‘PRIVATE’. Пока еще не разобрался в чем дело, надеюсь отдельный сериализатор писать не придется