feat: add redis cache

This commit is contained in:
Sergey Krylov 2026-04-15 07:10:10 +03:00
parent 827c1a431b
commit 9d183e1d36
9 changed files with 246 additions and 7 deletions

View File

@ -37,6 +37,7 @@
"@teacinema/common": "^1.1.6",
"@teacinema/contracts": "^1.2.1",
"drizzle-orm": "^0.45.2",
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { DatabaseModule } from 'src/infra/database/database.module'
import { RedisModule } from '../infra/redis/redis.module'
import { CategoryModule } from '../modules/category/category.module'
import { MovieModule } from '../modules/movie/movie.module'
@ -9,6 +10,7 @@ import { MovieModule } from '../modules/movie/movie.module'
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule,
RedisModule,
MovieModule,
CategoryModule
]

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common'
import { RedisService } from './redis.service'
@Global()
@Module({
providers: [RedisService],
exports: [RedisService]
})
export class RedisModule {}

View File

@ -0,0 +1,81 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Redis from 'ioredis'
@Injectable()
export class RedisService
extends Redis
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(RedisService.name)
public constructor(private readonly configService: ConfigService) {
super({
username: configService.getOrThrow<string>('REDIS_USERNAME'),
host: configService.getOrThrow('REDIS_HOST'),
port: configService.getOrThrow('REDIS_PORT'),
password: configService.getOrThrow('REDIS_PASSWORD'),
db: parseInt(configService.getOrThrow('REDIS_DB')),
maxRetriesPerRequest: 5,
enableOfflineQueue: true
})
}
public onModuleInit() {
const start = Date.now()
this.on('connect', () => {
this.logger.log('Redis connecting...')
})
this.on('ready', () => {
this.logger.log(`Redis connected in ${Date.now() - start}ms`)
})
this.on('error', e => {
this.logger.error('Redis connection error', {
error: e.message || e
})
})
this.on('close', () => {
this.logger.warn('Redis connection closed')
})
this.on('reconnecting', () => {
this.logger.log('Redis reconnecting')
})
}
public async onModuleDestroy() {
this.logger.log('Redis disconnecting...')
try {
await this.quit()
this.logger.log('Redis disconnected')
} catch (e) {
this.logger.error('Redis disconnecting error', e)
}
}
public async getJson<T>(key: string): Promise<T | null> {
const value = await this.get(key)
return value ? (JSON.parse(value) as T) : null
}
public async setJson(key: string, value: unknown, ttl?: number) {
const payload = JSON.stringify(value)
if (ttl) {
await this.set(key, payload, 'EX', ttl)
} else {
await this.set(key, payload)
}
}
}

View File

@ -0,0 +1,12 @@
export const MovieCacheKeys = {
all: (params: { category?: string; random?: boolean; limit?: number }) =>
[
'movies',
'list',
params.category ?? 'all',
params.random ? 'random' : 'ordered',
params.limit ?? 'nolimit'
].join(':'),
bySlug: (slug: string) => `movies:slug:${slug}`,
byId: (id: string) => `movies:id:${id}`
}

View File

@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { RedisService } from '../../infra/redis/redis.service'
import { MovieCacheKeys } from './movie.cache.keys'
@Injectable()
export class MovieCacheService {
private readonly CACHE_TTL: number
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService
) {
this.CACHE_TTL = this.configService.getOrThrow<number>('CACHE_TTL')
}
public get<T>(key: string): Promise<T | null> {
return this.redisService.getJson<T>(key)
}
public set(key: string, value: unknown): Promise<void> {
return this.redisService.setJson(key, value, this.CACHE_TTL)
}
public getAll<T>(params: {
category?: string
random?: boolean
limit?: number
}): Promise<T | null> {
return this.get<T>(MovieCacheKeys.all(params))
}
public setAll(
params: {
category?: string
random?: boolean
limit?: number
},
value: unknown
) {
return this.set(MovieCacheKeys.all(params), value)
}
}

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { DatabaseModule } from '../../infra/database/database.module'
import { MovieCacheService } from './movie.cache.service'
import { MovieController } from './movie.controller'
import { MovieRepository } from './movie.repository'
import { MovieService } from './movie.service'
@ -9,6 +10,6 @@ import { MovieService } from './movie.service'
@Module({
imports: [DatabaseModule],
controllers: [MovieController],
providers: [MovieService, MovieRepository]
providers: [MovieService, MovieRepository, MovieCacheService]
})
export class MovieModule {}

