Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
4 contributors

Users who have contributed to this file

@sandwwraith @ValV @BenWoodworth @fvasco
246 lines (182 sloc) 13.5 KB

Writing and using your own serializers

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 arguments KSerializer<T1>, KSerializer<T2>..., where T1, T2 - your generic type parameters.
  • Special nested object in your class, which implements KSerializer<MyData>
  • Methods serialize, deserialize and patch of interfaces SerializationStrategy and DeserializationStrategy
  • Descriptor property descriptor of KSerializer

Table of contents

Customizing

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)

Representing classes as a single value

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 =
            StringDescriptor.withName("MyData")

        override fun serialize(output: Encoder, obj: MyData) {
            output.encodeString(HexConverter.printHexBinary(obj.s.toByteArray()))
        }

        override fun deserialize(input: Decoder): MyData {
            return MyData(String(HexConverter.parseHexBinary(input.decodeString())))
        }
    }
}

Representing classes as multiple values

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. 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<MyData> {
        override val descriptor: SerialDescriptor = object : SerialClassDescImpl("BinaryPayload") {
            init {
                addElement("req") // req will have index 0
                addElement("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(output: Encoder, obj: BinaryPayload) {
    val compositeOutput: CompositeEncoder = output.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(input: Decoder): BinaryPayload {
    val inp: CompositeDecoder = input.beginStructure(descriptor)
    lateinit var req: ByteArray // consider using flags or bit mask if you 
    lateinit var res: ByteArray // need to read nullable non-optional properties
    loop@ while (true) {
        when (val i = inp.decodeElementIndex(descriptor)) {
            CompositeDecoder.READ_DONE -> break@loop
            0 -> req = HexConverter.parseHexBinary(inp.decodeStringElement(descriptor, i))
            1 -> res = HexConverter.parseHexBinary(inp.decodeStringElement(descriptor, i))
            else -> throw SerializationException("Unknown index $i")
        }
    }
    inp.endStructure(descriptor)
    return BinaryPayload(req, 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 is not working for generic classes, see below.

External serializers for library classes

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 serializer from 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 and imposes certain restrictions - class should have only primary constructor's vals/vars and class body var properties (you can learn more in docs)

As in the first example, you can customize the process by overriding serialize and deserialize methods.

If it is Java class, things getting more complicated: because Java has no concept of primary constructor, plugin don't know which properties it can took. For Java classes, you always should override serialize/deserialize methods. You still can use @Serializer(forClass = ...) to generate empty serial descriptor. For example, let's write 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 =
        StringDescriptor.withName("WithCustomDefault")

    override fun serialize(output: Encoder, obj: Date) {
        output.encodeString(df.format(obj))
    }

    override fun deserialize(input: Decoder): Date {
        return df.parse(input.decodeString())
    }
}

See it in action here.

About generic serializers

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 object can't have constructor arguments.

Current limitation: Because primary constructor in such case is generated by the compiler itself, not the plugin, you have to override descriptor manually since it can't be initialized in non-synthetic constructor.

See full sample here.

Using custom serializers

Recommended way of using custom serializers is to give a clue to plugin about which serializer use for specified property, using annotation in form @Serializable(with = SomeKSerializer::class):

@Serializable
data class MyWrapper(
    val id: Int,
    @Serializable(with=MyExternalSerializer::class) val data: MyData
)

This will affect generating of save/load methods only for this dedicated class, and allows plugin to resolve serializer at compile-time to reduce runtime overhead. It would also allow plugin to inject serializers for generic properties automatically.

UseSerializers annotation

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.

Registering and context

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.

ContextualSerialization annotation

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!

Example

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.

You can’t perform that action at this time.