-
Notifications
You must be signed in to change notification settings - Fork 9
Add kotlin.serialization
implementation of Serializer
#124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
87ee5ff
43c5d0e
4bf4ddf
0af12e5
2b7735b
1256d2d
f7d4ee9
798b3fd
edb6903
b8fbd02
c113040
7acce4c
aa6804f
20479a8
e0f411a
8d7b70d
34844c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
/* | ||
* Copyright (c) 2010-2021. Axon Framework | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.axonframework.extensions.kotlin.serialization | ||
|
||
import kotlinx.serialization.KSerializer | ||
import kotlinx.serialization.SerializationException | ||
import kotlinx.serialization.json.Json | ||
import kotlinx.serialization.json.JsonElement | ||
import kotlinx.serialization.json.JsonNull | ||
import org.axonframework.common.ObjectUtils | ||
import org.axonframework.serialization.AnnotationRevisionResolver | ||
import org.axonframework.serialization.ChainingConverter | ||
import org.axonframework.serialization.Converter | ||
import org.axonframework.serialization.RevisionResolver | ||
import org.axonframework.serialization.SerializedObject | ||
import org.axonframework.serialization.SerializedType | ||
import org.axonframework.serialization.Serializer | ||
import org.axonframework.serialization.SimpleSerializedObject | ||
import org.axonframework.serialization.SimpleSerializedType | ||
import org.axonframework.serialization.UnknownSerializedType | ||
import kotlin.reflect.full.companionObject | ||
import kotlin.reflect.full.companionObjectInstance | ||
|
||
/** | ||
* Implementation of Axon Serializer that uses a kotlinx.serialization implementation. | ||
* The serialized format is JSON. | ||
* | ||
* The DSL function kotlinSerializer can be used to easily configure the parameters | ||
* for this serializer. | ||
* | ||
* @see kotlinx.serialization.Serializer | ||
* @see org.axonframework.serialization.Serializer | ||
* @see kotlinSerializer | ||
*/ | ||
class KotlinSerializer( | ||
private val revisionResolver: RevisionResolver, | ||
hiddewie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private val converter: Converter, | ||
private val json: Json | ||
) : Serializer { | ||
|
||
private val serializerCache: MutableMap<Class<*>, KSerializer<*>> = mutableMapOf() | ||
|
||
override fun <T> serialize(value: Any?, expectedRepresentation: Class<T>): SerializedObject<T> { | ||
try { | ||
val type = ObjectUtils.nullSafeTypeOf(value) | ||
return when { | ||
expectedRepresentation.isAssignableFrom(String::class.java) -> | ||
SimpleSerializedObject( | ||
(if (value == null) "null" else json.encodeToString(type.serializer(), value)) as T, | ||
expectedRepresentation, | ||
typeForClass(type) | ||
) | ||
|
||
expectedRepresentation.isAssignableFrom(JsonElement::class.java) -> | ||
SimpleSerializedObject( | ||
(if (value == null) JsonNull else json.encodeToJsonElement(type.serializer(), value)) as T, | ||
expectedRepresentation, | ||
typeForClass(type) | ||
) | ||
|
||
else -> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would've assumed the Several spots in the framework assume that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pretty confident this will require introducing the Furthermore, I assume this will warrant introducing content type converters going from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I totally forgot about the (extra) converters. I will add some defaults to convert between byte arrays <-> Strings and JSON Nodes 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made the By default String, Byte array, Json Element and InputStream already work through service discovery, so that is fine. If any other converters are needed I can add them. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for validating @hiddewie 👍 |
||
throw org.axonframework.serialization.SerializationException("Cannot serialize type $type to representation $expectedRepresentation. String and JsonElement are supported.") | ||
} | ||
} catch (ex: SerializationException) { | ||
throw org.axonframework.serialization.SerializationException("Cannot serialize type ${value?.javaClass?.name} to representation $expectedRepresentation.", ex) | ||
} | ||
} | ||
|
||
override fun <T> canSerializeTo(expectedRepresentation: Class<T>): Boolean = | ||
expectedRepresentation == String::class.java || | ||
hiddewie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
expectedRepresentation == JsonElement::class.java | ||
|
||
override fun <S, T> deserialize(serializedObject: SerializedObject<S>?): T? { | ||
try { | ||
if (serializedObject == null) { | ||
return null | ||
} | ||
|
||
if (serializedObject.type == SerializedType.emptyType()) { | ||
return null | ||
} | ||
|
||
val serializer: KSerializer<T> = serializedObject.serializer() | ||
return when { | ||
serializedObject.contentType.isAssignableFrom(String::class.java) -> | ||
hiddewie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
json.decodeFromString(serializer, serializedObject.data as String) | ||
|
||
serializedObject.contentType.isAssignableFrom(JsonElement::class.java) -> | ||
json.decodeFromJsonElement(serializer, serializedObject.data as JsonElement) | ||
|
||
else -> | ||
hiddewie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
throw org.axonframework.serialization.SerializationException("Cannot deserialize from content type ${serializedObject.contentType} to type ${serializedObject.type}. String and JsonElement are supported.") | ||
} | ||
} catch (ex: SerializationException) { | ||
throw org.axonframework.serialization.SerializationException( | ||
"Could not deserialize from content type ${serializedObject?.contentType} to type ${serializedObject?.type}", | ||
ex | ||
) | ||
} | ||
} | ||
|
||
private fun <S, T> SerializedObject<S>.serializer(): KSerializer<T> = | ||
classForType(type).serializer() as KSerializer<T> | ||
|
||
/** | ||
* When a type is compiled by the Kotlin compiler extension, a companion object | ||
* is created which contains a method `serializer()`. This method should be called | ||
* to get the serializer of the class. | ||
* | ||
* In a 'normal' serialization environment, you would call the MyClass.serializer() | ||
* method directly. Here we are in a generic setting, and need reflection to call | ||
* the method. | ||
* | ||
* This method caches the reflection mapping from class to serializer for efficiency. | ||
*/ | ||
private fun <T> Class<T>.serializer(): KSerializer<T> = | ||
serializerCache.computeIfAbsent(this) { | ||
// Class<T>: T must be non-null | ||
val kClass = (this as Class<Any>).kotlin | ||
|
||
val companion = kClass.companionObject | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume that the hard requirement on the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the toughest question :) As far as I can see, there are two solutions:
To illustrate option 2, I added a small example and custom serializer with test. The third option is abandoning this approach because the Kotlin serialization is not wanted in the core framework, and it is not worth the effort to maintain the custom serializers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (After thinking about option 1 a bit more: I have not tested adding There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the insights here. In the meantime, I'll start an internal discussion about whether we want to add the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried a few things to test how the interoperability of Java classes with Kotlin annotations, and the Kotlin annotation processors work together. What I did:
What I saw:
What I did:
What I saw:
What I did:
What I saw:
So my conclusion is that the Kotlin serialization compiler plugin only works for Kotlin source files, and that the That is very unfortunate, I might open an issue with the Kotlin Serialization team to ask if that is supposed to work (ref Kotlin/kotlinx.serialization#1687). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Either way, we can not assume that every class that comes here will have a serializer attached to it (nor be a Kotlin class) If there is no serializer defined, or just based on types, we can fall back on a registry that we can keep as a map of Type (Class, Class name, etc) and a Serializer. That map could be configurable so someone might add support for classes without This and |
||
?: throw SerializationException("Class $this has no companion object. Did you mark it as @Serializable?") | ||
|
||
val serializerMethod = companion.java.getMethod("serializer") | ||
?: throw SerializationException("Class $this has no serializer() method. Did you mark it as @Serializable?") | ||
|
||
serializerMethod.invoke(kClass.companionObjectInstance) as KSerializer<*> | ||
} as KSerializer<T> | ||
|
||
override fun classForType(type: SerializedType): Class<*> = | ||
if (SerializedType.emptyType() == type) { | ||
Void.TYPE | ||
} else { | ||
try { | ||
Class.forName(type.name) | ||
} catch (e: ClassNotFoundException) { | ||
UnknownSerializedType::class.java | ||
} | ||
} | ||
|
||
override fun typeForClass(type: Class<*>?): SerializedType = | ||
if (type == null || Void.TYPE == type || Void::class.java == type) { | ||
SimpleSerializedType.emptyType() | ||
} else { | ||
SimpleSerializedType(type.name, revisionResolver.revisionOf(type)) | ||
} | ||
|
||
private fun revisionOf(type: Class<*>): String? = | ||
hiddewie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
revisionResolver.revisionOf(type) | ||
|
||
override fun getConverter(): Converter = | ||
converter | ||
|
||
} | ||
|
||
/** | ||
* Configuration which will be used to construct a KotlinSerializer. | ||
* This class is used in the kotlinSerializer DSL function. | ||
* | ||
* @see KotlinSerializer | ||
* @see kotlinSerializer | ||
*/ | ||
class KotlinSerializerConfiguration { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the driving force to use this format instead of a constructor with default values? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These properties are all There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Honestly, I wouldn't know what's more "Kotlin-like". If I'd look at the other Axon components, the settings are done through the Builder of a piece of infrastructure. I'd wager that such a separate config object does not align very well with that idea. @sandjelkovic, what's your opinion on this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I removed this Configuration class, and used the constructor with default values. It is no problem to add it back later if it is needed. The Configuration class could be seen as a builder pattern for a Kotlin-style DSL. The Configuration class contains the same properties as the constructor, and all mutable. In the DSL builder function you could configure the Configuration class (or builder, however you want to call it), and that will then invoke the constructor of the actual (immutable) service. |
||
var revisionResolver: RevisionResolver = AnnotationRevisionResolver() | ||
var converter: Converter = ChainingConverter() | ||
var json: Json = Json | ||
} | ||
|
||
/** | ||
* DSL function to configure a new KotlinSerializer. | ||
* | ||
* Usage example: | ||
* <code> | ||
* val serializer: KotlinSerializer = kotlinSerializer { | ||
* json = Json | ||
* converter = ChainingConverter() | ||
* revisionResolver = AnnotationRevisionResolver() | ||
* } | ||
* </code> | ||
* | ||
* @see KotlinSerializer | ||
*/ | ||
fun kotlinSerializer(init: KotlinSerializerConfiguration.() -> Unit = {}): KotlinSerializer { | ||
val configuration = KotlinSerializerConfiguration() | ||
configuration.init() | ||
return KotlinSerializer( | ||
configuration.revisionResolver, | ||
configuration.converter, | ||
configuration.json, | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
package org.axonframework.extensions.kotlin.serialization | ||
|
||
import kotlinx.serialization.Serializable | ||
import kotlinx.serialization.json.Json | ||
import kotlinx.serialization.json.JsonElement | ||
import org.axonframework.serialization.AnnotationRevisionResolver | ||
import org.axonframework.serialization.ChainingConverter | ||
import org.axonframework.serialization.SerializedType | ||
import org.axonframework.serialization.SimpleSerializedObject | ||
import org.junit.jupiter.api.Assertions.assertEquals | ||
import org.junit.jupiter.api.Assertions.assertTrue | ||
import org.junit.jupiter.api.Test | ||
import kotlin.test.assertNotNull | ||
import kotlin.test.assertNull | ||
|
||
class KotlinSerializerTest { | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am missing test cases that validate if the Assuming that's because those objects aren't annotated with the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indeed. See https://github.com/AxonFramework/extension-kotlin/pull/124/files#r645048334 for the comment about serializing internal classes. I added one test case for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gotcha. To make this Making these can wait until we've concluded the conversation on the |
||
/** | ||
* This class will automatically become serializable through the Kotlin serialization compiler plugin. | ||
*/ | ||
@Serializable | ||
data class TestData( | ||
val name: String, | ||
val value: Float? | ||
) | ||
|
||
@Test | ||
fun canSerializeTo() { | ||
val serializer = kotlinSerializer() | ||
|
||
assertTrue(serializer.canSerializeTo(String::class.java)) | ||
assertTrue(serializer.canSerializeTo(JsonElement::class.java)) | ||
} | ||
|
||
@Test | ||
fun `configuration options`() { | ||
val serializer = kotlinSerializer { | ||
json = Json | ||
converter = ChainingConverter() | ||
revisionResolver = AnnotationRevisionResolver() | ||
} | ||
assertNotNull(serializer) | ||
} | ||
|
||
@Test | ||
fun serialize() { | ||
val serializer = kotlinSerializer() | ||
|
||
val emptySerialized = serializer.serialize(TestData("", null), String::class.java) | ||
assertEquals("SimpleSerializedType[org.axonframework.extensions.kotlin.serialization.KotlinSerializerTest\$TestData] (revision null)", emptySerialized.type.toString()) | ||
assertEquals("""{"name":"","value":null}""", emptySerialized.data) | ||
assertEquals(String::class.java, emptySerialized.contentType) | ||
|
||
val filledSerialized = serializer.serialize(TestData("name", 1.23f), String::class.java) | ||
assertEquals("SimpleSerializedType[org.axonframework.extensions.kotlin.serialization.KotlinSerializerTest\$TestData] (revision null)", filledSerialized.type.toString()) | ||
assertEquals("""{"name":"name","value":1.23}""", filledSerialized.data) | ||
assertEquals(String::class.java, filledSerialized.contentType) | ||
|
||
val nullSerialized = serializer.serialize(null, String::class.java) | ||
assertEquals("null", nullSerialized.data) | ||
assertEquals(String::class.java, nullSerialized.contentType) | ||
} | ||
|
||
@Test | ||
fun deserialize() { | ||
val serializer = kotlinSerializer() | ||
|
||
val nullDeserialized: Any? = serializer.deserialize(SimpleSerializedObject( | ||
"", | ||
String::class.java, | ||
SerializedType.emptyType() | ||
)) | ||
assertNull(nullDeserialized) | ||
|
||
val emptyDeserialized: Any? = serializer.deserialize(SimpleSerializedObject( | ||
"""{"name":"","value":null}""", | ||
String::class.java, | ||
TestData::class.java.name, | ||
null | ||
)) | ||
assertNotNull(emptyDeserialized as TestData) | ||
assertEquals(emptyDeserialized.name, "") | ||
assertEquals(emptyDeserialized.value, null) | ||
|
||
val filledDeserialized: Any? = serializer.deserialize(SimpleSerializedObject( | ||
"""{"name":"name","value":1.23}""", | ||
String::class.java, | ||
TestData::class.java.name, | ||
null | ||
)) | ||
assertNotNull(filledDeserialized as TestData) | ||
assertEquals(filledDeserialized.name, "name") | ||
assertEquals(filledDeserialized.value, 1.23f) | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.