View File

@ -3,33 +3,60 @@ import { RpcException } from '@nestjs/microservices'
import { RpcStatus } from '@teacinema/common'
import {
GetMovieRequest,
type GetMovieResponse,
ListMovieRequest,
type ListMovieResponse,
Movie,
MovieDetails
} from '@teacinema/contracts/gen/ts/movie'
import { MovieCacheKeys } from './movie.cache.keys'
import { MovieCacheService } from './movie.cache.service'
import { MovieMapper } from './movie.mapper'
import { MovieRepository } from './movie.repository'
@Injectable()
export class MovieService {
public constructor(private readonly movieRepository: MovieRepository) {}
public constructor(
private readonly movieRepository: MovieRepository,
private readonly movieCacheService: MovieCacheService
) {}
public async getAll(data: ListMovieRequest) {
public async getAll(data: ListMovieRequest): Promise<ListMovieResponse> {
const filter = {
category: data.category ?? undefined,
random: data.random === true,
limit: data.limit > 0 ? data.limit : undefined
}
const cached =
await this.movieCacheService.getAll<ListMovieResponse['movies']>(filter)
if (cached) {
return { movies: cached }
}
const movies = await this.movieRepository.findAll(filter)
const mapped = movies.map(movie => MovieMapper.toMovie<Movie>(movie))
await this.movieCacheService.setAll(filter, mapped)
return {
movies: movies.map(movie => MovieMapper.toMovie<Movie>(movie))
movies: mapped
}
}
public async getOne(data: GetMovieRequest) {
public async getOne(data: GetMovieRequest): Promise<GetMovieResponse> {
const cacheKey = data.id
? MovieCacheKeys.byId(data.id)
: MovieCacheKeys.bySlug(data.slug)
const cached =
await this.movieCacheService.get<GetMovieResponse['movie']>(cacheKey)
if (cached) {
return { movie: cached }
}
const movie = data.id
? await this.movieRepository.findById(data.id)
: await this.movieRepository.findBySlug(data.slug)
@ -41,6 +68,9 @@ export class MovieService {
})
}
return { movie: MovieMapper.toMovie<MovieDetails>(movie) }
const mapped = MovieMapper.toMovie<MovieDetails>(movie)
await this.movieCacheService.set(cacheKey, mapped)
return { movie: mapped }
}
}

View File

@ -1033,6 +1033,11 @@
resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-3.0.10.tgz#11ed564ec78432a200ea2601a212d24af8150d50"
integrity sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==
"@ioredis/commands@1.5.1":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.5.1.tgz#a0a3449993b10c7aeb91ecb0d5f1a23692297e51"
integrity sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
@ -2652,6 +2657,11 @@ clone@^1.0.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
cluster-key-slot@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -2786,7 +2796,7 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
shebang-command "^2.0.0"
which "^2.0.1"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.7, debug@^4.4.0, debug@^4.4.3:
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7, debug@^4.4.0, debug@^4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@ -2820,6 +2830,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
denque@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
depd@^2.0.0, depd@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@ -3714,6 +3729,21 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.4:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
ioredis@^5.10.1:
version "5.10.1"
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.10.1.tgz#6082781d8aec8d51ee4936bf81d0610404db1e3d"
integrity sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==
dependencies:
"@ioredis/commands" "1.5.1"
cluster-key-slot "^1.1.0"
debug "^4.3.4"
denque "^2.1.0"
lodash.defaults "^4.2.0"
lodash.isarguments "^3.1.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.1.0"
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@ -4324,6 +4354,16 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
lodash.isarguments@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==
lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@ -4966,6 +5006,18 @@ readdirp@^4.0.1:
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
dependencies:
redis-errors "^1.0.0"
reflect-metadata@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b"
@ -5222,6 +5274,11 @@ stack-utils@^2.0.6:
dependencies:
escape-string-regexp "^2.0.0"
standard-as-callback@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"