From abb5727d0299fcf770ba57cd32d601c65fed1d7a Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 19 Aug 2026 21:24:59 +0300 Subject: [PATCH] create redis cache --- apps/vbox/backend/build.gradle.kts | 2 + .../ru/shadowsparky/vbox/backend/Main.kt | 7 ++ .../vbox/backend/data/RedisCache.kt | 116 ++++++++++++++++++ .../vbox/backend/data/http/SessionVideoApi.kt | 83 +++---------- .../vbox/backend/di/HttpModule.kt | 6 +- gradle/libs.versions.toml | 2 + 6 files changed, 146 insertions(+), 70 deletions(-) create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt diff --git a/apps/vbox/backend/build.gradle.kts b/apps/vbox/backend/build.gradle.kts index 1be004d..daf007a 100644 --- a/apps/vbox/backend/build.gradle.kts +++ b/apps/vbox/backend/build.gradle.kts @@ -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) diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt index fb18316..ea1b5c5 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt @@ -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) { } fun Application.module() { + monitor.subscribe(ApplicationStopping) { + runBlocking { koin.getOrNull()?.disconnect() } + } val koin = object : KoinComponent {} configureSerialization() configureJwt(koin.get()) diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt new file mode 100644 index 0000000..152bdd3 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt @@ -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? = null + private var commands: RedisCoroutinesCommands? = null + + private val connectionMutex = Mutex() + + private suspend fun getCommands(): RedisCoroutinesCommands { + 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 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 exec( + key: String, + ttl: Duration? = null, + createNew: suspend () -> T + ): T { + read(key)?.let { return it } + val result = createNew() + write(key, result, ttl) + return result + } + + suspend inline fun 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 успешно отключен") + } + } + } +} + diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt index c5f1655..ae4d302 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt @@ -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() - private val detailsCache = createBoundedCache() - private val linksCache = createBoundedCache() - - 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 MutableMap>.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 createBoundedCache(): MutableMap> { - val map = object : LinkedHashMap>(maxCacheSize + 1, 0.75f, false) { - override fun removeEldestEntry(eldest: Map.Entry>?): Boolean { - return size > maxCacheSize - } - } - return Collections.synchronizedMap(map) - } - - private data class CacheEntry(val data: T, val createdAt: Long = System.currentTimeMillis()) - private data class VideoLinksKey(val id: Long, val seasonId: Long?) } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt index ec6f139..5642da6 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt @@ -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) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 068969f..c7331a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }