Skip to content

Commit c33b976

Browse files
authored
Merge pull request #164 from ProjectMapK/fix/cache
Combined the cache LRUMap into one and changed it so that both the initial and maximum sizes can be specified.
2 parents e375b27 + 39bf2de commit c33b976

File tree

9 files changed

+238
-71
lines changed

9 files changed

+238
-71
lines changed

src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinModule.kt

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,30 @@ import io.github.projectmapk.jackson.module.kogera.ser.serializers.KotlinSeriali
2020
import java.util.*
2121

2222
/**
23-
* @param reflectionCacheSize Default: 512. Size, in items, of the caches used for mapping objects.
24-
* @param nullToEmptyCollection Default: false. Whether to deserialize null values for collection properties as
25-
* empty collections.
26-
* @param nullToEmptyMap Default: false. Whether to deserialize null values for a map property to an empty
27-
* map object.
28-
* @param nullIsSameAsDefault Default false. Whether to treat null values as absent when deserializing, thereby
29-
* using the default value provided in Kotlin.
30-
* @param singletonSupport Default: DISABLED. Mode for singleton handling.
31-
* See {@link io.github.projectmapk.jackson.module.kogera.SingletonSupport label}
32-
* @param strictNullChecks Default: false. Whether to check deserialized collections. With this disabled,
33-
* the default, collections which are typed to disallow null members
34-
* (e.g. List<String>) may contain null values after deserialization. Enabling it
35-
* protects against this but has significant performance impact.
36-
* @param useJavaDurationConversion Default: false. Whether to use [java.time.Duration] as a bridge for [kotlin.time.Duration].
37-
* This allows use Kotlin Duration type with [com.fasterxml.jackson.datatype.jsr310.JavaTimeModule].
23+
* @param initialCacheSize
24+
* Default: [Builder.DEFAULT_CACHE_SIZE]. See [Builder.withInitialCacheSize] for details.
25+
* @param maxCacheSize
26+
* Default: [Builder.DEFAULT_CACHE_SIZE]. See [Builder.withMaxCacheSize] for details.
27+
* @param nullToEmptyCollection
28+
* Default: false. See [KotlinFeature.NullToEmptyCollection] for details.
29+
* @param nullToEmptyMap
30+
* Default: false. See [KotlinFeature.NullToEmptyMap] for details.
31+
* @param nullIsSameAsDefault
32+
* Default: false. See [KotlinFeature.NullIsSameAsDefault] for details.
33+
* @param singletonSupport
34+
* Default: false. See [KotlinFeature.SingletonSupport] for details.
35+
* @param strictNullChecks
36+
* Default: false. See [KotlinFeature.StrictNullChecks] for details.
37+
* @param copySyntheticConstructorParameterAnnotations
38+
* Default false. See [KotlinFeature.CopySyntheticConstructorParameterAnnotations] for details.
39+
* @param useJavaDurationConversion
40+
* Default: false. See [KotlinFeature.UseJavaDurationConversion] for details.
3841
*/
3942
// Do not delete default arguments,
4043
// as this will cause an error during initialization by Spring's Jackson2ObjectMapperBuilder.
4144
public class KotlinModule private constructor(
42-
public val reflectionCacheSize: Int = Builder.DEFAULT_CACHE_SIZE,
45+
public val initialCacheSize: Int = Builder.DEFAULT_CACHE_SIZE,
46+
public val maxCacheSize: Int = Builder.DEFAULT_CACHE_SIZE,
4347
public val nullToEmptyCollection: Boolean = NullToEmptyCollection.enabledByDefault,
4448
public val nullToEmptyMap: Boolean = NullToEmptyMap.enabledByDefault,
4549
public val nullIsSameAsDefault: Boolean = NullIsSameAsDefault.enabledByDefault,
@@ -50,7 +54,8 @@ public class KotlinModule private constructor(
5054
public val useJavaDurationConversion: Boolean = UseJavaDurationConversion.enabledByDefault
5155
) : SimpleModule(KotlinModule::class.java.name, kogeraVersion) { // kogeraVersion is generated by building.
5256
private constructor(builder: Builder) : this(
53-
builder.reflectionCacheSize,
57+
builder.initialCacheSize,
58+
builder.maxCacheSize,
5459
builder.isEnabled(NullToEmptyCollection),
5560
builder.isEnabled(NullToEmptyMap),
5661
builder.isEnabled(NullIsSameAsDefault),
@@ -66,8 +71,26 @@ public class KotlinModule private constructor(
6671
)
6772
public constructor() : this(Builder())
6873

74+
init {
75+
checkMaxCacheSize(maxCacheSize)
76+
checkCacheSize(initialCacheSize, maxCacheSize)
77+
}
78+
6979
public companion object {
70-
private const val serialVersionUID = 1L
80+
@Suppress("ConstPropertyName")
81+
private const val serialVersionUID = 2L
82+
83+
private fun checkMaxCacheSize(maxCacheSize: Int) {
84+
if (maxCacheSize < 16) throw IllegalArgumentException("16 or higher must be specified")
85+
}
86+
87+
private fun checkCacheSize(initialCacheSize: Int, maxCacheSize: Int) {
88+
if (maxCacheSize < initialCacheSize) {
89+
throw IllegalArgumentException(
90+
"maxCacheSize($maxCacheSize) was less than initialCacheSize($initialCacheSize)."
91+
)
92+
}
93+
}
7194
}
7295

7396
override fun setupModule(context: SetupContext) {
@@ -79,7 +102,7 @@ public class KotlinModule private constructor(
79102
)
80103
}
81104

82-
val cache = ReflectionCache(reflectionCacheSize)
105+
val cache = ReflectionCache(initialCacheSize, maxCacheSize)
83106

84107
context.addValueInstantiators(
85108
KotlinInstantiators(cache, nullToEmptyCollection, nullToEmptyMap, nullIsSameAsDefault)
@@ -115,13 +138,35 @@ public class KotlinModule private constructor(
115138
internal const val DEFAULT_CACHE_SIZE = 512
116139
}
117140

118-
public var reflectionCacheSize: Int = DEFAULT_CACHE_SIZE
141+
public var initialCacheSize: Int = DEFAULT_CACHE_SIZE
142+
private set
143+
public var maxCacheSize: Int = DEFAULT_CACHE_SIZE
119144
private set
120145

121146
private val bitSet: BitSet = KotlinFeature.defaults
122147

123-
public fun withReflectionCacheSize(reflectionCacheSize: Int): Builder = apply {
124-
this.reflectionCacheSize = reflectionCacheSize
148+
/**
149+
* @throws IllegalArgumentException A value less than [maxCacheSize] was entered for [initialCacheSize].
150+
*/
151+
public fun withInitialCacheSize(initialCacheSize: Int): Builder = apply {
152+
checkCacheSize(initialCacheSize, maxCacheSize)
153+
154+
this.initialCacheSize = initialCacheSize
155+
}
156+
157+
/**
158+
* Kogera's internal processing requires a certain cache size.
159+
* The lower limit of [maxCacheSize] is set to 16 for extreme use cases,
160+
* but it is recommended to set it to 100 or more unless there is a very clear reason.
161+
*
162+
* @throws IllegalArgumentException Specified size was less than the 16.
163+
* @throws IllegalArgumentException A value less than [initialCacheSize] was entered for maxCacheSize.
164+
*/
165+
public fun withMaxCacheSize(maxCacheSize: Int): Builder = apply {
166+
checkMaxCacheSize(maxCacheSize)
167+
checkCacheSize(initialCacheSize, maxCacheSize)
168+
169+
this.maxCacheSize = maxCacheSize
125170
}
126171

127172
public fun enable(feature: KotlinFeature): Builder = apply {
Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,53 @@
11
package io.github.projectmapk.jackson.module.kogera
22

3-
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod
43
import com.fasterxml.jackson.databind.util.LRUMap
54
import java.io.Serializable
65
import java.lang.reflect.Method
76
import java.util.Optional
87

9-
internal class ReflectionCache(reflectionCacheSize: Int) : Serializable {
8+
// For ease of testing, maxCacheSize is limited only in KotlinModule.
9+
internal class ReflectionCache(initialCacheSize: Int, maxCacheSize: Int) : Serializable {
1010
companion object {
1111
// Increment is required when properties that use LRUMap are changed.
1212
@Suppress("ConstPropertyName")
13-
private const val serialVersionUID = 3L
13+
private const val serialVersionUID = 4L
1414
}
1515

16-
// This cache is used for both serialization and deserialization, so reserve a larger size from the start.
17-
private val classCache = LRUMap<Class<*>, JmClass>(reflectionCacheSize, reflectionCacheSize)
16+
/**
17+
* For frequently used JmClass and BoxedReturnType, reduce overhead by using Class and Method directly as key.
18+
* For other caches, if the key type overlaps, wrap it.
19+
*/
20+
private sealed class OtherCacheKey<K : Any, V : Any> {
21+
abstract val key: K
1822

19-
// Initial size is 0 because the value class is not always used
20-
private val valueClassReturnTypeCache: LRUMap<Method, Optional<Class<*>>> =
21-
LRUMap(0, reflectionCacheSize)
23+
// The comparison was implemented directly because the decompiled results showed subtle efficiency.
2224

23-
// TODO: Consider whether the cache size should be reduced more,
24-
// since the cache is used only twice locally at initialization per property.
25-
private val valueClassBoxConverterCache: LRUMap<Class<*>, ValueClassBoxConverter<*, *>> =
26-
LRUMap(0, reflectionCacheSize)
27-
private val valueClassUnboxConverterCache: LRUMap<Class<*>, ValueClassUnboxConverter<*>> =
28-
LRUMap(0, reflectionCacheSize)
25+
final override fun equals(other: Any?): Boolean =
26+
(other as? OtherCacheKey<*, *>)?.let { it::class == this::class && it.key == key } ?: false
27+
28+
// If the hashCode matches the raw key, the search efficiency is reduced, so it is displaced.
29+
final override fun hashCode(): Int = key.hashCode() * 31
30+
final override fun toString(): String = key.toString()
31+
32+
class ValueClassBoxConverter(
33+
override val key: Class<*>
34+
) : OtherCacheKey<Class<*>, io.github.projectmapk.jackson.module.kogera.ValueClassBoxConverter<*, *>>()
35+
class ValueClassUnboxConverter(
36+
override val key: Class<*>
37+
) : OtherCacheKey<Class<*>, io.github.projectmapk.jackson.module.kogera.ValueClassUnboxConverter<*>>()
38+
}
39+
40+
private val cache = LRUMap<Any, Any>(initialCacheSize, maxCacheSize)
41+
private fun <T : Any> find(key: Any): T? = cache[key]?.let {
42+
@Suppress("UNCHECKED_CAST")
43+
it as T
44+
}
45+
46+
@Suppress("UNCHECKED_CAST")
47+
private fun <T : Any> putIfAbsent(key: Any, value: T): T = cache.putIfAbsent(key, value) as T? ?: value
2948

3049
fun getJmClass(clazz: Class<*>): JmClass? {
31-
return classCache[clazz] ?: run {
50+
return find(clazz) ?: run {
3251
val metadata = clazz.getAnnotation(Metadata::class.java) ?: return null
3352

3453
// Do not parse super class for interfaces.
@@ -43,7 +62,7 @@ internal class ReflectionCache(reflectionCacheSize: Int) : Serializable {
4362
val interfaceJmClasses = clazz.interfaces.mapNotNull { getJmClass(it) }
4463

4564
val value = JmClass(clazz, metadata, superJmClass, interfaceJmClasses)
46-
(classCache.putIfAbsent(clazz, value) ?: value)
65+
putIfAbsent(clazz, value)
4766
}
4867
}
4968

@@ -56,34 +75,29 @@ internal class ReflectionCache(reflectionCacheSize: Int) : Serializable {
5675
}
5776

5877
// Return boxed type on Kotlin for unboxed getters
59-
fun findBoxedReturnType(getter: AnnotatedMethod): Class<*>? {
60-
val method = getter.member
61-
val optional = valueClassReturnTypeCache.get(method)
78+
fun findBoxedReturnType(getter: Method): Class<*>? {
79+
val optional = find<Optional<Class<*>>>(getter)
6280

6381
return if (optional != null) {
6482
optional
6583
} else {
6684
// If the return value of the getter is a value class,
6785
// it will be serialized properly without doing anything.
6886
// TODO: Verify the case where a value class encompasses another value class.
69-
if (method.returnType.isUnboxableValueClass()) return null
87+
if (getter.returnType.isUnboxableValueClass()) return null
7088

71-
val value = Optional.ofNullable(method.getValueClassReturnType())
72-
(valueClassReturnTypeCache.putIfAbsent(method, value) ?: value)
89+
val value = Optional.ofNullable(getter.getValueClassReturnType())
90+
putIfAbsent(getter, value)
7391
}.orElse(null)
7492
}
7593

76-
fun getValueClassBoxConverter(unboxedClass: Class<*>, valueClass: Class<*>): ValueClassBoxConverter<*, *> =
77-
valueClassBoxConverterCache.get(valueClass) ?: run {
78-
val value = ValueClassBoxConverter(unboxedClass, valueClass)
79-
80-
(valueClassBoxConverterCache.putIfAbsent(valueClass, value) ?: value)
81-
}
82-
83-
fun getValueClassUnboxConverter(valueClass: Class<*>): ValueClassUnboxConverter<*> =
84-
valueClassUnboxConverterCache.get(valueClass) ?: run {
85-
val value = ValueClassUnboxConverter(valueClass)
94+
fun getValueClassBoxConverter(unboxedClass: Class<*>, valueClass: Class<*>): ValueClassBoxConverter<*, *> {
95+
val key = OtherCacheKey.ValueClassBoxConverter(valueClass)
96+
return find(key) ?: putIfAbsent(key, ValueClassBoxConverter(unboxedClass, valueClass))
97+
}
8698

87-
(valueClassUnboxConverterCache.putIfAbsent(valueClass, value) ?: value)
88-
}
99+
fun getValueClassUnboxConverter(valueClass: Class<*>): ValueClassUnboxConverter<*> {
100+
val key = OtherCacheKey.ValueClassUnboxConverter(valueClass)
101+
return find(key) ?: putIfAbsent(key, ValueClassUnboxConverter(valueClass))
102+
}
89103
}

src/main/kotlin/io/github/projectmapk/jackson/module/kogera/annotation_introspector/KotlinFallbackAnnotationIntrospector.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ internal class KotlinFallbackAnnotationIntrospector(
6868

6969
override fun findSerializationConverter(a: Annotated): Converter<*, *>? = when (a) {
7070
// Find a converter to handle the case where the getter returns an unboxed value from the value class.
71-
is AnnotatedMethod -> cache.findBoxedReturnType(a)?.let {
71+
is AnnotatedMethod -> cache.findBoxedReturnType(a.member)?.let {
7272
if (useJavaDurationConversion && it == KotlinDuration::class.java) {
7373
if (a.rawReturnType == KotlinDuration::class.java) {
7474
KotlinToJavaDurationConverter
@@ -102,7 +102,7 @@ internal class KotlinFallbackAnnotationIntrospector(
102102
// Perform proper serialization even if the value wrapped by the value class is null.
103103
// If value is a non-null object type, it must not be reboxing.
104104
override fun findNullSerializer(am: Annotated): JsonSerializer<*>? = (am as? AnnotatedMethod)?.let { _ ->
105-
cache.findBoxedReturnType(am)?.let {
105+
cache.findBoxedReturnType(am.member)?.let {
106106
if (it.requireRebox()) cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer else null
107107
}
108108
}

src/test/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinModuleTest.kt

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import org.junit.jupiter.api.Assertions.assertEquals
44
import org.junit.jupiter.api.Assertions.assertFalse
55
import org.junit.jupiter.api.Assertions.assertNotNull
66
import org.junit.jupiter.api.Assertions.assertTrue
7+
import org.junit.jupiter.api.Nested
78
import org.junit.jupiter.api.Test
89
import org.junit.jupiter.api.assertDoesNotThrow
10+
import org.junit.jupiter.api.assertThrows
911

1012
class KotlinModuleTest {
1113
@Test
@@ -35,10 +37,52 @@ class KotlinModuleTest {
3537
assertFalse(builder.isEnabled(KotlinFeature.UseJavaDurationConversion))
3638
}
3739

40+
@Nested
41+
inner class SetCacheSizesTest {
42+
@Test
43+
fun `Cannot set initialCacheSize to a value larger than maxCacheSize`() {
44+
assertThrows<IllegalArgumentException> {
45+
KotlinModule.Builder().apply {
46+
withInitialCacheSize(maxCacheSize + 1)
47+
}
48+
}
49+
}
50+
51+
@Test
52+
fun `Cannot set maxCacheSize to a value smaller than initialCacheSize`() {
53+
assertThrows<IllegalArgumentException> {
54+
KotlinModule.Builder().apply {
55+
withMaxCacheSize(initialCacheSize - 1)
56+
}
57+
}
58+
}
59+
60+
@Test
61+
fun `Cannot set maxCacheSize to a value smaller than 15`() {
62+
assertThrows<IllegalArgumentException> {
63+
KotlinModule.Builder().apply {
64+
withInitialCacheSize(0)
65+
withMaxCacheSize(15)
66+
}
67+
}
68+
}
69+
70+
@Test
71+
fun test() {
72+
assertDoesNotThrow {
73+
KotlinModule.Builder().apply {
74+
withInitialCacheSize(0)
75+
withMaxCacheSize(16)
76+
}
77+
}
78+
}
79+
}
80+
3881
@Test
3982
fun jdkSerializabilityTest() {
4083
val module = KotlinModule.Builder().apply {
41-
withReflectionCacheSize(123)
84+
withInitialCacheSize(123)
85+
withMaxCacheSize(321)
4286

4387
KotlinFeature.values().forEach {
4488
enable(it)
@@ -51,7 +95,8 @@ class KotlinModuleTest {
5195
val deserialized = jdkDeserialize<KotlinModule>(serialized)
5296

5397
assertNotNull(deserialized)
54-
assertEquals(123, deserialized.reflectionCacheSize)
98+
assertEquals(123, deserialized.initialCacheSize)
99+
assertEquals(321, deserialized.maxCacheSize)
55100
assertEquals(module.nullToEmptyCollection, deserialized.nullToEmptyCollection)
56101
assertEquals(module.nullToEmptyMap, deserialized.nullToEmptyMap)
57102
assertEquals(module.nullIsSameAsDefault, deserialized.nullIsSameAsDefault)

0 commit comments

Comments
 (0)