JSON in Kotlin – DZone

In any web service that receives and transmits data to and from a server, the first and last events will usually be transforming the data from the format used by the web request into the format that the web server will handle, and vice versa; these operations are called deserialization and serialization, respectively. For some web services, the thought put towards this part of the flow of data is focused solely on how to configure the serialization mechanism so it works properly. However, there are some scenarios for which every CPU cycle counts, and the faster the serialization mechanism can work, the better. This article will explore the development and performance characteristics of four different options for working with the serialization of JSON messages—GSON, Jackson, JSON-B, and Kotlinx Serialization, using both the Kotlin programming language and some of the unique features that Kotlin offers compared to its counterpart language, Java.

Setup

Since its first release in 2017, Kotlin has grown by leaps and bounds within the JVM community, becoming the go-to programming language for Android development as well as a first-class citizen in major JVM tools like Spring, JUnit, Gradle, and more. Among the innovations it brought to the JVM community compared to Java was the data class, a special type of class that is to be used primarily as a holder of data (in other words, a Data Transfer Object, or DTO) and automatically generates base utility functions for the class like equals(), hashcode(), copy(), and more. This will form the base of the classes that will be used for the performance tests, the first of which being PojoFoo. “Pojo” stands for “Plain Old Java Object,” signifying using only basic class types of the Java programming language:

data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
   constructor() : this("", 0, emptyList())
}

For those who are not familiar with the Kotlin programming language: the class has three attributes:

  1. fizz
  2. bizz
  3. bazz

That contain both getter and setter functions. There are two constructors for the class: 

  1. One that requires arguments for each of the attributes.
  2. One that requires no arguments and populates the attributes with default values.

This second constructor is the “no-arg constructor” that is typically required by JSON serialization mechanisms.

In the example above, the three class attributes are marked with the keyword var; this signifies that the attributes are mutable and can be modified at any time during the lifetime of an instance of the class. To make the attributes immutable, all that is needed is to change the designator to val, upon which the attributes will become the equivalent of final attributes in Java, and Kotlin will no longer generate a getter function for the attributes. In addition, this removes the requirement of a no-arg constructor, so that can be eliminated from the code:

data class ImmutableFoo(val fizz: String, val bizz: Int, val bazz: List<String>)

The next example class—DefaultValueFoo—uses a default value for the attribute fizz. This means that, if the constructor of DefaultValueFoo is invoked and no argument is provided for fizz, then the argument will be assigned the default value:

data class DefaultValueFoo(var fizz: String = "FUZZ", var bizz: Int, var bazz: List<String>) {
   constructor() : this(bizz = 0, bazz = emptyList())
}

Finally, the example class ValueClassFoo changes the type of attribute bizz from a plain integer to an inline class. Inline classes function as wrappers around a single “payload” value; while the Kotlin code will treat the inline class as a “genuine” class, the compiler will translate the code so that only the payload value is present. This provides for several advantages compared to simply using the payload value directly, such as enforcing a type safety for different variables, for example specifying a username and a password type—two types that would normally both be strings—for a login function. In this case, it allows for the usage of UInt: a Kotlin-exclusive class that simulates the behavior of an unsigned function, something that is not supported by default by the JVM:

data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
   constructor() : this("", 0u, emptyList())
}

Note: the class is named as such because while inline classes are still called as such in the Kotlin documentation, they have been renamed as value classes in the actual code; the keyword inline is deprecated.

The Contestants

GSON

Introduced in 2008 and developed by Google, GSON is one of the main options that Java users employ for conducting serialization between JSON strings and Java objects and is the preferred library to leverage in Android development thanks to the support by Google.

Usage

The basic usage is to construct an instance of Gson and invoke the functions Gson.toJson() and Gson.fromJson() to serialize an object and deserialize a JSON string, respectively.

Working With Kotlin

Surprisingly, there are no additional steps necessary to work with the four example classes; all of the code snippets provided above were from the GSON testing code.

Jackson

Introduced in 2009, Jackson is the other widely-used JSON serialization library—alongside GSON—and is used by default in major JVM ecosystems like the Spring framework.

Usage

The basic usage is to construct an instance of ObjectMapper and invoke the functions ObjectMapper.writeValueAsString() and ObjectMapper.readValue() to serialize an object and deserialize a JSON string, respectively.

Working With Kotlin

Unlike GSON, there is quite a bit of work that is necessary to support the Kotlin features in the example classes:

  • Jackson does not have a native concept of deserializing classes that do not possess a no-arg constructor; if it cannot find a no-arg constructor, it will normally raise an exception. A workaround for this is to mark the parameters in the constructor with @JsonProperty so that Jackson knows which argument corresponds to which class attribute:
