This library supports serialization of CARP domain objects, for both the JVM and JavaScript runtime.
This is achieved by relying on the kotlinx.serialization
library and compiler plugin.
Classes are made serializable by applying a @Serializable
annotation to the class definition.
Custom serializers for classes and properties can be specified through an optional KClass
parameter of @Serializable
which specifies the custom KSerializer<T>
to use, e.g., @Serializable( CustomSerializer::class )
.
Most of this is self-explanatory when looking at the codebase. StudyProtocolSnapshot
is a good starting point to see how composite objects with complicated hierarchies of inheriting objects, including collections, can be serialized.
But, what follows are pointers on how to use this library specific to CARP, which might be useful in case you need to understand the codebase, extend on base types, or introduce new ones.
A core design principle of our architecture is to be extensible. Many of the (domain model) classes contained in this library can be—and are meant to be—extended by external users. The question then arises how we should 'load' such custom classes when they are sent to our infrastructure which is unaware about them.
To enable this, we rely on a custom serializer for each of the base classes which are designed for extension. When encountering unknown types, these serializers extract the base properties they do know about, and 'wrap' the unknown type in a custom type definition deriving from the same base class, thereby providing transparent access to base properties without having to know the concrete type (the type does not need to be 'loaded' at runtime). When serializing unknown types, the original serialized form is output again. Thus, the 'wrapper' is only ever present while the custom domain objects are held in memory on a runtime which does not have the concrete type available. We envision this will greatly facilitate dealing with (or simply ignoring) such objects, making the codebase more stable and maintainable.
Serializing unknown types is currently only supported when using Json.
Other formats, such as ProtoBuf or CBOR, will throw a SerializationException
when trying to serialize types which are not registered for polymorphic serialization.
To facilitate the creation of these custom serializers, the abstract base class UnknownPolymorphicSerializer<P: Any, W: P>
can be used (suggestions for a better name are still welcome).
This is a "serializer for polymorph objects of type [P] which wraps extending types unknown at runtime as instances of type [W]."
A helper function createUnknownPolymorphicSerializer
simplifies creating these concrete classes by only having to pass a method which constructs the custom wrapper based on incoming JSON data.
For example, the following code creates an UnknownPolymorphicSerializer
for an abstract TaskConfiguration
class.
object TaskConfigurationSerializer : KSerializer<TaskConfiguration>
by createUnknownPolymorphicSerializer( { className, json, serializer -> CustomTaskConfiguration( className, json, serializer ) } )
The custom wrapper needs to:
- Extend from the base class. E.g.,
TaskConfiguration
. - Implement
UnknownPolymorphicWrapper
, which simply provides access to the class name and original JSON source. - Apply
@Serializable
to use the custom serializer. E.g,@Serializable( TaskConfigurationSerializer::class )
.
The only real work lies in extracting the base properties using traditional JSON parsing.
This can easily be done using a serializer of an intermediate concrete type, as exemplified in CustomTaskConfiguration
.
The custom serializer should be configured as the default
serializer for the expected base type in the SerializersModule
,
and the wrapper should be registered as a subclass.
For example, TaskConfigurationSerializer
is the UnknownPolymorphicSerializer
for the TaskConfiguration
base class,
which is registered in the common subsystem SerializersModule
:
polymorphic( TaskConfiguration::class )
{
subclass( ConcurrentTask::class )
...
subclass( CustomTaskConfiguration::class )
default { TaskConfigurationSerializer }
}
The serializer for classes with named companion objects cannot be found. We recommend not to name companion objects. The compiler plugin adds a serializer()
method to the companion object, and the JavaScript runtime relies on the default name of companion objects (Companion
). This is something I fixed for the JVM runtime, but seems harder to do for the JS runtime.