Введение в полиморфизм в 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)
}
}
}
Если в будущем у нас появится новый подкласс, нам так же будет необходимо добавить его в список регистрируемых классов.