create redis cache
This commit is contained in:
@@ -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 успешно отключен")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+15
-68
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user