Як використовувати кеш у сервісах NestJS

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

«Кеш це як спеції: трошки додав — і все смачно, але переборщив — і вже не зрозумієш, що це за страва!»

Вступ

Якщо ви працюєте з NestJS, то напевно знаєте, що кешування — це маст-хев для продуктивного додатку. Без кешу ваш сервер може працювати, як старий принтер: думає довше, ніж друкує.

Сьогодні ми розберемо, як правильно додати кеш у NestJS, створимо глобальний модуль кешування, а також напишемо декоратор Cached() для кешування викликів функцій.

Кешування на рівні сервісів vs контролерів

NestJS надає вбудовані засоби для кешування відповідей на рівні контролерів за допомогою CacheInterceptor. Цей підхід дозволяє кешувати відповіді ендпоїнтів, зменшуючи навантаження на сервер та прискорюючи обробку запитів.

Однак, кешування можна реалізувати не лише на рівні контролерів, але й безпосередньо в сервісах. Цей підхід дозволяє кешувати результати виконання методів сервісів, що особливо корисно при частому виклику одних і тих самих методів з однаковими параметрами. Використання декоратора Cached() для кешування методів сервісів забезпечує більш гнучке та ефективне управління кешем, оскільки дозволяє контролювати кешування на рівні бізнес-логіки.

Крок 1: Встановлення необхідних пакетів

Для початку потрібно встановити cache-manager та @nestjs/cache-manager:

npm install cache-manager @nestjs/cache-manager

Якщо ви хочете використовувати Redis як бекенд для кешу (а це гарна ідея), то додайте ще @keyv/redis:

npm install @keyv/redis

Крок 2: Створюємо AppCacheModule та CacheService

CacheService

Щоб керувати кешем, створимо сервіс кешування:

// cache.service.ts
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
import { Cache } from 'cache-manager'

@Injectable()
export class CacheService implements OnModuleInit {
    private static instance: CacheService

    constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}

    onModuleInit() {
        CacheService.instance = this
    }

    static getInstance(): CacheService {
        if (!CacheService.instance) {
            throw new Error('CacheService is not initialized yet!')
        }
        return CacheService.instance
    }

    async get<T>(key: string): Promise<T | null> {
        return this.cacheManager.get<T>(key)
    }

    async set<T>(key: string, value: T, ttlInMs: number): Promise<void> {
        await this.cacheManager.set(key, value, ttlInMs)
    }
}

Тепер у нас є синглтон-сервіс для кешування, який ми можемо використовувати в усьому додатку.

AppCacheModule

Щоб зробити кеш глобальним, створимо модуль AppCacheModule:

// cache.module.ts
import { Module, Global } from '@nestjs/common'
import { CacheModule } from '@nestjs/cache-manager'
import { CacheService } from './cache.service'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { EnvVariables } from '@common/validators/env.validator'
import KeyvRedis from '@keyv/redis'

@Global()
@Module({
    imports: [
        CacheModule.registerAsync({
        imports: [ConfigModule],
        inject: [ConfigService],
        useFactory: async (configService: ConfigService<EnvVariables, true>) => ({
            stores: [
                new KeyvRedis({
                    url: `redis://${configService.get('REDIS_HOST')}:${configService.get('REDIS_PORT')}`,
                }),
            ],
        }),
        isGlobal: true,
        }),
    ],
    providers: [CacheService],
    exports: [CacheService],
})
export class AppCacheModule {}

Тепер цей модуль можна підключати в AppModule, і модуль кешування буде доступний у всьому додатку.

Крок 3: Створюємо декоратор Cached()

Щоб не писати логіку кешування вручну в кожному методі, створимо декоратор Cached():

// cached.decorator.ts
import { ClassConstructor, plainToInstance } from 'class-transformer'
import { CacheService } from '../cache.service'

export function Cached<T>(
    classType: ClassConstructor<T>,
    ttlInSeconds: number,
    key?: string | ((...args: unknown[]) => string),
) {
    return function (
        target: any,
        propertyKey: string,
        descriptor: TypedPropertyDescriptor<(...args: unknown[]) => Promise<T>>,
    ) {
        const originalMethod = descriptor.value

        if (!originalMethod) {
            throw new Error(`Decorator @Cached can only be applied to methods.`)
        }

        descriptor.value = async function (...args: unknown[]): Promise<T> {
            let cacheKey = `cache:${propertyKey}:`

            if (typeof key === 'function') {
                cacheKey += key(...args)
            } else if (typeof key === 'string') {
                cacheKey += key
            } else {
                cacheKey += JSON.stringify(args)
            }

            const cacheManager = CacheService.getInstance()

            const cached = await cacheManager.get<T>(cacheKey)

            if (cached !== null) {
                // Deserialize plain object into an instance of classType
                return plainToInstance(classType, cached, {
                    enableImplicitConversion: true,
                })
            }

            const result = await originalMethod.apply(this, args)
            await cacheManager.set<T>(cacheKey, result, ttlInSeconds * 1000)

            return result
        }

        return descriptor
    }
}

Цей декоратор автоматично кешує результати функції. Якщо значення є в кеші — повертаємо його, інакше виконуємо метод і зберігаємо результат. Також результат, який зберігається в кеші, десеріалізується у відповідний клас, щоб зберегти правильний тип.

Приклад використання декоратора Cached()

Краще всього використовувати кешування на методах, які виконують важкі обчислення або витяжку великої інформації з основної бази даних. Нижче наведений лише приклад використання:

import { Injectable } from '@nestjs/common';
import { Cached } from './cached.decorator';

@Injectable()
export class UserService {
  // Приклад 1: кешуємо дані юзера на 2 хвилини
  @Cached(User, 120)
  async getUser(id: number): Promise<User> {}

  // Приклад 2: кешуємо дані юзера на 10 хвилин, використовуючи динамічний ключ
  @Cached(User, 600, (id: number) => `user:${id}`)
  async getUser(id: number): Promise<User> {}

  // Приклад 3: кешуємо список всіх юзерів на 5 хвилин з фіксованим ключем
  @Cached(User, 300, 'all_users'): Promise<User[]>
  async getUsers() {}
}

Загальна структура модуля Cache

├── cache/
│   ├── decorators/
│   │   └── cached.decorator.ts
│   ├── cache.module.ts
│   └── cache.service.ts

Висновок

— Ми створити глобальний модуль та сервіс роботи з кешуванням в NestJS.

— Реалізували декоратор Cached() для зручного кешування результату функцій.

— Тепер ваші запити оброблятимуться миттєво, а сервер більше не буде нагадувати пароварку під час навантаження.

Mastering Caching: Strategies, Benefits and Trade-offs | by Cyber Drudge |  Level Up Coding

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Цікава ідея, особливо з декоратором. Але у мене питання по юз кейсам, наприклад у випадку якщо треба інвалідувати запис у кеші, там якщо дані оновились? Чи ми рахуємо, що відносно короткий ttl нам це забезпечує?

Іван, дякую за запитання. Так, у поточній реалізації я справді робив ставку на короткий ttl, щоб уникнути проблеми з застарілими даними. З практики знаю, що кеш це завжди баланс між швидкодією і невалідними даними))). Це підходить для більшості сценаріїв, де дані оновлюються нечасто, але читаються активно.
Проте, для складніших кейсів (наприклад, після оновлення сутності) логічно додати явне інвалідування кешу, і це цілком можливо: CacheService вже доступний, тому в методах оновлення/видалення даних можна вручну видалити потрібний ключ через cacheManager.del(key).
У майбутньому в планах розширити декоратор, щоб підтримувати автоматичне інвалідування наприклад, через ще один декоратор або конфігурацію ключів.

Так, завжди доводиться йти на компроміси. Просто мені стало цікаво, бо я сам колись робив кастомний кеш сервіс і там якраз була нетривіальна задача в тому, щоб правильно трекати які саме записи кешу мають бути інвалідовані в тому чи іншому випадку

Ну так просто окремий декоратор, який інвалідує вказані ключі. Хоча колись таке робив просто для класових полів, але суть та сама.

const {type, cached, touches, validate, accessor, defineValidator, string}= require('define-accessor2');
const hash = require('object-hash');
const validator= require('validator');
const Joi= require('@hapi/joi');
 
defineValidator(validator);

class Model{
    @cached
    get sha(){
        console.log('calc sha');
        return hash(this);
    }
 
    @touches('sha') // invalidate
    @string
    name= 'anonymous';
 
    @touches('sha')
    @type('number|string')
    foo= 30;
  
    @accessor({
        touches: 'sha',
        validate: 'isEmail'
    })
    email= '';
 
