Kotlin Serialization is a cross-platform serialization and deserialization library provided by Kotlin. It can serialize the object tree into several common formats, natively supporting Kotlin. It has strong extensibility and can satisfy almost all business scenarios. It doesn’t need to use reflection, and its performance is excellent. It can be said to be the best choice for Kotlin language serialization tools at present.
kotlinx.serialization
currently supports several formats:
- JSON: kotlinx-serialization-json
- Protocol Buffers: kotlinx-serialization-protobuf
- CBOR: kotlinx-serialization-cbor
- Properties: kotlinx-serialization-properties
- HOCON: kotlinx-serialization-hocon (only on JVM)
In most cases, we use JSON, so this article will mainly introduce the use of JSON.
Kotlin serialization is divided into two processes. The first step is to convert the object tree into a sequence composed of basic data types, and the second step is to encode and output this sequence according to the format.
Integration
Kotlin serialization tool is in a separate component.
It includes the following parts:
- Gradle compilation plugin: org.jetbrains.kotlin.plugin.serialization
- Runtime dependency library
First, you need to add the compilation plugin in gradle:
plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.23'
}
Then add the dependency library:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}
Moreover, kotlinx.serialization
has its own version, which is not synchronised with Kotlin. For specific version see here.
Json Encoding
The process of converting data into a specified format is known as encoding. For Kotlin serialization encoding, it is achieved using the extension function Json.encodeToString
.
@Serializable
class Project(val name: String, val language: String)
fun main() {
val data = Project("kotlinx.serialization", "Kotlin")
println(Json.encodeToString(data))
}
Kotlin serialization does not use reflection, so the class that supports serialization/deserialization should be marked with the @Serializable
annotation.
Json Decoding
The opposite process is called decoding. To decode a JSON string into an object, we will use the Json.decodeFromString
extension function. To specify the result type we want to obtain, we provide a type parameter to this function.
@Serializable
data class Project(val name: String, val language: String)
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":"Kotlin"}
""")
println(data)
}
@Serializable Annotation
This annotation is used to mark a class that can be serialized, and the serialization rules are as follows:
- Only properties with backing fields will participate in the serialization process. Proxy properties or properties with get/set will not participate.
- The parameters in the main constructor must be object properties
- For scenarios where data validation is required before serialization is completed, you can validate the parameters in the init block of the class.
Optional Properties
When deserializing a Json string into an object, if the Json string is missing a property in the class, the deserialization will fail. However, we can avoid this by adding a default value to the property.
@Serializable
data class Project(val name: String, val language: String = "Kotlin")
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization"}
""")
println(data)
}
@Required
The @Required annotation indicates that the property decorated with this annotation must be non-null during the deserialization process.
If a field has a default value and we expect the JSON input during deserialization to contain this property, we can use this annotation. After using it, if the property does not exist in JSON, the deserialization will fail.
@Transient
If a property does not need to be serialized, it can be decorated with this annotation, and this property must have a default value.
By default, if a property marked with @Transient
has a property with the same name in the serialized string, an error will be reported when deserializing into this object. You can use ignoreUnknownKeys = true
when building Json to avoid errors. The meaning of ignoreUnknownKeys
is to ignore the unknown fields in the serialized string. By default, it is false, so if the deserialized string contains extra fields, deserialization will not report an error.
Default values do not participate in serialization
If a property has a default value, and the value of this property in this object is also the default value, then this value will not participate in serialization.
@Serializable
data class User(val firstname: String, val lastname: String = "Zhang")
fun main() {
val data = User("Ke")
println(Json.encodeToString(data))
// output: {"firstname":"Ke"}
}
As above, because the language
property in the built data
is the default value, the serialized data does not include lastname
.
If you set a value for lastname
that is not equal to the default value, it will participate in serialization.
fun main() {
val data = User("Ke", "Li")
println(Json.encodeToString(data))
// Output: {"firstname":"Ke","lastname":"Li"}
}
Of course, there is also a way to circumvent this.
@EncodeDefault
This annotation is to solve the above problem, it can make the default value also participate in serialization.
In addition, the @EncodeDefault
annotation can adjust its behavior to the opposite by using the EncodeDefault.Mode
parameter.
Serial field names
Kotlin Serialization supports custom serialization and deserialization of property names, which is implemented through the @SerialName
annotation.
Enum
Kotlin Serialization supports enum classes, and it is not necessary to use the @Serializable
annotation on enum classes.
Of course, if you want to customize the property name after serialization, you can also add the @Serializable
annotation and set it through @SerialName
.
Pre-existing types
In addition to supporting basic types and String, Kotlin Serialization also pre-supports some composite data types.
- Pair
- Triple
- Array
- List
- Set
- Map
- Unit
- Singleton Class
- Duration
- Nothing
The serialization/deserialization of Unit and singleton class is the same. Since Unit is a singleton class by itself, their serialization contains an empty Json string.
Serializers/Serializers
As mentioned above, the process from object to basic data type is called serialization, and the serialization process is controlled by the serializer Serializer.
The several types of pre-supported serialization we introduced above provide the corresponding Serializer, or Serializer for basic types*.*
Basic type serializer
To get the basic type serializer, you can directly use the extension function.
val intSerializer: KSerializer<Int> = Int.serializer()
println(intSerializer.descriptor)
// output: PrimitiveDescriptor(kotlin.Int)
Pre-packaged serializers
To get the Kotlin pre-packaged serializers, you can use the top-level function serializer().
enum class Status { SUPPORTED }
val pairSerializer: KSerializer<Pair<Int, Int>> = serializer()
val statusSerializer: KSerializer<Status> = serializer()
println(pairSerializer.descriptor)
println(statusSerializer.descriptor)
// output:
// kotlin.Pair(first: kotlin.Int, second: kotlin.Int)
// com.zhangke.algorithms.Status(SUPPORTED)
The serializer()
function accepts a generic parameter to get the Serializer of the specified type, so in fact, we can get the Serializer of all serializable classes through this function.
Serializer generated by the compiler plugin
After adding the @Serializable
annotation to a class, the compiler plugin will automatically generate the corresponding Serializer, which can be obtained through the extension function of this class object.
@Serializable
class User(val name: String)
val userSerializer:KSerializer<User> = User.serializer()
println(userSerializer.descriptor)
// output: com.zhangke.algorithms.User(name: kotlin.String)
Serializer of the generic class generated by the compiler plugin
For generic classes that support serialization, the generic type also needs to support serialization. The serializer()
function of a generic class needs to pass in the Serializer of the generic type.
@Serializable
class Box<T>(val contents: T)
val userSerializer:KSerializer<Box<User>> = Box.serializer(User.serializer())
println(userSerializer.descriptor)
// output: com.zhangke.algorithms.Box(contents: com.zhangke.algorithms.User)
Collection type Serializer
The Serializer of the collection type is in these three types: ListSerializer() / MapSerializer() / SetSerializer(). You also need to pass parameters in the same way as a generic class for the Serializer of collection types.
val stringListSerializer: KSerializer<List<String>> = ListSerializer(String.serializer())
println(stringListSerializer.descriptor)
Custom Serializer
In most cases, we just need to use the annotations and preset Serializer. But sometimes we may want to control the serialization process or serialize some classes that cannot add annotations. Then we need to use a custom Serializer.
To customize the Serializer, we need to create a class that implements the KSerializer interface.
class Color(val rgb: Int)
object ColorAsStringSerializer : KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Color) {
val string = value.rgb.toString(16).padStart(6, '0')
encoder.encodeString(string)
}
override fun deserialize(decoder: Decoder): Color {
val string = decoder.decodeString()
return Color(string.toInt(16))
}
}
The overridden descriptor
property is the descriptor of this Serializer. You can choose to implement SerialDescriptor
by yourself, or create a basic type PrimitiveSerialDescriptor
as above.
Then the other two functions are self-evident, they are serialization and deserialization respectively.
Delegated Serializer
The Serializer can delegate the serialization/deserialization process to other Serializer to complete. For example, we can first convert Color into an integer array, and then delegate it to IntArraySerializer
.
class ColorIntArraySerializer : KSerializer<Color> {
private val delegateSerializer = IntArraySerializer()
override val descriptor = SerialDescriptor("Color", delegateSerializer.descriptor)
override fun serialize(encoder: Encoder, value: Color) {
val data = intArrayOf(
(value.rgb shr 16) and 0xFF,
(value.rgb shr 8) and 0xFF,
value.rgb and 0xFF
)
encoder.encodeSerializableValue(delegateSerializer, data)
}
override fun deserialize(decoder: Decoder): Color {
val array = decoder.decodeSerializableValue(delegateSerializer)
return Color((array[0] shl 16) or (array[1] shl 8) or array[2])
}
}
After the Serializer is created, it needs to be used according to different scenarios.
Directly set on the class
@Serializable(with = ColorAsStringSerializer::class)
class Color(val rgb: Int)
Set on attributes
@Serializable
class Table(
@Serializable(with = ColorAsStringSerializer::class) val color: Color
)
Use during serialization
Pass the Serializer as the first parameter to the Json.encodeToString
function.
Json.encodeToString(ColorAsStringSerializer, Color(0xFF0000))
Set Serializer for generics
The @Serializable annotation can be used for generic types.
@Serializable
class ProgrammingLanguage(
val name: String,
val releaseDates: List<@Serializable(DateAsLongSerializer::class) Date>
)
Specify Serializer for file
Kotlin also allows Serializer to be set for file.
@file:UseSerializers(DateAsLongSerializer::class)
With this setting, the classes in this file will automatically apply this Serializer.
Contextual serialization
Until now, the serialization process we have above is all static. But sometimes, we need to dynamically select different Serializer for the same class.
For example, we hope that the Date class should be serialized into different standard strings, and at the same time, this depends on different interface versions. We only know which standard Serializer should be used at runtime.
In this case, we don’t need to set a specific Serializer for Date first, but add a @Contextual tag, which means this property will decide which Serializer to use based on the context in Json.
@Serializable
class ProgrammingLanguage(
val name: String,
@Contextual
val stableReleaseDate: Date
)
In order to provide context, we need to create a SerializersModule instance. It describes which Serializer should be used to serialize those classes marked with Contextual at runtime.
private val module = SerializersModule {
contextual(Date::class, DateAsLongSerializer)
}
val format = Json { serializersModule = module }
Now, the context module information is stored in the format
object. As long as this format
object is used, the Date class above will use DateAsLongSerializer
serialization.
After setting, you can use the format
object for serialization.
fun main() {
val data = ProgrammingLanguage("Kotlin", SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00"))
println(format.encodeToString(data))
}
Serialization of Polymorphic Classes
For the polymorphism of classes, which refers to classes with inheritance relationships, there are some issues encountered during serialization. Kotlin Serialization provides some solutions for these issues too.
Job Offers
Sealed Classes
One solution is to use a sealed class. Kotlin serialization supports sealed class serialization, all we need to do is to add the @Serializable
annotation.
@Serializable
sealed class Project {
abstract val name: String
}
@Serializable
class OwnedProject(override val name: String, val owner: String) : Project()
fun main() {
val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
println(Json.encodeToString(data)) // Serializing data of compile-time type Project
}
// output: {"type":"com.zhangke.OwnedProject","name":"kotlinx.coroutines","owner":"kotlin"}
As we can see, to solve the serialization issue of polymorphic classes, Kotlin add a type
field in the serialized content to represent the specific type of the object. During deserialization, it creates the corresponding object based on this type.
Of course, the value of the type field can be customized. Sometimes, you might not want to use the default package name. You can choose to customize it.
@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()
Registering Subclasses
Besides using a sealed class
, you can also serialize polymorphic classes by registering subclasses.
To register a subclass means that you need to build a serialization Module in Json and provide the correlation between the interface and subclasses. This tips the serializer to which subclasses to choose for serialization.
@Serializable
abstract class Project {
abstract val name: String
}
@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()
@Serializable
class JavaProject(override val name: String): Project()
val module = SerializersModule {
polymorphic(
baseClass = Project::class,
actualClass = OwnedProject::class,
actualSerializer = serializer(),
)
polymorphic(
baseClass = Project::class,
actualClass = JavaProject::class,
actualSerializer = serializer(),
)
}
val format = Json { serializersModule = module }
fun main() {
val list = listOf(
OwnedProject("kotlinx.coroutines", "kotlin"),
JavaProject("sun.java")
)
println(format.encodeToString(list))
}
// output: [{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"},{"type":"com.zhangke.algorithms.JavaProject","name":"sun.java"}]
Interface Serialization
The way of registering subclasses can deal with the scenario of abstract classes, but interfaces still can’t be serialized because the @Serializable
annotation is not allowed to be added on the interface. However, Kotlin serialization uses PolymorphicSerializer
strategy to implicitly serialize the interface. It indicates that we do not need to add the @Serializable
annotation to the interface. We just need to register the subclasses the same way we did before.
interface Project {
val name: String
}
@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project
@Serializable
class JavaProject(override val name: String) : Project
val module = SerializersModule {
polymorphic(
baseClass = Project::class,
actualClass = OwnedProject::class,
actualSerializer = serializer(),
)
polymorphic(
baseClass = Project::class,
actualClass = JavaProject::class,
actualSerializer = serializer(),
)
}
val format = Json { serializersModule = module }
fun main() {
val list = listOf(
OwnedProject("kotlinx.coroutines", "kotlin"),
JavaProject("sun.java")
)
println(format.encodeToString(list))
}
// output: [{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"},{"type":"com.zhangke.algorithms.JavaProject","name":"sun.java"}]
Json Configuration
Previously, we discussed the role of the serializer as part of the serialization process. Now, let’s start talking about the encoding process, specifically in regards to Json encoding.
Json encoding/decoding is performed through the Json class in kotlinx. We can directly call Json to get a globally unique standard Json object, or we can construct a Json object of our own.
Since there may be internal caching within the Json class, considering potential performance issues, it is recommended to save and reuse built Json objects, and avoid creating a new one for each use.
Output Formatting
By default, Json outputs as a single-line string. However, you can make it output in a more readable Json format by setting prettyPrint
to true
.
val format = Json { prettyPrint = true }
Now it will output a tidy Json string:
{
"name": "kotlinx.serialization",
"language": "Kotlin"
}
Lenient Parsing
By default, Json will parse Json according to strict standards, such as mandatory quotation marks for keys, restrictions for integer and string types, etc. However, lenient mode can be activated by setting isLenient = true
.
In this mode, quoted values can be parsed as integers if their corresponding type in Kotlin object is an integer, and keys can also be left unquoted.
Ignoring Unknown Keys
By default, Json will return an error if it encounters unknown keys during the deserialization process. For instance, If there is an ‘id’ field in the Json string but no ‘id’ attribute in the target class being deserialized, an error will be triggered.
By setting ignoreUnknownKeys = true
, these errors can be avoided, allowing for normal parsing.
Replacing Json Names
As we mentioned above, @SerialName
can be used to set a name for a field in Json. However, using it means the field’s original name can no longer be parsed. To resolve this issue, you can use the @JsonNames
annotation. It allows for multiple names to be set for a field, and the field’s original name can still be parsed.
@Serializable
data class Project(@JsonNames("title") val name: String)
fun main() {
val project = Json.decodeFromString<Project>("""{"name":"kotlinx.serialization"}""")
println(project)
val oldProject = Json.decodeFromString<Project>("""{"title":"kotlinx.coroutines"}""")
println(oldProject)
}
Also, support for the @JsonNames
annotation is controlled by the JsonBuilder.useAlternativeNames
flag. Unlike most configuration flags, this flag is enabled by default.
Forcing Use of Default Values
By using coerceInputValues = true
, certain invalid inputs can be converted to default values.
Currently, only the following two types of invalid input are supported:
- Null input for non-nullable values.
- Unknown values for enums.
This implies that during deserialization, if a certain field in a class has a default value and the corresponding field in the Json string satisfies one of the two conditions above, the property’s default value will be used for deserialization.
val format = Json { coerceInputValues = true }
@Serializable
data class Project(val name: String, val language: String = "Kotlin")
fun main() {
val data = format.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":null}
""")
println(data)
}
// output: Project(name=kotlinx.serialization, language=Kotlin)
Displaying Null Values
By default, null values will also be encoded into Json. By setting explicitNulls = false
, you can prevent null values from being serialized into Json.
Structured Json Keys
JSON format itself does not support structured keys, which generally can only be strings.
However, nonstandard support for structured keys can be enabled through the allowStructuredMapKeys = true
property.
val format = Json { allowStructuredMapKeys = true }
@Serializable
data class Project(val name: String)
fun main() {
val map = mapOf(
Project("kotlinx.serialization") to "Serialization",
Project("kotlinx.coroutines") to "Coroutines"
)
println(format.encodeToString(map))
}
Maps with structured keys are represented as JSON arrays containing the following items: [key1, value1, key2, value2,...]
.
[{"name":"kotlinx.serialization"},"Serialization",{"name":"kotlinx.coroutines"},"Coroutines"]
Others
Furthermore, additional settings are available in Json, suitable for a rich variety of scenarios.
allowSpecialFloatingPointValues
– Using special floating-point values like NaN and infinity.classDiscriminator
– Setting the key name of the types for polymorphic data.decodeEnumsCaseInsensitive
– Decoding enums in a case-insensitive manner.namingStrategy
– Global naming strategy. UsingJsonNamingStrategy.SnakeCase
can parse fields from camel case to snake case.
Json Element
As well as being a tool for encoding/decoding, Json also exposes its internal JsonElement classes and tools for use.
The JsonElement object can be parsed using the Json.parseToJsonElement
function.
The content within the JsonElement class is almost identical to that in Gson and most other Json tools, so we won’t elaborate on them here.
Json Element Builder
Json provides several DSLs to build JsonElement.
fun main() {
val element = buildJsonObject {
put("name", "kotlinx.serialization")
putJsonObject("owner") {
put("name", "kotlin")
}
putJsonArray("forks") {
addJsonObject {
put("votes", 42)
}
addJsonObject {
put("votes", 9000)
}
}
}
println(element)
}
Then, the Json.decodeFromJsonElement
function can be used to deserialize it directly into an object.
@Serializable
data class Project(val name: String, val language: String)
fun main() {
val element = buildJsonObject {
put("name", "kotlinx.serialization")
put("language", "Kotlin")
}
val data = Json.decodeFromJsonElement<Project>(element)
println(data)
}
Json Transformations
Json offers the capability of customizing the encoding/decoding of Json data, which can affect the resultant Json content after serialization.
For example, let’s consider a User class. We would like to have a ‘time’ field in the serialized Json data that denotes the time of serialization.
@Serializable
class User(
val name: String,
val age: Int,
)
object UserSerializer : JsonTransformingSerializer<User>(User.serializer()) {
override fun transformSerialize(element: JsonElement): JsonElement {
return buildJsonObject {
element.jsonObject.forEach { key, value ->
put(key, value)
}
put("time", System.currentTimeMillis())
}
}
}
fun main() {
val user = User("zhangke", 18)
println(format.encodeToString(UserSerializer, user))
}
// output: {"name":"zhangke","age":18,"time":1711556475153}
In the above example, we first define UserSerializer. Then, in the returned JsonObject, we first add the original fields and then add a time field.
That’s it for now. More detailed content can be accessed on the official website.
This article is previously published on proandroiddev.com