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-common"))
implementation(project(":feature:chat:chat-backend")) 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.core.jvm)
implementation(libs.ktor.server.host.common.jvm) implementation(libs.ktor.server.host.common.jvm)
implementation(libs.ktor.server.status.pages.jvm) implementation(libs.ktor.server.status.pages.jvm)
@@ -1,10 +1,14 @@
package ru.shadowsparky.vbox.backend package ru.shadowsparky.vbox.backend
import io.ktor.server.application.Application 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.annotation.KoinApplication
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.plugin.module.dsl.startKoin 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.configureJwt
import ru.shadowsparky.vbox.backend.presentation.configureRouting import ru.shadowsparky.vbox.backend.presentation.configureRouting
import ru.shadowsparky.vbox.backend.presentation.configureSerialization import ru.shadowsparky.vbox.backend.presentation.configureSerialization
@@ -19,6 +23,9 @@ fun main(args: Array<String>) {
} }
fun Application.module() { fun Application.module() {
monitor.subscribe(ApplicationStopping) {
runBlocking { koin.getOrNull<RedisCache>()?.disconnect() }
}
val koin = object : KoinComponent {} val koin = object : KoinComponent {}
configureSerialization() configureSerialization()
configureJwt(koin.get()) 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 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.FuzzyMovieSearchFilter
import ru.shadowsparky.vbox.backend.data.RedisCache
import ru.shadowsparky.vbox.shared.domain.VideoApi import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
import java.util.Collections
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
class SessionVideoApi( class SessionVideoApi(
private val wrapper: VideoApi, private val wrapper: VideoApi,
private val filter: FuzzyMovieSearchFilter private val filter: FuzzyMovieSearchFilter,
private val redis: RedisCache
) : VideoApi { ) : VideoApi {
private val cacheTtlMs = 3.days.inWholeMilliseconds private val ttl = 3.days
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()
override suspend fun fetchNewVideos(query: String?): VideosResponse { override suspend fun fetchNewVideos(query: String?): VideosResponse {
val cacheKey = query?.trim().orEmpty() return redis.exec("$PREFIX:videos:$query", ttl) {
videosCache.getValid(cacheKey)?.let { return it } wrapper.fetchNewVideos(query).let {
return videosMutex.withLock { if (query != null) {
videosCache.getValid(cacheKey) ?: run { it.copy(items = filter.filter(it.items, query))
val remoteData = wrapper.fetchNewVideos(query).let { response -> } else {
if (cacheKey.isNotEmpty()) { it
response.copy(
items = filter.filter(
response.items,
cacheKey
)
)
} else {
response
}
} }
videosCache[cacheKey] = CacheEntry(remoteData)
remoteData
} }
} }
} }
override suspend fun fetchDetails(id: Long): VideoDetails { override suspend fun fetchDetails(id: Long): VideoDetails {
detailsCache.getValid(id)?.let { return it } return redis.exec("$PREFIX:details:$id", ttl) { wrapper.fetchDetails(id) }
return detailsMutex.withLock {
detailsCache.getValid(id) ?: run {
val remoteData = wrapper.fetchDetails(id)
detailsCache[id] = CacheEntry(remoteData)
remoteData
}
}
} }
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse { override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
val cacheKey = VideoLinksKey(id, seasonId) return redis.exec("$PREFIX:links:$id:$seasonId", ttl) {
linksCache.getValid(cacheKey)?.let { return it } wrapper.fetchVideoLinks(id, seasonId)
return linksMutex.withLock {
linksCache.getValid(cacheKey) ?: run {
val remoteData = wrapper.fetchVideoLinks(id, seasonId)
linksCache[cacheKey] = CacheEntry(remoteData)
remoteData
}
} }
} }
private fun <K, V> MutableMap<K, CacheEntry<V>>.getValid(key: K): V? { private companion object {
val entry = this[key] ?: return null const val PREFIX = "video-api"
val isExpired = System.currentTimeMillis() - entry.createdAt > cacheTtlMs
return if (isExpired) {
this.remove(key)
null
} else {
entry.data
}
} }
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.Factory
import org.koin.core.annotation.Module import org.koin.core.annotation.Module
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter 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.ExternalBackendApi
import ru.shadowsparky.vbox.backend.data.http.SessionVideoApi import ru.shadowsparky.vbox.backend.data.http.SessionVideoApi
import ru.shadowsparky.vbox.shared.domain.VideoApi import ru.shadowsparky.vbox.shared.domain.VideoApi
@@ -13,8 +14,9 @@ class HttpModule {
@Factory @Factory
fun provideVideoApi( fun provideVideoApi(
impl: ExternalBackendApi, impl: ExternalBackendApi,
filter: FuzzyMovieSearchFilter filter: FuzzyMovieSearchFilter,
redisCache: RedisCache
): VideoApi { ): VideoApi {
return SessionVideoApi(impl, filter) return SessionVideoApi(impl, filter, redisCache)
} }
} }
+2
View File
@@ -1,5 +1,6 @@
[versions] [versions]
activity-compose = "1.13.0" activity-compose = "1.13.0"
lettuceCoreVersion = "7.7.0.RELEASE"
material3-compose = "1.11.0-alpha03" material3-compose = "1.11.0-alpha03"
adaptiveLayout = "1.3.0-beta02" adaptiveLayout = "1.3.0-beta02"
appcompat = "1.8.0" 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-host-common-jvm = { module = "io.ktor:ktor-server-host-common-jvm" }
ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm" } ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm" }
ktor-server-websockets-jvm = { module = "io.ktor:ktor-server-websockets-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" } 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" } 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" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }