diff --git a/package.json b/package.json index f766212..34a7ccc 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/core/app.module.ts b/src/core/app.module.ts index 7591168..013d87c 100644 --- a/src/core/app.module.ts +++ b/src/core/app.module.ts @@ -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 ] diff --git a/src/infra/redis/redis.module.ts b/src/infra/redis/redis.module.ts new file mode 100644 index 0000000..d47a2e2 --- /dev/null +++ b/src/infra/redis/redis.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common' + +import { RedisService } from './redis.service' + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService] +}) +export class RedisModule {} diff --git a/src/infra/redis/redis.service.ts b/src/infra/redis/redis.service.ts new file mode 100644 index 0000000..e3fef1b --- /dev/null +++ b/src/infra/redis/redis.service.ts @@ -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('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(key: string): Promise { + 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) + } + } +} diff --git a/src/modules/movie/movie.cache.keys.ts b/src/modules/movie/movie.cache.keys.ts new file mode 100644 index 0000000..f7c0664 --- /dev/null +++ b/src/modules/movie/movie.cache.keys.ts @@ -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}` +} diff --git a/src/modules/movie/movie.cache.service.ts b/src/modules/movie/movie.cache.service.ts new file mode 100644 index 0000000..b45ba03 --- /dev/null +++ b/src/modules/movie/movie.cache.service.ts @@ -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('CACHE_TTL') + } + + public get(key: string): Promise { + return this.redisService.getJson(key) + } + + public set(key: string, value: unknown): Promise { + return this.redisService.setJson(key, value, this.CACHE_TTL) + } + + public getAll(params: { + category?: string + random?: boolean + limit?: number + }): Promise { + return this.get(MovieCacheKeys.all(params)) + } + + public setAll( + params: { + category?: string + random?: boolean + limit?: number + }, + value: unknown + ) { + return this.set(MovieCacheKeys.all(params), value) + } +} diff --git a/src/modules/movie/movie.module.ts b/src/modules/movie/movie.module.ts index 2823acc..c7417fc 100644 --- a/src/modules/movie/movie.module.ts +++ b/src/modules/movie/movie.module.ts @@ -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 {} diff --git a/src/modules/movie/movie.service.ts b/src/modules/movie/movie.service.ts index 9b7c090..4f8733b 100644 --- a/src/modules/movie/movie.service.ts +++ b/src/modules/movie/movie.service.ts @@ -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 { const filter = { category: data.category ?? undefined, random: data.random === true, limit: data.limit > 0 ? data.limit : undefined } + const cached = + await this.movieCacheService.getAll(filter) + + if (cached) { + return { movies: cached } + } + const movies = await this.movieRepository.findAll(filter) + const mapped = movies.map(movie => MovieMapper.toMovie(movie)) + + await this.movieCacheService.setAll(filter, mapped) return { - movies: movies.map(movie => MovieMapper.toMovie(movie)) + movies: mapped } } - public async getOne(data: GetMovieRequest) { + public async getOne(data: GetMovieRequest): Promise { + const cacheKey = data.id + ? MovieCacheKeys.byId(data.id) + : MovieCacheKeys.bySlug(data.slug) + const cached = + await this.movieCacheService.get(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(movie) } + const mapped = MovieMapper.toMovie(movie) + await this.movieCacheService.set(cacheKey, mapped) + + return { movie: mapped } } } diff --git a/yarn.lock b/yarn.lock index 1cf6b12..ea3d87c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"