The most simple and straightforward way to create serializer is to write annotation @Serializable
directly on your class:
@Serializable
class MyData(val s: String)
In this case, compiler plugin will generate for you:
.serializer()
method on companion object (will be created if there is no such yet) to obtain serializer. If your class is a generic class, this method will have argumentsKSerializer<T1>, KSerializer<T2>
..., whereT1, T2
- your generic type parameters.- Special nested object in your class, which implements
KSerializer<MyData>
- Methods
serialize
,deserialize
andpatch
of interfacesSerializationStrategy
andDeserializationStrategy
- Descriptor property
descriptor
ofKSerializer
- Customizing default serializers
- External serializers for library classes
- Using custom serializers
- Registering and serial modules
If you want to customize representation of the class, in most cases, you need to write your own serialize
and deserialize
methods. patch
method have default implementation of throw UpdateNotSupportedException(descriptor.name)
. Serial descriptor property typically used in generated version of those methods; however, if you're using features like schema saving (which will be discussed later, once it's implemented), it is important to provide consistent implementation of it.
You can write methods directly on companion object, annotate it with @Serializer(forClass = ...)
, and serialization plugin will respect it as default serializer. Note that this is applicable only for companion objects, other nested objects would not be recognized automatically.
(note you still have to apply @Serializable
annotation, because we need to auto-generate descriptor
even if we don't use it)
is pretty straightforward – pick corresponding descriptor, and use encodeXXX/decodeXXX
methods of Encoder/Decoder.
import kotlinx.serialization.*
import kotlinx.serialization.internal.*
@Serializable
class MyData(val s: String) {
@Serializer(forClass = MyData::class)
companion object : KSerializer<MyData> {
override val descriptor: SerialDescriptor = PrimitiveDescriptor("MyData", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, obj: MyData) {
encoder.encodeString(HexConverter.printHexBinary(obj.s.toByteArray()))
}
override fun deserialize(decoder: Decoder): MyData {
return MyData(String(HexConverter.parseHexBinary(decoder.decodeString())))
}
}
}
is a bit more complicated. In this case, you'll need to write a code which is similar to code that is generated by the plugin. However, in some scenarios it may be sufficient to write only a json transformer, so try to read that article first. Further in this section, we'll discuss a more general and complicated approach.
Here will be presented a cut-and-paste recipe; you can read KEEP for details about core concepts.
First, we need to correctly fill-in descriptor so all formats would know about mapping from field names to indices and vice versa:
@Serializable
class BinaryPayload(val req: ByteArray, val res: ByteArray) {
@Serializer(forClass = BinaryPayload::class)
companion object : KSerializer<BinaryPayload> {
override val descriptor: SerialDescriptor = SerialDescriptor("BinaryPayload") {
element<String>("req") // req will have index 0
element<String>("res") // res will have index 1
}
}
}
Now we need to serialize class' properties one-by-one. Since they are structured, i.e. have their own position and name inside of BinaryPayload
class, we would use CompositeEncoder
instead of Encoder
:
override fun serialize(encoder: Encoder, obj: BinaryPayload) {
val compositeOutput = encoder.beginStructure(descriptor)
compositeOutput.encodeStringElement(descriptor, 0, HexConverter.printHexBinary(obj.req))
compositeOutput.encodeStringElement(descriptor, 1, HexConverter.printHexBinary(obj.res))
compositeOutput.endStructure(descriptor)
}
Deserializing a class with multiple values is a complex task, mainly because you don't know the order of fields in the input stream in advance.
So crucial part here is to make a when
over an index of an incoming element:
override fun deserialize(decoder: Decoder): BinaryPayload {
val dec: CompositeDecoder = decoder.beginStructure(descriptor)
var req: ByteArray? = null // consider using flags or bit mask if you
var res: ByteArray? = null // need to read nullable non-optional properties
loop@ while (true) {
when (val i = dec.decodeElementIndex(descriptor)) {
CompositeDecoder.READ_DONE -> break@loop
0 -> req = HexConverter.parseHexBinary(dec.decodeStringElement(descriptor, i))
1 -> res = HexConverter.parseHexBinary(dec.decodeStringElement(descriptor, i))
else -> throw SerializationException("Unknown index $i")
}
}
dec.endStructure(descriptor)
return BinaryPayload(
req ?: throw MissingFieldException("req"),
res ?: throw MissingFieldException("res")
)
}
You can see it in action in tests. Another useful example from tests is custom serializer which uses ability to read JSON as tree.
Note: this approach does not work for generic classes, see below.
The approach above will not work if you can't modify source code of the class (e.g. it is a Kotlin/Java library class). If it is Kotlin class, you can just let the plugin know you want to create a serializer from an object:
// Imagine that MyData is a third-party library class.
// Plugin will try to automatically serialize all constructor properties
// and public vars.
@Serializer(forClass = MyData::class)
object DataSerializer {}
This is called external serialization, which only supports serializing the following:
- Classes with primary constructors that only contain property declarations (i.e. no parameterized primary constructors)
- internal or public class body vars
Any class body vals or private/protected vars will not be seen by the serializer. You can learn more in the example docs
As in the first example, you can customize the process by overriding the serialize
and deserialize
methods.
If it is a Java class, things get more complicated: Java has no concept of a primary constructor and the plugin doesn't know which properties it can serialize. For Java classes, you should always override the serialize
/deserialize
methods.
You can still use @Serializer(forClass = ...)
to generate an empty SerialDescriptor
.
To illustrate, let's write a serializer for java.util.Date
:
@Serializer(forClass = Date::class)
object DateSerializer: KSerializer<Date> {
private val df: DateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm:ss.SSS")
override val descriptor: SerialDescriptor =
PrimitiveDescriptor("WithCustomDefault", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, obj: Date) {
encoder.encodeString(df.format(obj))
}
override fun deserialize(decoder: Decoder): Date {
return df.parse(decoder.decodeString())
}
}
See it in action here.
If your class has generic type arguments, it shouldn't be an object.
It must be a class with visible primary constructor, where its arguments are KSerializer<T0>, KSerializer<T1>, etc..
- one for each type argument of your class.
E.g. for given class class CheckedData<T : Any>(val data: T, val checkSum: ByteArray)
, serializer would look like:
@Serializer(forClass = CheckedData::class)
class CheckedDataSerializer<T : Any>(val dataSerializer: KSerializer<T>) : KSerializer<CheckedData<T>>
If you're familiar with DI concepts, think of it as constructor injection of serializers for generic properties.
Note that we haven't applied @Serializable
on the class, because we can't customize it via companion object since companion objects can't have constructor arguments.
Current limitation: Because the primary constructor in this case is generated by the compiler itself, not the plugin,
you have to override descriptor
manually since it can't be initialized in a non-synthetic constructor.
See full sample here.
The recommended way of using custom serializers is to instruct the plugin which serializer to use for the specified property by using an annotation in the form of @Serializable(with = SomeKSerializer::class)
:
@Serializable
data class MyWrapper(
val id: Int,
@Serializable(with=MyExternalSerializer::class) val data: MyData
)
This will only affect generating the save
/load
methods for this specific class, which allows the plugin to resolve the serializer at compile-time to reduce runtime overhead. It also enables the plugin to inject serializers for generic properties automatically.
If you have a lot of serializable classes, which use, say java.util.Date
, it may be inconvenient to annotate every property with this type with @Serializable(with=MyJavaDateSerializer::class)
. For such purpose, a file-level annotation UseSerializers
was introduced. With it, you can write @file:UseSerializers(MyJavaDateSerializer::class)
and all properties of type java.util.Date
in all classes in this file would be serialized with MyJavaDateSerializer
. See its documentation for more details.
By default, all serializers are resolved by plugin statically when compiling serializable class.
This gives us type-safety, performance and eliminates reflection usage to minimum. However, if there is no
@Serializable
annotation of class and no @Serializable(with=...)
on property, in general,
it is impossible to know at compile time which serializer to
use - user can define more than one external serializer, or define them in other module, or even it's a class from
library which doesn't know anything about serialization.
To support such cases, a concept of SerialModule
was introduced. Roughly speaking, it's a map where
runtime part of framework is looking for serializers if they weren't resolved at compile time by plugin.
Modules are intended to be reused in different formats or even different projects
(e.g. Library A have some custom serializers and exports a module with them so Application B can use A's classes with serializers in B's output).
If you want your external serializers to be used, you pass a module with them to the serialization format.
All standard formats have constructor parameter context: SerialModule
.
When some runtime ambiguity involved, it's always better to be explicit about your intentions — especially in such a security-sensitive thing like a serialization framework. Therefore, to be able to use Context at runtime, you need to explicitly use special ContextSerializer — otherwise compiler will report you an error about missing serializer. To enable contextual serialization, simply use @Serializable(with=ContextSerializer::class)
or @ContextualSerialization
on a property with type which does not have default serializer. To be less verbose, it is also possible to apply this annotation on file — @file:ContextualSerialization(A::class, B::class)
instructs compiler plugin to use ContextSerializer everywhere in this file for properties of types A
and B
. It also can be used on type usages: List<@ContextualSerialization MyDate>
.
In next releases, the same thing would be required for polymorphism and
PolymorphicSerializer
. Start using@Polymorphic
right now!
You can also install different modules for one class into different instances of output formats. Let's see it in example:
// Imagine we have class Payload with 2 different serializers
val simpleModule = serializersModuleOf(Payload::class, PayloadSerializer)
// You can also create modules from map or using a builder function SerializersModule { ... }
val binaryModule = serializersModuleOf(Payload::class, BinaryPayloadSerializer)
val json1 = Json(context = simpleModule)
val json2 = Json(context = binaryModule)
// in json1, Payload would be serialized with PayloadSerializer,
// in json2, Payload would be serialized with BinaryPayloadSerializer
See it in action here.