依赖
"@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
来获取skipCache
,
shipCache
为 true
则跳过缓存。
skipCache
的设置可以自定义一个装饰器来做
import { applyDecorators, SetMetadata } from '@nestjs/common'
export function CustomCache(skipCache: boolean = false) {
applyDecorators(SetMetadata('skipCache', skipCache))
}
在不需要缓存的controller
中设置
删除已经缓存的部分
在这之前我们先拓展 cache.decorator.ts
和 custom-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 就可以这样:
这样就可以根据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}`])
}
}
\
}
:::