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.