    @validate(Joi.array().items(Joi.string()))
    labels= [];
}

Трохи критики. Але це фронт, тому навряд чи це прям важливо...
Ну перше питання: як цей глобальний кеш контролювати? Буває купа ситуацій, де потрібні invalidate/purge і використовувати стандартний ttl — замало. Було б непогано в таких штуках одразу додавати методи для цих дій.
2. Навіщо в контексті js гратися з ms->s->ms? Там якщо що, ще під капотом драйвера redis буде знову конвертація в секунди. Чи то зроблено щоб стандартизувати загально під ttl який зазвичай вказують саме в секундах? В такому випадку можна не юзати уточнення розміру в назві (типу, додаємо параметр ttl і все)
3. Реалізація з

cacheKey += JSON.stringify

— це надія що розробник який використовує декоратор, буде точно розуміти що не варто намішувати купу спагетті в параметри? Ну таке, я б тут краще робив з того якийсь хеш з фіксованим розміром.

Доречі, якщо вже згадали про кеш фронта, то варто згадати що це не останній рівень, є ще http (звісно то окрема довга історія, але все-таки...), ну хоча б одним реченням чи що).

Дякую за доречну критику. Це те, що рухає нас вперед)
1. Так, інвалідація — це реально важливо. Але ціль @Cached() це зробити простий декларативний кеш для типових юз-кейсів, коли інвалідація не критична (наприклад дані, що змінюються рідко).
В планах звичайно можливе розширення CacheService методами типу invalidate(key) і invalidateByPattern, але не хотілося одразу ускладнювати приклад. Це все ж вступна стаття, і хотілося залишити мінімальний бар’єр для входу.
2. ttl в секундах — абсолютно слушне зауваження. Redis драйвер keyv приймає ttl у мс, тому в коді йде явна конвертація * 1000. Я свідомо зробив параметр у секундах, оскільки це значно зручніше для розробника при роботі з кешем (5 хв = 300 сек, 10 хв = 600). Мілісекунди менш читабельні та й рідко ми будемо кешувати щось з точністю до мілісекунд.
3. Повністю погоджуюсь, що тут є простір для покращення. Але я спирався на припущення, що:
кеш застосовується до простих методів (наприклад, getUser(id: number)) і args не будуть складними структурами. Хеш — це компромісне рішення. Так, воно дозволяє уникнути довгих рядків, але ускладнює дебаг: складно зрозуміти, що саме зберігається в кеші, коли ключ непрозорий.
І так, звичайно кешування буває на різних рівнях http, cdn (типу Cloudflare), бази даних тощо. Але в цій статті ми розглядали саме кеш на рівні бізнес-логіки, де потрібна максимальна контрольованість і гнучкість у використанні.

Написав універсальний memoized декоратор для TypeScript. Використовую в Angular схожий.
Він кешує результати методів і геттерів для уникнення повторних обчислень.

github.com/eurusik/memoized

Дякую за лаконічну статтю :)

Пару місяців назад теж робив схожу штуку для внутрішньої ліби, проте ми відійшли від CacheManager, і просто юзаємо Keyv напряму, який конфігуруєм в залежності від запуску: редіс або локальний кеш для тестів.

Враховуючи що по дефолту в Nest це і так буде сінглтон, то нащо ми робимо «мануальний» сінглтон?

private static instance: CacheService

onModuleInit() {
CacheService.instance = this
}

Чи є якісь проблеми під час ініціалізації модулів в Nest? ,

Дякую за коментар.
Вірно, NestJS @Injectable() сервіси за замовчуванням є сінглтонами, і зазвичай немає потреби робити мануальний сінглтон.
Але в нашому випадку ми явно зберігаємо інстанс у статичному полі, бо нам потрібно мати доступ до цього сервісу поза DI-контекстом, — в кастомному декораторі Cached()

const cacheManager = CacheService.getInstance()
Це такий собі компроміс, щоб не реалізовувати складніші підходи типу прокидування через контекст.

дякую за відповідь.

в нас для декоратора робиться інжект

const cacheInjection = Inject(CacheService);

return function cacheableDecorator(
    target: Record<string, unknown> | object,
    functionName: string,
    descriptor: PropertyDescriptor,
  ) {
    cacheInjection(target, 'cacheService');
...
}

Підписатись на коментарі