data class ImmutableFoo(
   @param:JsonProperty("fizz") val fizz: String,
   @param:JsonProperty("bizz") val bizz: Int,
   @param:JsonProperty("bazz") val bazz: List<String>
)

  • Inline classes are not processed properly due to a difference in how Jackson computes how to conduct serialization and deserialization on a class. An advantage of these serialization libraries is that they do not normally require the creation of specialized classes to conduct the serialization and deserialization actions on a class. Instead, they compute which fields to pull values from and set via reflection; whereas GSON executes the reflection actions on the actual attribute fields within the target class, Jackson’s reflection actions are targeted on the attributes’ getter and setter functions. This is an issue with inline classes, as any function that accepts or returns an inline class is name-mangled to prevent collisions with functions that might accept the equivalent “normal” type in the JVM. Thus, serializing and deserializing classes with inline class attributes will prove problematic:
// Attempting to serialize to JSON
expected: <{"fizz":"FUZZ","bizz":5,"bazz":["BUZZ","BOZZ"]}> but was: <{"fizz":"FUZZ","bazz":["BUZZ","BOZZ"],"bizz-pVg5ArA":5}>
// Attempting to deserialize from JSON
Unrecognized field "bizz" (class com.severett.serializationcomparison.jackson.model.ValueClassFoo), not marked as ignorable (3 known properties: "fizz", "bizz-WZ4Q5Ns", "bazz"])

While there is a specialized module for Jackson— jackson-module-kotlin—which provides support for many parts of Kotlin that are not included in the testing here (e.g., Pair, Triple, IntRange, etc.), it does not provide support for inline classes and does not plan on offering support for the foreseeable future. Instead, it is necessary to create custom serializer and deserializer classes to handle ValueClassFoo and mark ValueClassFoo with @JsonSerialize and @JsonDeserialize, respectively:

class ValueClassFooSerializer : JsonSerializer<ValueClassFoo>() {
   override fun serialize(value: ValueClassFoo, gen: JsonGenerator, serializers: SerializerProvider?) {
       gen.writeStartObject()
       gen.writeStringField(ValueClassFoo.FIZZ_FIELD, value.fizz)
       gen.writeNumberField(ValueClassFoo.BIZZ_FIELD, value.bizz.toInt())
       gen.writeArrayFieldStart(ValueClassFoo.BAZZ_FIELD)
       value.bazz.forEach(gen::writeString)
       gen.writeEndArray()
       gen.writeEndObject()
   }
}

class ValueClassFooDeserializer : JsonDeserializer<ValueClassFoo>() {
   override fun deserialize(jsonParser: JsonParser, ctxt: DeserializationContext?): ValueClassFoo {
       val node = jsonParser.codec.readTree<JsonNode>(jsonParser)
       return ValueClassFoo(
           fizz = node[ValueClassFoo.FIZZ_FIELD].asText(),
           bizz = node[ValueClassFoo.BIZZ_FIELD].asInt().toUInt(),
           bazz = (node[ValueClassFoo.BAZZ_FIELD] as ArrayNode).map { it.textValue() }
       )
   }
}

@JsonSerialize(using = ValueClassFooSerializer::class)
@JsonDeserialize(using = ValueClassFooDeserializer::class)
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
   constructor() : this("", 0u, emptyList())

   companion object {
       const val FIZZ_FIELD = "fizz"
       const val BIZZ_FIELD = "bizz"
       const val BAZZ_FIELD = "bazz"
   }
}

JSON-B

A relative newcomer to the Java world—having been first released only in 2017 alongside JEE 8—JSON-B is an official standard for conducting serialization and deserialization for the JSON data format. The API uses either Eclipse Yasson or Apache Johnzon as the underlying implementation, meaning either one of these libraries would have to be included as a runtime dependency; the tests for this article used Yasson as the implementation.

Usage

The basic usage is to construct an instance of Jsonb via JsonbBuilder.create() and invoke the functions Jsonb.toJson() and Jsonb.fromJson() to serialize an object and deserialize a JSON string, respectively.

Working With Kotlin

JSON-B requires the most work of the four libraries evaluated to properly work with Kotlin.

  • JSON-B serializes a class’s attributes in alphabetical order instead of declaration order. While this is not a deal-breaker—JSON objects do not require ordering for key fields—it is necessary to annotate a class with @JsonbPropertyOrder if specific ordering is desired:
@JsonbPropertyOrder("fizz", "bizz", "bazz")
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
   constructor() : this("", 0, emptyList())
}

  • Like Jackson, JSON-B requires a no-arg constructor and will fail if it does not encounter one while deserializing a JSON string into a class. Thus, a class without a no-arg constructor will need to mark the constructor that JSON-B needs to use with @JsonbCreator and mark each of the constructor’s arguments with @JsonbProperty so they correspond to the class’s attributes:
