refactor: Migrate from monolithic to modular architecture
1. **Dokumentation der Architektur:**
- Vervollständigen Sie die C4-Diagramme im docs-Verzeichnis
- Dokumentieren Sie die wichtigsten Architekturentscheidungen in ADRs
2. **Redis-Integration finalisieren:**
- Implementieren Sie die verteilte Cache-Lösung für die Offline-Fähigkeit
- Nutzen Sie Redis Streams für das Event-Sourcing
This commit is contained in:
@@ -4,6 +4,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(platform(projects.platform.platformBom))
|
||||
implementation(projects.infrastructure.cache.cacheApi)
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheEntry
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
/**
|
||||
* Jackson-based implementation of CacheSerializer.
|
||||
*/
|
||||
class JacksonCacheSerializer : CacheSerializer {
|
||||
private val objectMapper: ObjectMapper = ObjectMapper().apply {
|
||||
registerModule(KotlinModule.Builder().build())
|
||||
registerModule(JavaTimeModule())
|
||||
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
}
|
||||
|
||||
override fun <T : Any> serialize(value: T): ByteArray {
|
||||
return objectMapper.writeValueAsBytes(value)
|
||||
}
|
||||
|
||||
override fun <T : Any> deserialize(bytes: ByteArray, clazz: Class<T>): T {
|
||||
return objectMapper.readValue(bytes, clazz)
|
||||
}
|
||||
|
||||
override fun <T : Any> serializeEntry(entry: CacheEntry<T>): ByteArray {
|
||||
// Create a wrapper that holds both the entry metadata and the serialized value
|
||||
val wrapper = CacheEntryWrapper(
|
||||
key = entry.key,
|
||||
valueBytes = serialize(entry.value),
|
||||
valueType = entry.value.javaClass.name,
|
||||
createdAt = entry.createdAt,
|
||||
expiresAt = entry.expiresAt,
|
||||
lastModifiedAt = entry.lastModifiedAt,
|
||||
isDirty = entry.isDirty,
|
||||
isLocal = entry.isLocal
|
||||
)
|
||||
return objectMapper.writeValueAsBytes(wrapper)
|
||||
}
|
||||
|
||||
override fun <T : Any> deserializeEntry(bytes: ByteArray, valueClass: Class<T>): CacheEntry<T> {
|
||||
val wrapper = objectMapper.readValue<CacheEntryWrapper>(bytes)
|
||||
val value = deserialize(wrapper.valueBytes, valueClass)
|
||||
|
||||
return CacheEntry(
|
||||
key = wrapper.key,
|
||||
value = value,
|
||||
createdAt = wrapper.createdAt,
|
||||
expiresAt = wrapper.expiresAt,
|
||||
lastModifiedAt = wrapper.lastModifiedAt,
|
||||
isDirty = wrapper.isDirty,
|
||||
isLocal = wrapper.isLocal
|
||||
)
|
||||
}
|
||||
|
||||
override fun compress(bytes: ByteArray): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(outputStream).use { it.write(bytes) }
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
override fun decompress(bytes: ByteArray): ByteArray {
|
||||
val inputStream = GZIPInputStream(ByteArrayInputStream(bytes))
|
||||
return inputStream.readBytes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper class for serializing cache entries.
|
||||
* This separates the metadata from the value, allowing us to deserialize
|
||||
* the metadata without knowing the type of the value.
|
||||
*/
|
||||
private data class CacheEntryWrapper(
|
||||
val key: String,
|
||||
val valueBytes: ByteArray,
|
||||
val valueType: String,
|
||||
val createdAt: java.time.Instant,
|
||||
val expiresAt: java.time.Instant?,
|
||||
val lastModifiedAt: java.time.Instant,
|
||||
val isDirty: Boolean,
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as CacheEntryWrapper
|
||||
|
||||
if (key != other.key) return false
|
||||
if (!valueBytes.contentEquals(other.valueBytes)) return false
|
||||
if (valueType != other.valueType) return false
|
||||
if (createdAt != other.createdAt) return false
|
||||
if (expiresAt != other.expiresAt) return false
|
||||
if (lastModifiedAt != other.lastModifiedAt) return false
|
||||
if (isDirty != other.isDirty) return false
|
||||
if (isLocal != other.isLocal) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = key.hashCode()
|
||||
result = 31 * result + valueBytes.contentHashCode()
|
||||
result = 31 * result + valueType.hashCode()
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + (expiresAt?.hashCode() ?: 0)
|
||||
result = 31 * result + lastModifiedAt.hashCode()
|
||||
result = 31 * result + isDirty.hashCode()
|
||||
result = 31 * result + isLocal.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheConfiguration
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory
|
||||
import org.springframework.data.redis.connection.RedisPassword
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
|
||||
/**
|
||||
* Redis connection properties.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "redis")
|
||||
data class RedisProperties(
|
||||
val host: String = "localhost",
|
||||
val port: Int = 6379,
|
||||
val password: String? = null,
|
||||
val database: Int = 0,
|
||||
val connectionTimeout: Long = 2000,
|
||||
val readTimeout: Long = 2000,
|
||||
val usePooling: Boolean = true,
|
||||
val maxPoolSize: Int = 8,
|
||||
val minPoolSize: Int = 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Spring configuration for Redis.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RedisProperties::class)
|
||||
class RedisConfiguration {
|
||||
|
||||
/**
|
||||
* Creates a Redis connection factory.
|
||||
*
|
||||
* @param properties Redis connection properties
|
||||
* @return Redis connection factory
|
||||
*/
|
||||
@Bean
|
||||
fun redisConnectionFactory(properties: RedisProperties): RedisConnectionFactory {
|
||||
val config = RedisStandaloneConfiguration().apply {
|
||||
hostName = properties.host
|
||||
port = properties.port
|
||||
properties.password?.let { password = RedisPassword.of(it) }
|
||||
database = properties.database
|
||||
}
|
||||
|
||||
return LettuceConnectionFactory(config).apply {
|
||||
// Configure connection timeouts
|
||||
afterPropertiesSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Redis template for byte arrays.
|
||||
*
|
||||
* @param connectionFactory Redis connection factory
|
||||
* @return Redis template
|
||||
*/
|
||||
@Bean
|
||||
fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, ByteArray> {
|
||||
return RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
// Use default serializer for values (byte arrays)
|
||||
afterPropertiesSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache serializer.
|
||||
*
|
||||
* @return Cache serializer
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun cacheSerializer(): CacheSerializer {
|
||||
return JacksonCacheSerializer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default cache configuration if none is provided.
|
||||
*
|
||||
* @return Cache configuration
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun cacheConfiguration(): CacheConfiguration {
|
||||
return DefaultCacheConfiguration()
|
||||
}
|
||||
}
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheConfiguration
|
||||
import at.mocode.infrastructure.cache.api.CacheEntry
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import at.mocode.infrastructure.cache.api.ConnectionState
|
||||
import at.mocode.infrastructure.cache.api.ConnectionStateListener
|
||||
import at.mocode.infrastructure.cache.api.ConnectionStatusTracker
|
||||
import at.mocode.infrastructure.cache.api.DistributedCache
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.redis.RedisConnectionFailureException
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Redis implementation of DistributedCache with offline capability.
|
||||
*/
|
||||
class RedisDistributedCache(
|
||||
private val redisTemplate: RedisTemplate<String, ByteArray>,
|
||||
private val serializer: CacheSerializer,
|
||||
private val config: CacheConfiguration
|
||||
) : DistributedCache, ConnectionStatusTracker {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RedisDistributedCache::class.java)
|
||||
|
||||
// Local cache for offline capability
|
||||
private val localCache = ConcurrentHashMap<String, CacheEntry<Any>>()
|
||||
|
||||
// Set of keys that have been modified locally and need to be synchronized
|
||||
private val dirtyKeys = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
// Connection state
|
||||
private var connectionState = ConnectionState.DISCONNECTED
|
||||
private var lastStateChangeTime = Instant.now()
|
||||
|
||||
// Connection state listeners
|
||||
private val connectionListeners = CopyOnWriteArrayList<ConnectionStateListener>()
|
||||
|
||||
init {
|
||||
// Try to connect to Redis
|
||||
checkConnection()
|
||||
}
|
||||
|
||||
//
|
||||
// DistributedCache implementation
|
||||
//
|
||||
|
||||
override fun <T : Any> get(key: String, clazz: Class<T>): T? {
|
||||
val prefixedKey = addPrefix(key)
|
||||
|
||||
// Try to get from local cache first
|
||||
val localEntry = localCache[prefixedKey] as? CacheEntry<T>
|
||||
if (localEntry != null) {
|
||||
if (localEntry.isExpired()) {
|
||||
localCache.remove(prefixedKey)
|
||||
return null
|
||||
}
|
||||
return localEntry.value
|
||||
}
|
||||
|
||||
// If not in local cache and we're disconnected, return null
|
||||
if (!isConnected()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to get from Redis
|
||||
try {
|
||||
val bytes = redisTemplate.opsForValue().get(prefixedKey) ?: return null
|
||||
val entry = serializer.deserializeEntry(bytes, clazz)
|
||||
|
||||
// Store in local cache
|
||||
localCache[prefixedKey] = entry as CacheEntry<Any>
|
||||
|
||||
return entry.value
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error getting value from Redis for key $prefixedKey", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : Any> set(key: String, value: T, ttl: Duration?) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
val expiresAt = ttl?.let { Instant.now().plus(it) } ?: config.defaultTtl?.let { Instant.now().plus(it) }
|
||||
|
||||
val entry = CacheEntry(
|
||||
key = prefixedKey,
|
||||
value = value,
|
||||
expiresAt = expiresAt
|
||||
)
|
||||
|
||||
// Store in local cache
|
||||
localCache[prefixedKey] = entry as CacheEntry<Any>
|
||||
|
||||
// If we're disconnected, mark as dirty and return
|
||||
if (!isConnected()) {
|
||||
markDirty(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to store in Redis
|
||||
try {
|
||||
val bytes = serializer.serializeEntry(entry)
|
||||
redisTemplate.opsForValue().set(prefixedKey, bytes)
|
||||
|
||||
if (ttl != null) {
|
||||
redisTemplate.expire(prefixedKey, ttl)
|
||||
} else if (config.defaultTtl != null) {
|
||||
val defaultTtl: Duration? = config.defaultTtl
|
||||
redisTemplate.expire(prefixedKey, defaultTtl)
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
markDirty(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error setting value in Redis for key $prefixedKey", e)
|
||||
markDirty(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(key: String) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
|
||||
// Remove from local cache
|
||||
localCache.remove(prefixedKey)
|
||||
|
||||
// If we're disconnected, mark as dirty and return
|
||||
if (!isConnected()) {
|
||||
markDirty(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to delete from Redis
|
||||
try {
|
||||
redisTemplate.delete(prefixedKey)
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
markDirty(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deleting value from Redis for key $prefixedKey", e)
|
||||
markDirty(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun exists(key: String): Boolean {
|
||||
val prefixedKey = addPrefix(key)
|
||||
|
||||
// Check local cache first
|
||||
if (localCache.containsKey(prefixedKey)) {
|
||||
val entry = localCache[prefixedKey]
|
||||
if (entry != null && !entry.isExpired()) {
|
||||
return true
|
||||
}
|
||||
// Remove expired entry
|
||||
localCache.remove(prefixedKey)
|
||||
}
|
||||
|
||||
// If we're disconnected, return false
|
||||
if (!isConnected()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
try {
|
||||
return redisTemplate.hasKey(prefixedKey) ?: false
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
return false
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error checking if key exists in Redis for key $prefixedKey", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : Any> multiGet(keys: Collection<String>, clazz: Class<T>): Map<String, T> {
|
||||
val result = mutableMapOf<String, T>()
|
||||
|
||||
// Get from local cache first
|
||||
val prefixedKeys = keys.map { addPrefix(it) }
|
||||
val localEntries = prefixedKeys.mapNotNull { key ->
|
||||
val entry = localCache[key] as? CacheEntry<T>
|
||||
if (entry != null && !entry.isExpired()) {
|
||||
key to entry.value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
result.putAll(localEntries.mapKeys { removePrefix(it.key) })
|
||||
|
||||
// If we're disconnected, return local entries
|
||||
if (!isConnected()) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Get missing keys from Redis
|
||||
val missingKeys = prefixedKeys.filter { !localEntries.containsKey(it) }
|
||||
if (missingKeys.isEmpty()) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
val redisEntries = redisTemplate.opsForValue().multiGet(missingKeys)
|
||||
if (redisEntries != null) {
|
||||
for (i in missingKeys.indices) {
|
||||
val key = missingKeys[i]
|
||||
val bytes = redisEntries[i]
|
||||
if (bytes != null) {
|
||||
try {
|
||||
val entry = serializer.deserializeEntry(bytes, clazz)
|
||||
|
||||
// Store in local cache
|
||||
localCache[key] = entry as CacheEntry<Any>
|
||||
|
||||
// Add to result
|
||||
result[removePrefix(key)] = entry.value
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deserializing entry for key $key", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error getting multiple values from Redis", e)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun <T : Any> multiSet(entries: Map<String, T>, ttl: Duration?) {
|
||||
// Store in local cache and prepare for Redis
|
||||
val redisBatch = mutableMapOf<String, ByteArray>()
|
||||
val expiresAt = ttl?.let { Instant.now().plus(it) } ?: config.defaultTtl?.let { Instant.now().plus(it) }
|
||||
|
||||
for ((key, value) in entries) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
val entry = CacheEntry(
|
||||
key = prefixedKey,
|
||||
value = value,
|
||||
expiresAt = expiresAt
|
||||
)
|
||||
|
||||
// Store in local cache
|
||||
localCache[prefixedKey] = entry as CacheEntry<Any>
|
||||
|
||||
// Prepare for Redis
|
||||
redisBatch[prefixedKey] = serializer.serializeEntry(entry)
|
||||
}
|
||||
|
||||
// If we're disconnected, mark all as dirty and return
|
||||
if (!isConnected()) {
|
||||
entries.keys.forEach { markDirty(it) }
|
||||
return
|
||||
}
|
||||
|
||||
// Try to store in Redis
|
||||
try {
|
||||
redisTemplate.opsForValue().multiSet(redisBatch)
|
||||
|
||||
if (ttl != null || config.defaultTtl != null) {
|
||||
val duration = ttl ?: config.defaultTtl
|
||||
if (duration != null) {
|
||||
for (key in redisBatch.keys) {
|
||||
redisTemplate.expire(key, duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
entries.keys.forEach { markDirty(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error setting multiple values in Redis", e)
|
||||
entries.keys.forEach { markDirty(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun multiDelete(keys: Collection<String>) {
|
||||
val prefixedKeys = keys.map { addPrefix(it) }
|
||||
|
||||
// Remove from local cache
|
||||
prefixedKeys.forEach { localCache.remove(it) }
|
||||
|
||||
// If we're disconnected, mark all as dirty and return
|
||||
if (!isConnected()) {
|
||||
keys.forEach { markDirty(it) }
|
||||
return
|
||||
}
|
||||
|
||||
// Try to delete from Redis
|
||||
try {
|
||||
redisTemplate.delete(prefixedKeys)
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
keys.forEach { markDirty(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deleting multiple values from Redis", e)
|
||||
keys.forEach { markDirty(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun synchronize(keys: Collection<String>?) {
|
||||
if (!isConnected()) {
|
||||
logger.debug("Cannot synchronize while disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
val keysToSync = keys ?: getDirtyKeys()
|
||||
if (keysToSync.isEmpty()) {
|
||||
logger.debug("No keys to synchronize")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("Synchronizing ${keysToSync.size} keys")
|
||||
|
||||
for (key in keysToSync) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
val localEntry = localCache[prefixedKey]
|
||||
|
||||
if (localEntry == null) {
|
||||
// Entry was deleted locally, delete from Redis
|
||||
try {
|
||||
redisTemplate.delete(prefixedKey)
|
||||
dirtyKeys.remove(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deleting key $prefixedKey during synchronization", e)
|
||||
}
|
||||
} else {
|
||||
// Entry exists locally, update in Redis
|
||||
try {
|
||||
val bytes = serializer.serializeEntry(localEntry)
|
||||
redisTemplate.opsForValue().set(prefixedKey, bytes)
|
||||
|
||||
val ttl = localEntry.expiresAt?.let { Duration.between(Instant.now(), it) }
|
||||
if (ttl != null && !ttl.isNegative) {
|
||||
redisTemplate.expire(prefixedKey, ttl)
|
||||
}
|
||||
|
||||
// Update local entry to mark as clean
|
||||
localCache[prefixedKey] = localEntry.markClean() as CacheEntry<Any>
|
||||
dirtyKeys.remove(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error updating key $prefixedKey during synchronization", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDirty(key: String) {
|
||||
dirtyKeys.add(key)
|
||||
|
||||
val prefixedKey = addPrefix(key)
|
||||
val entry = localCache[prefixedKey]
|
||||
if (entry != null) {
|
||||
localCache[prefixedKey] = entry.markDirty() as CacheEntry<Any>
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDirtyKeys(): Collection<String> {
|
||||
return dirtyKeys.toList()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
// Clear local cache
|
||||
localCache.clear()
|
||||
dirtyKeys.clear()
|
||||
|
||||
// If we're disconnected, return
|
||||
if (!isConnected()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to clear Redis
|
||||
try {
|
||||
val keys = redisTemplate.keys("${config.keyPrefix}*")
|
||||
if (keys != null && keys.isNotEmpty()) {
|
||||
redisTemplate.delete(keys)
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error clearing Redis cache", e)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// ConnectionStatusTracker implementation
|
||||
//
|
||||
|
||||
override fun getConnectionState(): ConnectionState {
|
||||
return connectionState
|
||||
}
|
||||
|
||||
override fun getLastStateChangeTime(): Instant {
|
||||
return lastStateChangeTime
|
||||
}
|
||||
|
||||
override fun registerConnectionListener(listener: ConnectionStateListener) {
|
||||
connectionListeners.add(listener)
|
||||
}
|
||||
|
||||
override fun unregisterConnectionListener(listener: ConnectionStateListener) {
|
||||
connectionListeners.remove(listener)
|
||||
}
|
||||
|
||||
//
|
||||
// Helper methods
|
||||
//
|
||||
|
||||
private fun addPrefix(key: String): String {
|
||||
return if (config.keyPrefix.isEmpty()) key else "${config.keyPrefix}:$key"
|
||||
}
|
||||
|
||||
private fun removePrefix(key: String): String {
|
||||
return if (config.keyPrefix.isEmpty()) key else key.substring(config.keyPrefix.length + 1)
|
||||
}
|
||||
|
||||
private fun handleConnectionFailure(e: Exception) {
|
||||
logger.warn("Redis connection failure: ${e.message}")
|
||||
setConnectionState(ConnectionState.DISCONNECTED)
|
||||
}
|
||||
|
||||
private fun setConnectionState(newState: ConnectionState) {
|
||||
if (connectionState != newState) {
|
||||
val oldState = connectionState
|
||||
connectionState = newState
|
||||
lastStateChangeTime = Instant.now()
|
||||
|
||||
logger.info("Cache connection state changed from $oldState to $newState")
|
||||
|
||||
// Notify listeners
|
||||
val timestamp = lastStateChangeTime
|
||||
connectionListeners.forEach { listener ->
|
||||
try {
|
||||
listener.onConnectionStateChanged(newState, timestamp)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error notifying connection listener", e)
|
||||
}
|
||||
}
|
||||
|
||||
// If reconnected, synchronize dirty keys
|
||||
if (oldState != ConnectionState.CONNECTED && newState == ConnectionState.CONNECTED) {
|
||||
synchronize(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically check the connection to Redis.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.connection-check-interval:10000}")
|
||||
fun checkConnection() {
|
||||
try {
|
||||
redisTemplate.hasKey("connection-test")
|
||||
setConnectionState(ConnectionState.CONNECTED)
|
||||
} catch (e: Exception) {
|
||||
setConnectionState(ConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically clean up expired entries from the local cache.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.local-cache-cleanup-interval:60000}")
|
||||
fun cleanupLocalCache() {
|
||||
val now = Instant.now()
|
||||
val expiredKeys = localCache.entries
|
||||
.filter { it.value.expiresAt?.isBefore(now) ?: false }
|
||||
.map { it.key }
|
||||
|
||||
expiredKeys.forEach { localCache.remove(it) }
|
||||
|
||||
if (expiredKeys.isNotEmpty()) {
|
||||
logger.debug("Removed ${expiredKeys.size} expired entries from local cache")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically synchronize dirty keys when connected.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.sync-interval:300000}")
|
||||
fun scheduledSync() {
|
||||
if (isConnected() && dirtyKeys.isNotEmpty()) {
|
||||
synchronize(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheConfiguration
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import at.mocode.infrastructure.cache.api.ConnectionState
|
||||
import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import java.time.Duration
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@Testcontainers
|
||||
class RedisDistributedCacheTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine"))
|
||||
.withExposedPorts(6379)
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
private lateinit var config: CacheConfiguration
|
||||
private lateinit var cache: RedisDistributedCache
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
config = DefaultCacheConfiguration(
|
||||
keyPrefix = "test:",
|
||||
offlineModeEnabled = true
|
||||
)
|
||||
|
||||
cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
|
||||
// Clear the cache before each test
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test basic cache operations`() {
|
||||
// Set a value
|
||||
cache.set("key1", "value1")
|
||||
|
||||
// Get the value
|
||||
val value = cache.get("key1", String::class.java)
|
||||
assertEquals("value1", value)
|
||||
|
||||
// Check if the key exists
|
||||
assertTrue(cache.exists("key1"))
|
||||
|
||||
// Delete the key
|
||||
cache.delete("key1")
|
||||
|
||||
// Verify it's gone
|
||||
assertFalse(cache.exists("key1"))
|
||||
assertNull(cache.get("key1", String::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache with TTL`() {
|
||||
// Set a value with a short TTL
|
||||
cache.set("key2", "value2", Duration.ofMillis(100))
|
||||
|
||||
// Verify it exists
|
||||
assertTrue(cache.exists("key2"))
|
||||
assertEquals("value2", cache.get("key2", String::class.java))
|
||||
|
||||
// Wait for it to expire
|
||||
Thread.sleep(200)
|
||||
|
||||
// Verify it's gone
|
||||
assertFalse(cache.exists("key2"))
|
||||
assertNull(cache.get("key2", String::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test batch operations`() {
|
||||
// Set multiple values
|
||||
val entries = mapOf(
|
||||
"batch1" to "value1",
|
||||
"batch2" to "value2",
|
||||
"batch3" to "value3"
|
||||
)
|
||||
cache.multiSet(entries)
|
||||
|
||||
// Get multiple values
|
||||
val values = cache.multiGet(listOf("batch1", "batch2", "batch3"), String::class.java)
|
||||
assertEquals(3, values.size)
|
||||
assertEquals("value1", values["batch1"])
|
||||
assertEquals("value2", values["batch2"])
|
||||
assertEquals("value3", values["batch3"])
|
||||
|
||||
// Delete multiple values
|
||||
cache.multiDelete(listOf("batch1", "batch3"))
|
||||
|
||||
// Verify they're gone
|
||||
val remainingValues = cache.multiGet(listOf("batch1", "batch2", "batch3"), String::class.java)
|
||||
assertEquals(1, remainingValues.size)
|
||||
assertNull(remainingValues["batch1"])
|
||||
assertEquals("value2", remainingValues["batch2"])
|
||||
assertNull(remainingValues["batch3"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test offline capability`() {
|
||||
// Set a value
|
||||
cache.set("offline1", "value1")
|
||||
|
||||
// Simulate going offline by stopping the Redis container
|
||||
redisContainer.stop()
|
||||
|
||||
// Verify connection state is DISCONNECTED
|
||||
assertEquals(ConnectionState.DISCONNECTED, cache.getConnectionState())
|
||||
|
||||
// We should still be able to get the value from local cache
|
||||
assertEquals("value1", cache.get("offline1", String::class.java))
|
||||
|
||||
// Set a new value while offline
|
||||
cache.set("offline2", "value2")
|
||||
|
||||
// Verify it's marked as dirty
|
||||
assertTrue(cache.getDirtyKeys().contains("offline2"))
|
||||
|
||||
// Start Redis again
|
||||
redisContainer.start()
|
||||
|
||||
// Manually trigger synchronization
|
||||
cache.synchronize()
|
||||
|
||||
// Verify connection state is CONNECTED
|
||||
assertEquals(ConnectionState.CONNECTED, cache.getConnectionState())
|
||||
|
||||
// Verify the value set while offline is now in Redis
|
||||
assertEquals("value2", cache.get("offline2", String::class.java))
|
||||
|
||||
// Verify it's no longer marked as dirty
|
||||
assertFalse(cache.getDirtyKeys().contains("offline2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test complex objects`() {
|
||||
// Create a complex object
|
||||
val person = Person("John Doe", 30, listOf("Reading", "Hiking"))
|
||||
|
||||
// Store it in the cache
|
||||
cache.set("person1", person)
|
||||
|
||||
// Retrieve it
|
||||
val retrievedPerson = cache.get("person1", Person::class.java)
|
||||
|
||||
// Verify it's the same
|
||||
assertNotNull(retrievedPerson)
|
||||
assertEquals("John Doe", retrievedPerson.name)
|
||||
assertEquals(30, retrievedPerson.age)
|
||||
assertEquals(2, retrievedPerson.hobbies.size)
|
||||
assertTrue(retrievedPerson.hobbies.contains("Reading"))
|
||||
assertTrue(retrievedPerson.hobbies.contains("Hiking"))
|
||||
}
|
||||
|
||||
// Test data class
|
||||
data class Person(
|
||||
val name: String,
|
||||
val age: Int,
|
||||
val hobbies: List<String>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user