Kotlin

Введение в полиморфизм в Kotlin Serialization

Модели для полиморфизма

Есть два способа реализовать модели для полиморфного JSON. Оба имеют свои плюсы и минусы:

  • Изолированные (sealed) классы: представляют закрытый полиморфизм и требуют меньше кода при реализации.
  • Абстрактные и открытые (open) классы: представляют открытый полиморфизм, требуют дополнительного кода, но обеспечивают гибкость свойственную абстрактным классам.

Дискриминатор классов

Самый простой способ распарсить (deserialize) полиморфный JSON в изолированные классы - classDiscriminator. Для его реализации потребуется минимальный объем кода. Но он требует, чтобы в JSON содержался определенный идентификатор типа. classDiscriminator - свойство класса Json, которое определяет имя JSON свойства, обозначающее тип, в который следует распарсить данный вариант JSON. По умолчанию оно равно "type", но мы можем изменить его на необходимое нам значение.

Пример полиморфного JSON:

[
 {
   "type": "subclass_a",
   "id": 12345,
   "dataA": "Hello"
 },
 {
   "type": "subclass_b",
   "id": 54321
   "dataB": 100
 },
 {
   "type": "subclass_a",
   "id": 98765,
   "dataA": "Goodbye"
 }
]

В нём два разных типа объектов. Каждый из них имеет специфические для него свойства (строковое dataA и целое dataB). Мы можем парсить такие объекты в классы, содержащие все возможные поля, но тогда мы потеряем взаимосвязи типов c их свойствами: А всегда сожержит dataA и B всегда содержит dataB. Вместо этого мы можем распарсить данный JSON в List<Base> и реализовать его подклассы, сохраняя взаимосвязь типов и свойств:

Json {
   classDiscriminator = "type"
}

@Serializable
sealed class Base {

   abstract val id: Int
   
   @Serializable
   @SerialName("subclass_a")
   data class SubclassA(
       @SerialName("id") override val id: Int,
       @SerialName("dataA") val dataA: String,
   ) : Base()
   
   @Serializable
   @SerialName("subclass_b")
   data class SubclassB(
       @SerialName("id") override val id: Int,
       @SerialName("dataB") val dataB: Int,
   ) : Base()
}

Аннотация @SerialName на каждом подклассе указывает на соответствующее ему значение свойтсва type. Это значит, что если значение type равно "subclass_a", то оно будет парситься как класс SubclassA, а если "subclass_b" то как класс SubclassB.

JsonClassDiscriminator

Настройка Json.classDiscriminator полезна, когда у нас по всему JSON имена свойств для обозначения типа одинаковые. Но если у разных классов они отличаются, то этот способ нам не подойдет. Однако, и на такой случай найдется решение:

@JsonClassDiscriminator("data_type")
@Serializable
sealed class Base {

   abstract val id: Int
   
   @Serializable
   @SerialName("subclass_a")
   data class SubclassA(
       @SerialName("id") override val id: Int,
       @SerialName("dataA") val dataA: String,
   ) : Base()
   
   @Serializable
   @SerialName("subclass_b")
   data class SubclassB(
       @SerialName("id") override val id: Int,
       @SerialName("dataB") val dataB: Int,
   ) : Base()
}

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

Content Serializer

Дискриминатор, задаваемый настройкой classDiscriminator, а также аннотация JsonClassDiscriminator - мощные инструменты, но они предполагают наличие специального свойства, чтобы определять, какой именно тип использовать. Но такое свойство не всегда может быть, и данные способы нам тогда не помогут. Но и здесь есть решение!

Посмотрим, как может выглядеть JSON без свойств вроде type:

[
 {
   "id": 12345,
   "dataA": "Hello"
 },
 {
   "id": 54321
   "dataB": 100
 },
 {
   "id": 98765,
   "dataA": "Goodbye"
 }
]

Это простой JSON, но мы видим, что отдельные модели содержат уникальные для них свойства typeA и typeB, специфичные для их типов. Этой информации нам будет достаточно, чтобы определить в какой подкласс их распарсить.

object BaseSerializer : JsonContentPolymorphicSerializer<Base>(
   Base::class,
) {

   override fun selectDeserializer(
       element: JsonElement,
   ): DeserializationStrategy<Base> {
       val jsonObject = element.jsonObject
       return when {
           jsonObject.containsKey("dataA") -> SubclassA.serializer()
           jsonObject.containsKey("dataB") -> SubclassB.serializer()
           else -> throw IllegalArgumentException(
               "$type is not a supported Base type.",
           )
       }
   }
}

@Serializable(BaseSerializer::class)
sealed class Base {
   ...
}

Так как мы имеем доступ ко всему JsonElement, мы можем изменять логику выбора подтипа как угодно. Нам так же ничто не помешает использовать этот подход и при наличии поля описывающего тип, если нам нужен более гибкий процесс парсинга:

Json {
   classDiscriminator = "type"
}

object BaseSerializer : JsonContentPolymorphicSerializer<Base>(
   Base::class,
) {

   override fun selectDeserializer(
       element: JsonElement,
   ): DeserializationStrategy<Base> {
       val json = element.jsonObject
       val type = json.getValue("data_type").jsonPrimitive.content
       return when (type) {
           "subclass_a" -> SubclassA.serializer()
           "subclass_b" -> SubclassB.serializer()
           else -> throw IllegalArgumentException(
               "$type is not a supported Base type.",
           )
       }
   }
}

@Serializable(BaseSerializer::class)
sealed class Base {
   ...
}

Поскольку тут мы перехватываем процесс определения подтипа, нам нужно убедиться, что используемое имя обозначающего тип свойства не равно значению classDiscriminator. Иначе мы получим ошибку при попытке парсинга. В нашем случае classDiscriminator равен type, а используемое свойство - data_type, значит конфликта нет.

Открытый полиморфизм

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

Json {
   classDiscriminator = "type"
}

@Serializable
abstract class Base {

   abstract val id: Int
   
   @Serializable
   @SerialName("subclass_a")
   data class SubclassA(
       @SerialName("id") override val id: Int,
       @SerialName("dataA") val dataA: String,
   ) : Base()
   
   @Serializable
   @SerialName("subclass_b")
   data class SubclassB(
       @SerialName("id") override val id: Int,
       @SerialName("dataB") val dataB: Int,
   ) : Base()
}

Но этого будет недостаточно, так как при открытом полиморфизме мы должны зарегистрировать все подклассы, которые могут быть распарсены. Это в целом не сложный процесс, но добавляет работы. Поэтому рекомендуется использовать изолированный полиморфизм, так как его проще реализовывать и поддерживать.

Json {
   classDiscriminator = "type"
   serializersModule = SerializersModule {
       polymorphic(Base::class) {
           subclass(Base.SubclassA::class)
           subclass(Base.SubclassB::class)
       }
   }
}

Если в будущем у нас появится новый подкласс, нам так же будет необходимо добавить его в список регистрируемых классов.