refactor: cache clean and ttl

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2023-06-17 09:30:29 +08:00
parent 74ba088160
commit 06e21ecdf1
12 changed files with 175 additions and 72 deletions

View File

@@ -17,6 +17,7 @@ import { Inject, Injectable, Logger, RequestMethod } from '@nestjs/common'
import { HttpAdapterHost, Reflector } from '@nestjs/core'
import { REDIS } from '~/app.config'
import { API_CACHE_PREFIX } from '~/constants/cache.constant'
import * as META from '~/constants/meta.constant'
import * as SYSTEM from '~/constants/system.constant'
import { CacheService } from '~/processors/redis/cache.service'
@@ -63,7 +64,7 @@ export class HttpCacheInterceptor implements NestInterceptor {
if (isDisableCache) {
return call$
}
const key = this.trackBy(context) || `mx-api-cache:${request.url}`
const key = this.trackBy(context) || `${API_CACHE_PREFIX}${request.url}`
const metaTTL = this.reflector.get(META.HTTP_CACHE_TTL_METADATA, handler)
const ttl = metaTTL || REDIS.httpCacheTTL
@@ -89,10 +90,6 @@ export class HttpCacheInterceptor implements NestInterceptor {
}
}
/**
* @function trackBy
* @description 目前的命中规则是:必须手动设置了 CacheKey 才会启用缓存机制,默认 ttl 为 APP_CONFIG.REDIS.defaultCacheTTL
*/
trackBy(context: ExecutionContext): string | undefined {
const request = this.getRequest(context)
const httpServer = this.httpAdapterHost.httpAdapter

View File

@@ -25,11 +25,10 @@ export enum RedisKeys {
/** 函数编译缓存 */
FunctionComplieCache = 'function_complie_cache',
}
export const API_CACHE_PREFIX = 'mx-api-cache:'
export enum CacheKeys {
AggregateCatch = 'mx-api-cache:aggregate_catch',
SiteMapCatch = 'mx-api-cache:aggregate_sitemap_catch',
SiteMapXmlCatch = 'mx-api-cache:aggregate_sitemap_xml_catch',
RSS = 'mx-api-cache:rss',
RSSXmlCatch = 'mx-api-cache:rss_xml_catch',
SiteMapCatch = `${API_CACHE_PREFIX}aggregate_sitemap_catch`,
SiteMapXmlCatch = `${API_CACHE_PREFIX}aggregate_sitemap_xml_catch`,
RSS = `${API_CACHE_PREFIX}rss`,
RSSXmlCatch = `${API_CACHE_PREFIX}rss_xml_catch`,
}

View File

@@ -10,7 +10,13 @@ import { CacheKeys } from '~/constants/cache.constant'
import { AnalyzeService } from '../analyze/analyze.service'
import { ConfigsService } from '../configs/configs.service'
import { TimelineQueryDto, TopQueryDto } from './aggregate.dto'
import { NoteService } from '../note/note.service'
import { SnippetService } from '../snippet/snippet.service'
import {
AggregateQueryDto,
TimelineQueryDto,
TopQueryDto,
} from './aggregate.dto'
import { AggregateService } from './aggregate.service'
@ApiController('aggregate')
@@ -19,32 +25,49 @@ export class AggregateController {
private readonly aggregateService: AggregateService,
private readonly configsService: ConfigsService,
private readonly analyzeService: AnalyzeService,
private readonly noteService: NoteService,
private readonly snippetService: SnippetService,
) {}
@Get('/')
@CacheKey(CacheKeys.AggregateCatch)
@CacheTTL(300)
async aggregate() {
@CacheTTL(10 * 60)
async aggregate(@Query() query: AggregateQueryDto) {
const { theme } = query
const tasks = await Promise.allSettled([
this.configsService.getMaster(),
this.aggregateService.getAllCategory(),
this.aggregateService.getAllPages(),
this.configsService.get('url'),
this.configsService.get('seo'),
this.noteService.getLatestNoteId(),
!theme
? Promise.resolve()
: this.snippetService
.getCachedSnippet('theme', theme, 'public')
.then((cached) => {
if (cached) {
return JSON.safeParse(cached) || cached
}
return this.snippetService.getPublicSnippetByName(theme, 'theme')
}),
])
const [user, categories, pageMeta, url, seo] = tasks.map((t) => {
if (t.status === 'fulfilled') {
return t.value
} else {
return null
}
})
const [user, categories, pageMeta, url, seo, latestNodeId, themeConfig] =
tasks.map((t) => {
if (t.status === 'fulfilled') {
return t.value
} else {
return null
}
})
return {
user,
seo,
url: omit(url, ['adminUrl']),
categories,
pageMeta,
latestNodeId,
theme: themeConfig,
}
}

View File

@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer'
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'
export class TopQueryDto {
@Transform(({ value: val }) => parseInt(val))
@@ -29,3 +29,9 @@ export class TimelineQueryDto {
@Transform(({ value: v }) => v | 0)
type?: TimelineType
}
export class AggregateQueryDto {
@IsString()
@IsOptional()
theme?: string
}

View File

@@ -11,6 +11,7 @@ import { PageModule } from '../page/page.module'
import { PostModule } from '../post/post.module'
import { RecentlyModule } from '../recently/recently.module'
import { SayModule } from '../say/say.module'
import { SnippetModule } from '../snippet/snippet.module'
import { AggregateController } from './aggregate.controller'
import { AggregateService } from './aggregate.service'
@@ -24,6 +25,7 @@ import { AggregateService } from './aggregate.service'
forwardRef(() => CommentModule),
forwardRef(() => LinkModule),
forwardRef(() => RecentlyModule),
forwardRef(() => SnippetModule),
AnalyzeModule,
GatewayModule,

View File

@@ -10,7 +10,11 @@ import type { RSSProps } from './aggregate.interface'
import { forwardRef, Inject, Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { CacheKeys, RedisKeys } from '~/constants/cache.constant'
import {
API_CACHE_PREFIX,
CacheKeys,
RedisKeys,
} from '~/constants/cache.constant'
import { EventBusEvents } from '~/constants/event-bus.constant'
import { WebEventsGateway } from '~/processors/gateway/web/events.gateway'
import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service'
@@ -412,7 +416,7 @@ export class AggregateService {
return Promise.all([
this.cacheService.getClient().del(CacheKeys.RSS),
this.cacheService.getClient().del(CacheKeys.RSSXmlCatch),
this.cacheService.getClient().del(CacheKeys.AggregateCatch),
this.cacheService.getClient().del(`${API_CACHE_PREFIX}/aggregate*`),
this.cacheService.getClient().del(CacheKeys.SiteMapCatch),
this.cacheService.getClient().del(CacheKeys.SiteMapXmlCatch),
])

View File

@@ -19,7 +19,6 @@ import { ApiController } from '~/common/decorators/api-controller.decorator'
import { Auth } from '~/common/decorators/auth.decorator'
import { HTTPDecorators } from '~/common/decorators/http.decorator'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { PostService } from '../post/post.service'
@@ -129,7 +128,7 @@ export class CategoryController {
@HTTPDecorators.Idempotence()
async create(@Body() body: CategoryModel) {
const { name, slug } = body
return this.categoryService.model.create({ name, slug: slug ?? name })
return this.categoryService.create(name, slug)
}
@Put('/:id')
@@ -137,14 +136,11 @@ export class CategoryController {
async modify(@Param() params: MongoIdDto, @Body() body: CategoryModel) {
const { type, slug, name } = body
const { id } = params
await this.categoryService.model.updateOne(
{ _id: id },
{
slug,
type,
name,
},
)
await this.categoryService.update(id, {
slug,
type,
name,
})
return await this.categoryService.model.findById(id)
}
@@ -153,7 +149,7 @@ export class CategoryController {
@Auth()
async patch(@Param() params: MongoIdDto, @Body() body: PartialCategoryModel) {
const { id } = params
await this.categoryService.model.updateOne({ _id: id }, body)
await this.categoryService.update(id, body)
return
}
@@ -161,22 +157,7 @@ export class CategoryController {
@Auth()
async deleteCategory(@Param() params: MongoIdDto) {
const { id } = params
const category = await this.categoryService.model.findById(id)
if (!category) {
throw new NoContentCanBeModifiedException()
}
const postsInCategory = await this.categoryService.findPostsInCategory(
category.id,
)
if (postsInCategory.length > 0) {
throw new BadRequestException('该分类中有其他文章,无法被删除')
}
const res = await this.categoryService.model.deleteOne({
_id: category._id,
})
if ((await this.categoryService.model.countDocuments({})) === 0) {
await this.categoryService.createDefaultCategory()
}
return res
return await this.categoryService.deleteById(id)
}
}

View File

@@ -3,11 +3,21 @@ import type { DocumentType } from '@typegoose/typegoose'
import type { FilterQuery } from 'mongoose'
import type { PostModel } from '../post/post.model'
import { forwardRef, Inject, Injectable } from '@nestjs/common'
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
} from '@nestjs/common'
import { ReturnModelType } from '@typegoose/typegoose'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception'
import { EventScope } from '~/constants/business-event.constant'
import { EventBusEvents } from '~/constants/event-bus.constant'
import { EventManagerService } from '~/processors/helper/helper.event.service'
import { InjectModel } from '~/transformers/model.transformer'
import { scheduleManager } from '~/utils'
import { PostService } from '../post/post.service'
import { CategoryModel, CategoryType } from './category.model'
@@ -19,6 +29,7 @@ export class CategoryService {
private readonly categoryModel: ReturnModelType<typeof CategoryModel>,
@Inject(forwardRef(() => PostService))
private readonly postService: PostService,
private readonly eventManager: EventManagerService,
) {
this.createDefaultCategory()
}
@@ -114,6 +125,54 @@ export class CategoryService {
})
}
async create(name: string, slug?: string) {
const doc = await this.model.create({ name, slug: slug ?? name })
this.clearCache()
return doc
}
async update(id: string, partialDoc: Partial<CategoryModel>) {
const newDoc = await this.model.updateOne(
{ _id: id },
{
...partialDoc,
},
{
new: true,
},
)
this.clearCache()
return newDoc
}
async deleteById(id: string) {
const category = await this.model.findById(id)
if (!category) {
throw new NoContentCanBeModifiedException()
}
const postsInCategory = await this.findPostsInCategory(category.id)
if (postsInCategory.length > 0) {
throw new BadRequestException('该分类中有其他文章,无法被删除')
}
const res = await this.model.deleteOne({
_id: category._id,
})
if ((await this.model.countDocuments({})) === 0) {
await this.createDefaultCategory()
}
this.clearCache()
return res
}
private clearCache() {
scheduleManager.schedule(() =>
Promise.all([
this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, {
scope: EventScope.TO_SYSTEM,
}),
]),
)
}
async createDefaultCategory() {
if ((await this.model.countDocuments()) === 0) {
return await this.model.create({

View File

@@ -53,6 +53,21 @@ export class NoteService {
return isSecret
}
async getLatestNoteId() {
const note = await this.noteModel
.findOne()
.sort({
created: -1,
})
.lean()
if (!note) {
throw new CannotFindException()
}
return {
nid: note.nid,
id: note.id,
}
}
async getLatestOne(
condition: FilterQuery<DocumentType<NoteModel>> = {},
projection: any = undefined,
@@ -256,6 +271,9 @@ export class NoteService {
])
scheduleManager.schedule(async () => {
await Promise.all([
this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, {
scope: EventScope.TO_SYSTEM,
}),
this.eventManager.broadcast(BusinessEvents.NOTE_DELETE, id, {
scope: EventScope.TO_SYSTEM_VISITOR,
}),

View File

@@ -3,7 +3,6 @@ import {
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Post,
Put,
@@ -21,7 +20,7 @@ import { PagerDto } from '~/shared/dto/pager.dto'
import { transformDataToPaginate } from '~/transformers/paginate.transformer'
import { SnippetMoreDto } from './snippet.dto'
import { SnippetModel, SnippetType } from './snippet.model'
import { SnippetModel } from './snippet.model'
import { SnippetService } from './snippet.service'
@ApiController('snippets')
@@ -160,23 +159,10 @@ export class SnippetController {
if (cached) {
const json = JSON.safeParse(cached)
return json ? json : cached
return json || cached
}
const snippet = await this.snippetService.getSnippetByName(name, reference)
if (snippet.type === SnippetType.Function) {
throw new NotFoundException()
}
if (snippet.private && !isMaster) {
throw new ForbiddenException('snippet is private')
}
return this.snippetService.attachSnippet(snippet).then((res) => {
this.snippetService.cacheSnippet(res, res.data)
return res.data
})
return await this.snippetService.getPublicSnippetByName(name, reference)
}
@Put('/:id')

View File

@@ -5,13 +5,17 @@ import type { AggregatePaginateModel, Document } from 'mongoose'
import {
BadRequestException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common'
import { EventScope } from '~/constants/business-event.constant'
import { RedisKeys } from '~/constants/cache.constant'
import { EventBusEvents } from '~/constants/event-bus.constant'
import { EventManagerService } from '~/processors/helper/helper.event.service'
import { CacheService } from '~/processors/redis/cache.service'
import { InjectModel } from '~/transformers/model.transformer'
import { getRedisKey } from '~/utils'
@@ -28,6 +32,7 @@ export class SnippetService {
@Inject(forwardRef(() => ServerlessService))
private readonly serverlessService: ServerlessService,
private readonly cacheService: CacheService,
private readonly eventManager: EventManagerService,
) {}
get model() {
@@ -103,6 +108,11 @@ export class SnippetService {
}
await this.deleteCachedSnippet(old.reference, old.name)
await this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, {
scope: EventScope.TO_SYSTEM,
})
const newerDoc = await this.model.findByIdAndUpdate(
id,
{ ...newModel, modified: new Date() },
@@ -234,6 +244,22 @@ export class SnippetService {
return doc
}
async getPublicSnippetByName(name: string, reference: string) {
const snippet = await this.getSnippetByName(name, reference)
if (snippet.type === SnippetType.Function) {
throw new NotFoundException()
}
if (snippet.private) {
throw new ForbiddenException('snippet is private')
}
return this.attachSnippet(snippet).then((res) => {
this.cacheSnippet(res, res.data)
return res.data
})
}
async attachSnippet(model: SnippetModel) {
if (!model) {
throw new NotFoundException()
@@ -282,6 +308,7 @@ export class SnippetService {
const value = await client.hget(getRedisKey(RedisKeys.SnippetCache), key)
return value
}
async deleteCachedSnippet(reference: string, name: string) {
const keyBase = `${reference}:${name}`
const key1 = `${keyBase}:`

View File

@@ -6,6 +6,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'
import { Emitter } from '@socket.io/redis-emitter'
import { RedisIoAdapterKey } from '~/common/adapters/socket.adapter'
import { API_CACHE_PREFIX } from '~/constants/cache.constant'
import { getRedisKey } from '~/utils/redis.util'
// Cache 客户端管理器
@@ -68,7 +69,7 @@ export class CacheService {
public async cleanCatch() {
const redis = this.getClient()
const keys: string[] = await redis.keys('mx-api-cache:*')
const keys: string[] = await redis.keys(`${API_CACHE_PREFIX}*`)
await Promise.all(keys.map((key) => redis.del(key)))
return