paint-brush
Generated JSON-Serialisation for Kotlinby@hackernoon-archives

Generated JSON-Serialisation for Kotlin

tldt arrow

Too Long; Didn't Read

For quite some time I used Gson for serialisation in our Kotlin and Java-applications. It was the most brittle part of our code, for several reasons:
featured image - Generated JSON-Serialisation for Kotlin
蓮沼 貴裕(Takahiro Hasunuma) HackerNoon profile picture

For quite some time I used Gson for serialisation in our Kotlin and Java-applications. It was the most brittle part of our code, for several reasons:

  • When Gson doesn’t find a no-arg constructor, sun.misc.Unsafe is used. This means initialisers aren’t called and the deserialised objects could be broken.
  • Gson uses reflection to set and retrieve fields. Reflection is slow on Android.
  • Nulls in JSON are able to break Kotlin’s null-safe typing.

To improve on that situation, I decided to write an Annotation Processor that generates serialisation code at compile-time, for that would give the most freedom. Here are my design-choices:

All necessary data needs to be in the constructor

I decided to instantiate deserialised objects with an existing constructor only (the one with the most arguments). This ensures that they are always initialised properly.

The only needed annotation is @GsonSerializer which goes on the top of a class:

@GsonSerializer
class AppState(val userName: String,
               val notifications: List<String>)

This forces separation of initial from generated data. It also removes the need to exclude lazy or @Transient properties.

The generated serialiser is an extension function on JsonWriter:

fun JsonWriter.value(obj: AppState?) {
    if (obj == null) {
        nullValue()
        return
    }
    beginObject()
    name("userName").value(obj.userName)
    name("notifications").array(obj.notifications) { value(it) }
    endObject()
}

The field names and types are taken from the constructor. The code assumes they are also accessible as members, which is easy to obtain.

Developer-friendly deserialisation

Deserialisation is more complex, since it does a couple of things to help development:

  • It throws informative errors when fields are missing
  • Field-names and enum-values are matched case-insensitive. I realise this is debatable, but it prevents some flow-breaking bugs and was never an issue in production. Serialisation keeps the original casing.

The deserialiser:

fun JsonReader.nextAppState(): AppState {

    var userName: String? = null
    var notifications: MutableList<String>? = null

    beginObject()
    while (hasNext()) {
        when (nextName().toLowerCase()) {
            "username" -> userName = nextString()
            "notifications" -> notifications = readList(JsonReader::nextString)
            else -> skipValue()
        }
    }
    endObject()

    val obj = AppState(
        userName = checkNotNull(userName) {"error parsing AppState, field not found: userName: String"},
        notifications = checkNotNull(notifications) {"error parsing AppState, field not found: notifications: List"}
    )

    return obj
}

Nested objects

Since all methods are created as extensions on JsonWriter and JsonReader adding support for more fields is as simple as creating a new extension method or adding @GsonSerializer to the referenced class:

@GsonSerializer
class AppState(val user: UserInfo,
               val time: ZonedDateTime)

class UserInfo(name: String)

The generated code for this example won’t compile because it misses:

  • JsonWriter.value(UserInfo)
  • JsonReader.nextUserInfo()
  • JsonWriter.value(ZonedDateTime)
  • JsonReader.nextZonedDateTime()

To make it work add @GsonSerializer to UserInfo and add the following code for ZonedDateTime

fun JsonWriter.value(obj: ZonedDateTime) {
    value(obj.toString())
}

fun JsonReader.nextZonedDateTime(): ZonedDateTime {
    return ZonedDateTime.parse(nextString())
}

Abstract Types

Reading and writing abstract types is necessary and possible, consider this:

@GsonSerializer
class AppState(val requests: Set<NetworkRequest>)

@GsonSerializer
interface NetworkRequest

@GsonSerializer
data class LoginReq(val username: String, val password: String) : NetworkRequest

The processor checks the type-hierarchy and writes the object and type-name into an array. I chose an array over an object so I wouldn’t have to cache anything in case the type-name arrives after the data during deserialisation.

fun JsonWriter.abstractValue(obj: NetworkRequest?) {
    if (obj == null) {
        nullValue()
        return
    }
    beginArray()
    when (obj) {
        is LoginReq -> value("LoginReq").value(obj)
        else -> throw IllegalStateException("type $obj not annotated")
    }
    endArray()
}

/* deserialize */
fun JsonReader.nextNetworkRequest(): NetworkRequest {
    beginArray()
    val typeName = nextString()
    val obj: NetworkRequest = when (typeName) {
        "LoginReq" -> nextLoginReq()
        else -> throw IllegalStateException("unknown type: $typeName")
    }
    endArray()

    return obj
}

Graphs and circular objects

Using another annotation @ParentRef, the processor is able to process circular graphs, like a leaf-node having a references to it’s root

@GsonSerializer
class Root(var leaf: Leaf?)

@GsonSerializer
data class Leaf(@ParentRef val root: Root, val number: Int)

The deserialiser for Leaf returns a function now:

fun JsonReader.nextLeaf(): (root: Root) -> Leaf {
    var number: Int? = null

    beginObject()
    while (hasNext()) {
        when (nextName().toLowerCase()) {
            "number" -> number = nextInt()
            else -> skipValue()
        }
    }
    endObject()

    return { root->
        Leaf(
            root = root,
            number = checkNotNull(number) {"error parsing Leaf, field not found: number: Int"}
        )
    }
}

For type-safe deserialisation to work, the field in the parent-object must be either nullable or a collection-type:

fun JsonReader.nextRoot(): Root {

    var leaf: ((Root) -> Leaf)? = null
    
    beginObject()
    while (hasNext()) {
        when (nextName().toLowerCase()) {
            "leaf" ->  {
                 leaf = nextOrNull(JsonReader::nextLeaf)
             }
            else -> skipValue()
        }
    }
    endObject()

    /* construct parent */
    val obj = Root(
        leaf = null
    )

    /* set child */
    obj.leaf = leaf?.invoke(obj)

    return obj
}

Summary

This was a short overview about my new method of serialisation. I can’t share any code yet but the principles are clear.

The choice of compile-time-generation and Kotlin with it’s extensions methods and lambdas makes it possible to easily create convenient and fast serialisation-code.

Assuming all necessary data to be passed in the constructor helps keeping everything safe and enforces separation of initial from generated data.

I hope you can take some ideas with you. Happy coding.