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:
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:
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.
Deserialisation is more complex, since it does a couple of things to help development:
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
}
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:
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())
}
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
}
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
}
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.