create redis cache
This commit is contained in:
@@ -24,6 +24,8 @@ dependencies {
|
||||
implementation(project(":feature:chat:chat-common"))
|
||||
implementation(project(":feature:chat:chat-backend"))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.8.1")
|
||||
implementation(libs.lettuce.lettuce.core)
|
||||
implementation(libs.ktor.server.core.jvm)
|
||||
implementation(libs.ktor.server.host.common.jvm)
|
||||
implementation(libs.ktor.server.status.pages.jvm)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package ru.shadowsparky.vbox.backend
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationStopping
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.annotation.KoinApplication
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.vbox.backend.data.RedisCache
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureJwt
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureRouting
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
|
||||
@@ -19,6 +23,9 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
monitor.subscribe(ApplicationStopping) {
|
||||
runBlocking { koin.getOrNull<RedisCache>()?.disconnect() }
|
||||
}
|
||||
val koin = object : KoinComponent {}
|
||||
configureSerialization()
|
||||
configureJwt(koin.get())
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import io.lettuce.core.ExperimentalLettuceCoroutinesApi
|
||||
import io.lettuce.core.RedisClient
|
||||
import io.lettuce.core.SetArgs
|
||||
import io.lettuce.core.api.StatefulRedisConnection
|
||||
import io.lettuce.core.api.coroutines
|
||||
import io.lettuce.core.api.coroutines.RedisCoroutinesCommands
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.slf4j.LoggerFactory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import kotlin.time.Duration
|
||||
|
||||
@OptIn(ExperimentalLettuceCoroutinesApi::class)
|
||||
@Factory
|
||||
class RedisCache(
|
||||
private val envFetcher: EnvFetcher,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
val json: Json
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(RedisCache::class.java)
|
||||
|
||||
private var client: RedisClient? = null
|
||||
private var connection: StatefulRedisConnection<String, String>? = null
|
||||
private var commands: RedisCoroutinesCommands<String, String>? = null
|
||||
|
||||
private val connectionMutex = Mutex()
|
||||
|
||||
private suspend fun getCommands(): RedisCoroutinesCommands<String, String> {
|
||||
commands?.let { return it }
|
||||
return connectionMutex.withLock {
|
||||
commands?.let { return@withLock it }
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
val redisUri = envFetcher.get("REDIS_URI", "redis://redis:6379")
|
||||
logger.info("Динамическое подключение к Redis: $redisUri")
|
||||
val redisClient = RedisClient.create(redisUri)
|
||||
val conn = redisClient.connect()
|
||||
val cmds = conn.coroutines()
|
||||
|
||||
client = redisClient
|
||||
connection = conn
|
||||
commands = cmds
|
||||
|
||||
logger.info("Успешно подключено к Redis")
|
||||
cmds
|
||||
} catch (e: Exception) {
|
||||
logger.error("Ошибка динамического подключения к Redis: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> write(key: String, value: T, duration: Duration? = null) {
|
||||
write(key, json.encodeToString(value), duration)
|
||||
}
|
||||
|
||||
suspend fun write(key: String, value: String, duration: Duration? = null) {
|
||||
val ttlInSeconds = duration?.inWholeSeconds
|
||||
if (ttlInSeconds != null && ttlInSeconds > 0) {
|
||||
val args = SetArgs().ex(ttlInSeconds)
|
||||
getCommands().set(key, value, args)
|
||||
} else {
|
||||
getCommands().set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> exec(
|
||||
key: String,
|
||||
ttl: Duration? = null,
|
||||
createNew: suspend () -> T
|
||||
): T {
|
||||
read<T>(key)?.let { return it }
|
||||
val result = createNew()
|
||||
write(key, result, ttl)
|
||||
return result
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> read(key: String): T? {
|
||||
return json.decodeFromString(readInternal(key) ?: return null)
|
||||
}
|
||||
|
||||
suspend fun readInternal(key: String): String? {
|
||||
return getCommands().get(key)
|
||||
}
|
||||
|
||||
suspend fun delete(key: String): Boolean {
|
||||
return (getCommands().del(key) ?: 0L) > 0L
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
connectionMutex.withLock {
|
||||
if (connection != null || client != null) {
|
||||
logger.info("Закрытие соединений с Redis...")
|
||||
try {
|
||||
connection?.close()
|
||||
client?.shutdown()
|
||||
} catch (e: Exception) {
|
||||
logger.error("Ошибка при закрытии Redis: ${e.message}", e)
|
||||
} finally {
|
||||
connection = null
|
||||
client = null
|
||||
commands = null
|
||||
}
|
||||
logger.info("Redis успешно отключен")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-68
@@ -1,96 +1,43 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter
|
||||
import ru.shadowsparky.vbox.backend.data.RedisCache
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||
import java.util.Collections
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class SessionVideoApi(
|
||||
private val wrapper: VideoApi,
|
||||
private val filter: FuzzyMovieSearchFilter
|
||||
private val filter: FuzzyMovieSearchFilter,
|
||||
private val redis: RedisCache
|
||||
) : VideoApi {
|
||||
private val cacheTtlMs = 3.days.inWholeMilliseconds
|
||||
private val maxCacheSize = 100
|
||||
|
||||
private val videosCache = createBoundedCache<String, VideosResponse>()
|
||||
private val detailsCache = createBoundedCache<Long, VideoDetails>()
|
||||
private val linksCache = createBoundedCache<VideoLinksKey, VideoLinksResponse>()
|
||||
|
||||
private val videosMutex = Mutex()
|
||||
private val detailsMutex = Mutex()
|
||||
private val linksMutex = Mutex()
|
||||
private val ttl = 3.days
|
||||
|
||||
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||
val cacheKey = query?.trim().orEmpty()
|
||||
videosCache.getValid(cacheKey)?.let { return it }
|
||||
return videosMutex.withLock {
|
||||
videosCache.getValid(cacheKey) ?: run {
|
||||
val remoteData = wrapper.fetchNewVideos(query).let { response ->
|
||||
if (cacheKey.isNotEmpty()) {
|
||||
response.copy(
|
||||
items = filter.filter(
|
||||
response.items,
|
||||
cacheKey
|
||||
)
|
||||
)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
return redis.exec("$PREFIX:videos:$query", ttl) {
|
||||
wrapper.fetchNewVideos(query).let {
|
||||
if (query != null) {
|
||||
it.copy(items = filter.filter(it.items, query))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
videosCache[cacheKey] = CacheEntry(remoteData)
|
||||
remoteData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
detailsCache.getValid(id)?.let { return it }
|
||||
return detailsMutex.withLock {
|
||||
detailsCache.getValid(id) ?: run {
|
||||
val remoteData = wrapper.fetchDetails(id)
|
||||
detailsCache[id] = CacheEntry(remoteData)
|
||||
remoteData
|
||||
}
|
||||
}
|
||||
return redis.exec("$PREFIX:details:$id", ttl) { wrapper.fetchDetails(id) }
|
||||
}
|
||||
|
||||
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
|
||||
val cacheKey = VideoLinksKey(id, seasonId)
|
||||
linksCache.getValid(cacheKey)?.let { return it }
|
||||
return linksMutex.withLock {
|
||||
linksCache.getValid(cacheKey) ?: run {
|
||||
val remoteData = wrapper.fetchVideoLinks(id, seasonId)
|
||||
linksCache[cacheKey] = CacheEntry(remoteData)
|
||||
remoteData
|
||||
}
|
||||
return redis.exec("$PREFIX:links:$id:$seasonId", ttl) {
|
||||
wrapper.fetchVideoLinks(id, seasonId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <K, V> MutableMap<K, CacheEntry<V>>.getValid(key: K): V? {
|
||||
val entry = this[key] ?: return null
|
||||
val isExpired = System.currentTimeMillis() - entry.createdAt > cacheTtlMs
|
||||
return if (isExpired) {
|
||||
this.remove(key)
|
||||
null
|
||||
} else {
|
||||
entry.data
|
||||
}
|
||||
private companion object {
|
||||
const val PREFIX = "video-api"
|
||||
}
|
||||
|
||||
private fun <K, V> createBoundedCache(): MutableMap<K, CacheEntry<V>> {
|
||||
val map = object : LinkedHashMap<K, CacheEntry<V>>(maxCacheSize + 1, 0.75f, false) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<K, CacheEntry<V>>?): Boolean {
|
||||
return size > maxCacheSize
|
||||
}
|
||||
}
|
||||
return Collections.synchronizedMap(map)
|
||||
}
|
||||
|
||||
private data class CacheEntry<T>(val data: T, val createdAt: Long = System.currentTimeMillis())
|
||||
private data class VideoLinksKey(val id: Long, val seasonId: Long?)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ru.shadowsparky.vbox.backend.di
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter
|
||||
import ru.shadowsparky.vbox.backend.data.RedisCache
|
||||
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
|
||||
import ru.shadowsparky.vbox.backend.data.http.SessionVideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
@@ -13,8 +14,9 @@ class HttpModule {
|
||||
@Factory
|
||||
fun provideVideoApi(
|
||||
impl: ExternalBackendApi,
|
||||
filter: FuzzyMovieSearchFilter
|
||||
filter: FuzzyMovieSearchFilter,
|
||||
redisCache: RedisCache
|
||||
): VideoApi {
|
||||
return SessionVideoApi(impl, filter)
|
||||
return SessionVideoApi(impl, filter, redisCache)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[versions]
|
||||
activity-compose = "1.13.0"
|
||||
lettuceCoreVersion = "7.7.0.RELEASE"
|
||||
material3-compose = "1.11.0-alpha03"
|
||||
adaptiveLayout = "1.3.0-beta02"
|
||||
appcompat = "1.8.0"
|
||||
@@ -85,6 +86,7 @@ ktor-server-status-pages-jvm = { module = "io.ktor:ktor-server-status-pages-jvm"
|
||||
ktor-server-host-common-jvm = { module = "io.ktor:ktor-server-host-common-jvm" }
|
||||
ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm" }
|
||||
ktor-server-websockets-jvm = { module = "io.ktor:ktor-server-websockets-jvm" }
|
||||
lettuce-lettuce-core = { module = "io.lettuce:lettuce-core", version.ref = "lettuceCoreVersion" }
|
||||
material = { module = "com.google.android.material:material", version.ref = "materialVersion" }
|
||||
material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3-compose" }
|
||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
|
||||
|
||||
Reference in New Issue
Block a user