nest 集成 cache-module 实践

1 阅读2分钟

依赖

    "@nestjs/cache-manager": "^3.0.1",
    "cache-manager": "^6.4.3",
    "@keyv/redis": "^4.4.0",

集成

app.module.ts中注册,这里使用redis作为缓存的存储。

import { createKeyv } from '@keyv/redis'
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager'
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { APP_INTERCEPTOR } from '@nestjs/core'

import { AppController } from './app.controller'
import { RedisModule } from './common/redis/redis.module'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    RedisModule,
    CacheModule.registerAsync({
      useFactory: async (configService: ConfigService) => {
        const host = configService.get('REDIS_HOST') || '127.0.0.1'
        const port = configService.get('REDIS_PORT') || 6379
        const password = configService.get('REDIS_PASSWORD')
        return {
          stores: [
            createKeyv(
              {
                url: `redis://${host}:${port}`,
                password,
              },
              {
                namespace:
                  configService.get('CACHE_NAMESPACE') || 'request_cache_',
              },
            ),
          ],
          ttl: configService.get('CACHE_TTL') || 60000,
        }
      },
      isGlobal: true,
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

这样缓存就会在全局生效

然后实现部分接口不进行缓存,以及修改某些数据后,删除已经缓存的部分

skipCache

可以基于CacheInterceptor 拓展一个CustomCacheInterceptor来进行过滤

import { CacheInterceptor } from '@nestjs/cache-manager'
import { ExecutionContext, Injectable, Logger } from '@nestjs/common'

@Injectable()
export class CustomCacheInterceptor extends CacheInterceptor {
  private logger = new Logger('CustomCacheInterceptor')

  trackBy(context: ExecutionContext): string | undefined {
    try {
      const request = context.switchToHttp().getRequest()
      // 只缓存 GET 请求
      if (request.method !== 'GET') {
        return undefined
      }
      const handler = context.getHandler()
      const shouldSkipCache = Reflect.getMetadata('skipCache', handler) === true
      if (shouldSkipCache) {
        return undefined
      }

      const url = request.url
      return url
    } catch (error) {
      this.logger.error(error)
      return undefined
    }
  }
}

这里实现了只缓存 GET请求,并通过Reflect.getMetadata来获取skipCacheshipCachetrue则跳过缓存。

skipCache的设置可以自定义一个装饰器来做

import { applyDecorators, SetMetadata } from '@nestjs/common'

export function CustomCache(skipCache: boolean = false) {
  applyDecorators(SetMetadata('skipCache', skipCache))
}

在不需要缓存的controller中设置 image.png

删除已经缓存的部分

在这之前我们先拓展 cache.decorator.tscustom-cache.interceptor.ts来支持自定义前缀,方便后面删除

:::tabs @tab custom-cache.interceptor.ts

import { CacheInterceptor } from '@nestjs/cache-manager'
import { ExecutionContext, Injectable, Logger } from '@nestjs/common'

@Injectable()
export class CustomCacheInterceptor extends CacheInterceptor {
  private logger = new Logger('CustomCacheInterceptor')

  trackBy(context: ExecutionContext): string | undefined {
    try {
      const request = context.switchToHttp().getRequest()
      // 只缓存 GET 请求
      if (request.method !== 'GET') {
        return undefined
      }
      const handler = context.getHandler()
      const shouldSkipCache = Reflect.getMetadata('skipCache', handler) === true
      if (shouldSkipCache) {
        return undefined
      }

      const customCacheKey =
        Reflect.getMetadata('customCacheKey', handler) || ''

      const url = request.url
      return `${customCacheKey}:${url}`
    } catch (error) {
      this.logger.error(error)
      return undefined
    }
  }
}

@tab cache.decorator.ts

import { applyDecorators, SetMetadata } from '@nestjs/common'

export function CustomCache(key: string | boolean) {
  if (typeof key === 'boolean' && key === false) {
    return applyDecorators(SetMetadata('skipCache', true))
  } else {
    return applyDecorators(SetMetadata('customCacheKey', key))
  }
}

:::

这样修改后,controller 就可以这样: image.png

image.png

这样就可以根据custormKey来批量删除

实现一个cache.service.ts来查询、删除缓存:

import { Cache } from 'cache-manager'

import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Inject, Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'

@Injectable()
export class HelperCacheService {
  @Inject(CACHE_MANAGER)
  private cacheManager: Cache

  @Inject(ConfigService)
  private configService: ConfigService

  private logger = new Logger('HelperCacheService')
  async getCacheAllKeys(): Promise<string[]> {
    const keys: string[] = []
    try {
      const val = await this.cacheManager.stores[0].iterator(
        this.configService.get('CACHE_NAMESPACE') || 'request_cache_',
      )
      for await (const [key] of val) {
        keys.push(key)
      }
      return keys
    } catch (error) {
      this.logger.error('Cache iteration error:', error)
      return []
    }
  }

  async clearCache() {
    const keys = await this.getCacheAllKeys()
    await this.clearCacheByKeys(keys)
  }

  async clearCacheByPrefix(prefix: string) {
    const keys = await this.getCacheAllKeys()
    const cacheKeys = keys.filter((key) => key.startsWith(prefix))
    await this.clearCacheByKeys(cacheKeys)
  }

  async clearCacheByKeys(keys: string[]) {
    if (keys.length === 0) return

    try {
      await Promise.all(
        keys.map((key) => this.cacheManager.stores[0].delete(key)),
      )
      this.logger.log(`Cleared ${keys.length} cache entries`)
    } catch (error) {
      this.logger.error('Failed to clear cache by keys:', error)
    }
  }

  async getCacheByKeys(keys: string[]) {
    const values: any[] = []
    for (const key of keys) {
      const value = await this.cacheManager.stores[0].get(key)
      values.push(value)
    }
    return values
  }

  async getCacheByPrefix(prefix: string) {
    const keys = await this.getCacheAllKeys()
    const values: any[] = []
    for (const key of keys) {
      if (key.startsWith(prefix)) {
        const value = await this.cacheManager.stores[0].get(key)
        values.push(value)
      }
    }
    return values
  }
}

:::warn 使用 this.cacheManager.stores[0].iterator时,应当注意redis版本不能过低。实测redis:5-alpine 不行,redis:7-alpine可以 :::

使用示例:

:::tabs

@tab post.controller.ts

import { IncomingMessage } from 'node:http'
import { FastifyRequest } from 'fastify'

import { Controller, Get, Param, Query, Req } from '@nestjs/common'

import { CustomCache } from '~/common/decorators/cache.decorator'

import { PostFindAllDto, PostPageDto } from './post.dto'
import { PostService } from './post.service'

@Controller('post')
export class PostController {
  constructor(private readonly postService: PostService) {}

  @Get()
  @CustomCache('post-list')
  getList(@Query() query: PostPageDto) {
    return this.postService.getList(query)
  }

  @Get('findAll')
  @CustomCache('post-findAll')
  findAll(@Query() query: PostFindAllDto) {
    return this.postService.findAll(query)
  }

  @Get(':id')
  getDetail(@Param('id') id: string, @Req() req: IncomingMessage) {
    return this.postService.getDetail(id, req)
  }

  @Get(':id/read')
  @CustomCache(false)
  async incrementReadCount(
    @Param('id') id: string,
    @Req() req: FastifyRequest,
  ) {
    return await this.postService.incrementReadCount(id, req)
  }
}

@tab post.service.ts

import { IncomingMessage } from 'node:http'
import { FastifyRequest } from 'fastify'
import { catchError, firstValueFrom } from 'rxjs'

import { HttpService } from '@nestjs/axios'
import {
  BadRequestException,
  Inject,
  Injectable,
  Logger,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Post, Prisma } from '@prisma/client'

import { PrismaService } from '~/common/db/prisma.service'
import { RedisService } from '~/common/redis/redis.service'
import { HelperCacheService } from '~/helpers/helper.cache.service'
import { ImageModel, ImageService } from '~/helpers/helper.image.service'
import { WebEventsGateway } from '~/modules/gateway/web/event.gateway'
import { createPageResult } from '~/shared/page.result'
import {
  AnonymousCookieKey,
  auth,
  AuthCookieKey,
  hasAuthCookie,
} from '~/utils/auth'
import { isDev } from '~/utils/env'

import { SocketMessageType } from '../gateway/constant'
import { ActivityService, ActivityType } from '../statistics/activity.service'
import { PostDto, PostFindAllDto, PostPageDto } from './post.dto'

const TestIntroduction =
  '测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介,测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介测试简介'

@Injectable()
export class PostService {
  private readonly prisma: PrismaService

  @Inject(HelperCacheService)
  private helperCacheService: HelperCacheService

  constructor(
    prisma: PrismaService,
    redis: RedisService,
    httpService: HttpService,
    configService: ConfigService,
    webEventsGateway: WebEventsGateway,
    imageService: ImageService,
  ) {
    this.prisma = prisma
    this.redis = redis
    this.httpService = httpService
    this.configService = configService
    this.webEventsGateway = webEventsGateway
    this.imageService = imageService
  }

  async getDetail(id: string, req: IncomingMessage) {
    const post = await this.prisma.post.findUnique({
      where: { id, isDeleted: false },
      include: {
        categories: {
          select: {
            category: {
              select: {
                id: true,
                name: true,
              },
            },
          },
        },
        _count: {
          select: {
            likes: true,
          },
        },
        images: true,
      },
    })
    if (!post) {
      throw new NotFoundException('post not found or deleted')
    }

    const transformedPost = {
      ...post,
      categories: post.categories.map((pc) => pc.category),
      likeCount: post._count.likes,
    }

    return transformedPost
  }

  async delete(id: string, req: IncomingMessage) {
    const post = await this.prisma.post.findUnique({
      where: { id, isDeleted: false },
    })

    if (!post) {
      throw new NotFoundException()
    }

    await this.prisma.post.update({
      where: { id },
      data: { isDeleted: true, categories: { deleteMany: {} } },
    })

    this.webEventsGateway.emitMessage({
      type: SocketMessageType.Post_DELETED,
      data: {
        postId: post.id,
      },
    })

    await this.clearPostCache(id)
  }

  async create(data: PostDto, req: IncomingMessage) {
    const { categoryIdList, draftId, ...postData } = data

    try {
      const post = await this.prisma.post.create({
        data: {
          ...postData,
          categories: {
            create: categoryIdList.map((id) => ({
              category: {
                connect: { id },
              },
            })),
          },
          draft: draftId
            ? {
                connect: { id: draftId },
              }
            : undefined,
          introduction: isDev() ? TestIntroduction : undefined,
        },
      })

      await this.clearPostCache()
      return true
    } catch (error) {
      Logger.error(error)
      throw new BadRequestException('Failed to create post')
    }
  }

  async update(data: PostDto, req: IncomingMessage) {
    const { id, categoryIdList, ...postData } = data

    if (!id) {
      throw new BadRequestException('id is required')
    }

    const post = await this.prisma.post.findUnique({
      where: { id, isDeleted: false },
    })

    if (!post) {
      throw new NotFoundException('post not found or deleted')
    }

    try {
      await this.prisma.$transaction(async (tx) => {
        await tx.postCategory.deleteMany({
          where: { postId: id },
        })

        return tx.post.update({
          where: { id, isDeleted: false },
          data: {
            ...postData,
            categories: {
              create: categoryIdList.map((categoryId) => ({
                category: { connect: { id: categoryId } },
              })),
            },
            introduction: post.introduction,
          },
        })
      })

      await this.clearPostCache(id)
    } catch (error) {
      Logger.error(error)
      throw new BadRequestException('Failed to update post')
    }
  }

  async clearPostCache(id?: string) {
    await this.helperCacheService.clearCacheByPrefix('post-list')
    await this.helperCacheService.clearCacheByPrefix('post-findAll')
    if (id) {
      await this.helperCacheService.clearCacheByKeys([`:/post/${id}`])
    }
  }
\
}

:::