create redis cache

This commit is contained in:
2026-08-19 21:24:59 +03:00
parent d9302caa28
commit abb5727d02
6 changed files with 146 additions and 70 deletions
+2
View File
@@ -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 успешно отключен")
}
}
}
}
@@ -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)
}
}