@JsonbPropertyOrder("fizz", "bizz", "bazz")
data class ImmutableFoo @JsonbCreator constructor(
   @JsonbProperty("fizz") val fizz: String,
   @JsonbProperty("bizz") val bizz: Int,
   @JsonbProperty("bazz") val bazz: List<String>
)

  • Lastly, JSON-B also shares Jackson’s trait of not being able to handle inline classes properly. Attempting to serialize ValueClassFoo will produce an incorrect output, and while JSON-B will not fail while trying to deserialize a string to ValueClassFoo, it will fail to populate the inline class attribute correctly:
// Attempting to serialize to JSON
expected: <{"fizz":"FUZZ","bizz":5,"bazz":["BUZZ","BOZZ"]}> but was: <{"bazz":["BUZZ","BOZZ"],"bizz-pVg5ArA":5,"fizz":"FUZZ"}>
// Attempting to deserialize from JSON
expected: <ValueClassFoo(fizz=FUZZ, bizz=5, bazz=[BUZZ, BOZZ])> but was: <ValueClassFoo(fizz=FUZZ, bizz=0, bazz=[BUZZ, BOZZ])>

Like Jackson, the target class will need special serializer and deserializer classes to handle it and be annotated as such:

class ValueClassFooSerializer : JsonbSerializer<ValueClassFoo> {
   override fun serialize(valueClassFoo: ValueClassFoo, generator: JsonGenerator, ctx: SerializationContext?) {
       generator.writeStartObject()
       generator.write(ValueClassFoo.FIZZ_FIELD, valueClassFoo.fizz)
       generator.write(ValueClassFoo.BIZZ_FIELD, valueClassFoo.bizz.toInt())
       generator.writeStartArray(ValueClassFoo.BAZZ_FIELD)
       valueClassFoo.bazz.forEach(generator::write)
       generator.writeEnd()
       generator.writeEnd()
   }
}

class ValueClassFooDeserializer : JsonbDeserializer<ValueClassFoo> {
   override fun deserialize(jsonParser: JsonParser, ctx: DeserializationContext?, rtType: Type?): ValueClassFoo {
       var fizz: String? = null
       var bizz: UInt? = null
       var bazz: List<String>? = null
       while (jsonParser.hasNext()) {
           val event = jsonParser.next()
           if (event != JsonParser.Event.KEY_NAME) continue
           when (jsonParser.string) {
               ValueClassFoo.FIZZ_FIELD -> {
                   jsonParser.next()
                   fizz = jsonParser.string
               }
               ValueClassFoo.BIZZ_FIELD -> {
                   jsonParser.next()
                   bizz = jsonParser.int.toUInt()
               }
               ValueClassFoo.BAZZ_FIELD -> {
                   jsonParser.next()
                   bazz = jsonParser.array.getValuesAs(JsonString::class.java).map { it.string }
               }
           }
       }
       if (fizz != null && bizz != null && bazz != null) {
           return ValueClassFoo(fizz = fizz, bizz = bizz, bazz = bazz)
       } else {
           throw IllegalStateException("'fizz', 'bizz', and 'bazz' must be not null")
       }
   }
}

@JsonbTypeDeserializer(ValueClassFooDeserializer::class)
@JsonbTypeSerializer(ValueClassFooSerializer::class)
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
   constructor() : this("", 0u, emptyList())

   companion object {
       const val FIZZ_FIELD = "fizz"
       const val BIZZ_FIELD = "bizz"
       const val BAZZ_FIELD = "bazz"
   }
}

Kotlinx Serialization

Finally, the authors of Kotlin have published their own serialization library for the Kotlin programming language. First released in 2020, the Kotlinx Serialization library is designed for serialization actions in general, not just JSON; while the library only contains official support for JSON, it has experimental support for other formats like Protobuf and CBOR as well as community support for formats like YAML.

Usage

Unlike the other JSON serialization libraries, there is no instance object that needs to be created for conducting serialization actions. Instead, calls to the extension functions encodeToString() and decodeFromString() are made for the serializing object in question. In this case, the Kotlin object Json.

Working With Kotlin

Unlike the other JSON serialization libraries, Kotlinx Serialization does not work on custom classes by default. This is due to the way the library works: instead of using reflection like the other libraries, Kotlinx Serialization generates specific serialization and deserialization functions for the target class(es) at compile time. To recognize which classes need this serialization code generated for it, any target classes need to be annotated with @Serializable (a different method is available for third-party classes):

@Serializable
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
   constructor() : this("", 0, emptyList())
}

In addition, Kotlinx Serialization does not work by default on attributes with a default value. This needs to be enabled with the annotation @EncodeDefault:

@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class DefaultValueFoo(@EncodeDefault val fizz: String = "FUZZ", var bizz: Int, var bazz: List<String>) {
   constructor() : this(bizz = 0, bazz = emptyList())
}

Testing

Parameters

Each of the four JSON serialization libraries conducts serialization and deserialization of the four example classes, and the Java Microbenchmark Harness (JMH) benchmark tests measure the throughput of how many operations get executed per second on average. For example:

@State(Scope.Benchmark)
open class SerializationComparison {
   private val gson = Gson()

   @Benchmark
   fun serializePojoFoo(): String = gson.toJson(pojoFoo)

   @Benchmark
   fun serializeImmutableFoo(): String = gson.toJson(immutableFoo)

   @Benchmark
   fun serializeDefaultValueFoo(): String = gson.toJson(defaultValueFoo)

   @Benchmark
   fun serializeValueClassFoo(): String = gson.toJson(valueClassFoo)

   @Benchmark
   fun deserializePojoFoo(): PojoFoo = gson.fromJson(pojoFooStr, PojoFoo::class.java)

   @Benchmark
   fun deserializeImmutableFoo(): ImmutableFoo = gson.fromJson(immutableFooStr, ImmutableFoo::class.java)

   @Benchmark
   fun deserializeDefaultValueFoo(): DefaultValueFoo = gson.fromJson(defaultValueFooStr, DefaultValueFoo::class.java)

   @Benchmark
   fun deserializeValueClassFoo(): ValueClassFoo = gson.fromJson(valueClassFooStr, ValueClassFoo::class.java)
}

These tests utilize JMH’s defaults of:

  • Five warmup rounds of ten seconds.
  • Five rounds of measurements.
  • Five forked processes to conduct both of the above.

The tests are run on a macOS with an Intel Core i7 2.6 GHz 6-Core and 16GB of RAM; the executing JVM is Temurin 19+36.

Results

Serialization

The clear winner among the four libraries is Kotlinx Serialization, as it averages over 5 million operations per second, much faster than the second-place Jackson library. It’d be impossible to identify the exact reasons for why the performance of Kotlinx Serialization is so much higher compared to the competition without diving too deeply into the source code of each library, but a hint may lie in how the other libraries perform much better during the serialization of ValueClassFoo compared to the other example classes (the exception is Kotlinx Serialization, which appears to do worse, but given the error ranges for each result, it’s not statistically significant). For example, running the Java Flight Recorder profiler on Jackson provides the following result in the call tree for serializing PojoFoo:

POJO Foo

In contrast, here is the call tree for serializing ValueClassFoo:

Value Class Foo

As the two call trees show, creating a special class for the serialization of instances of ValueClassFoo means Jackson does not have to use reflection—a very expensive process, computationally-speaking—to determine what attributes need to be serialized. Of course, this comes with the downside of having more code for the developer to maintain, and will break as soon as the class’s attributes are modified.

Deserialization

Deserialization

Again, Kotlinx Serialization clearly performs better for deserializing compared to the remaining three libraries. GSON, Jackson, and Kotlinx Serialization all performed markedly better when deserializing instances of DefaultValueFoo, and that’s presumably because there were fewer data to read in for the deserialization test—for that scenario, the libraries had to deserialize {"bizz":5,"bazz":["BUZZ","BOZZ"]}, meaning one less field to parse. Interestingly, Jackson did worse in deserializing ValueClassFoo compared to the other example classes. Again using the Java Flight Recorder profiler, here is a flame graph for Jackson deserializing PojoFoo:

Flame Graph

Likewise, here is a flame graph for Jackson deserializing ValueClassFoo:

Flame Graph 2

It appears that, in contrast to serialization actions, Jackson’s default deserializer is faster than a hand-rolled deserializer. Of course, there wasn’t a choice for doing this in the case of an inline class: it was either creating the custom deserializer or having the code crash.

Final Thoughts

While the tests provide promising results for the Kotlinx Serialization library, there are a few caveats that must be provided:

  • The example classes were relatively simple to reduce the amount of variables between testing scenarios. Conducting serialization and deserialization actions on large and complex data structures might provide entirely different results in favor of a different serialization library.
  • Due to the Kotlinx Serialization code being developed for the Kotlin programming language, code written in Java would have to be rewritten in Kotlin to use the library, something that might be a very time-consuming endeavor and a hard sell for a project that has a large code base written in Java. The other three libraries, on the other hand, have no such restriction and can be used with Java and Kotlin alike.

Regardless, the results suggest that it would behoove Kotlin developers to give the Kotlinx Serialization library a try in their projects, as aside from the high performance, it also provides the opportunity to be a “one-stop shop” for serialization not only for JSON but for other formats like Protobuf, YAML, and more.


Source link