Skip to content

Commit 0da1c09

Browse files
authored
feat(Feat/cache): Create custom cache decorator (#31)
* Chore: pretty * Feat: Implement cache decorator for providers (service) - Install related dependencies - Add custom cache dynamic module * Feat: Implement cache interceptor for resolver - Divide set cache logic * Chore: Update dependencies * Docs: Update README.md
1 parent 24e7c67 commit 0da1c09

11 files changed

+942
-883
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,28 @@ You can use like below.
119119
$ yarn g
120120
```
121121

122+
## Caching
123+
124+
This project provides a custom decorator that makes it easy to implement method caching in NestJS applications.
125+
126+
1. **Caching Functionality**: Utilizes `DiscoveryService` and `MetadataScanner` to handle method caching automatically at runtime.
127+
2. **Usage**: Designed for use with any provider.
128+
3. **GraphQL Resolvers**: Resolvers are also part of providers, but due to GraphQL's internal logic, method overrides do not work. Therefore, the functionality has been replaced with an interceptor.
129+
130+
You can use like below
131+
132+
```js
133+
@Injectable()
134+
export class ExampleService {
135+
@Cache(...)
136+
async exampleMethod(arg: string) {
137+
...
138+
}
139+
}
140+
```
141+
142+
You can find related codes [here](./src/cache/custom-cache.module.ts)
143+
122144
## Getting Started
123145

124146
### Installation
@@ -339,7 +361,7 @@ db.public.registerFunction({
339361
- [x] Refresh Token
340362
- [ ] Redis
341363
- [ ] ElasticSearch
342-
- [ ] Caching
364+
- [x] Caching
343365
- [ ] Graphql Subscription
344366
- [ ] Remove lodash
345367
- [ ] [CASL](https://docs.nestjs.com/security/authorization#integrating-casl)

package.json

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@
3030
},
3131
"dependencies": {
3232
"@apollo/server": "^4.10.5",
33-
"@aws-sdk/client-s3": "^3.620.1",
34-
"@aws-sdk/lib-storage": "^3.620.1",
33+
"@aws-sdk/client-s3": "^3.621.0",
34+
"@aws-sdk/lib-storage": "^3.621.0",
3535
"@aws-sdk/types": "^3.609.0",
3636
"@nestjs/apollo": "^12.2.0",
3737
"@nestjs/axios": "^3.0.2",
38+
"@nestjs/cache-manager": "^2.2.2",
3839
"@nestjs/common": "^10.3.10",
3940
"@nestjs/config": "^3.2.3",
4041
"@nestjs/core": "^10.3.10",
@@ -48,6 +49,7 @@
4849
"apollo-server-express": "^3.13.0",
4950
"axios": "^1.7.2",
5051
"bcrypt": "^5.1.1",
52+
"cache-manager": "^5.7.4",
5153
"class-transformer": "^0.5.1",
5254
"class-validator": "^0.14.1",
5355
"graphql": "^16.9.0",
@@ -72,28 +74,28 @@
7274
"@nestjs/schematics": "^10.1.3",
7375
"@nestjs/testing": "^10.3.10",
7476
"@swc/cli": "^0.4.0",
75-
"@swc/core": "^1.7.3",
77+
"@swc/core": "^1.7.4",
7678
"@swc/jest": "^0.2.36",
7779
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
7880
"@types/bcrypt": "^5.0.2",
7981
"@types/express": "^4.17.21",
8082
"@types/graphql-upload": "^15.0.2",
8183
"@types/jest": "29.5.12",
8284
"@types/lodash": "^4.17.7",
83-
"@types/node": "^22.0.0",
85+
"@types/node": "^22.0.2",
8486
"@types/passport-jwt": "^4.0.1",
8587
"@types/passport-local": "^1.0.38",
8688
"@types/supertest": "^6.0.2",
8789
"@types/uuid": "^10.0.0",
88-
"@typescript-eslint/eslint-plugin": "^7.18.0",
89-
"@typescript-eslint/parser": "^7.18.0",
90+
"@typescript-eslint/eslint-plugin": "^8.0.0",
91+
"@typescript-eslint/parser": "^8.0.0",
9092
"eslint": "^9.8.0",
9193
"eslint-config-prettier": "^9.1.0",
9294
"eslint-plugin-prettier": "^5.2.1",
9395
"globals": "^15.8.0",
9496
"husky": "^9.1.4",
9597
"jest": "^29.7.0",
96-
"pg-mem": "^2.8.1",
98+
"pg-mem": "^2.9.1",
9799
"plop": "^4.0.1",
98100
"prettier": "^3.3.3",
99101
"source-map-support": "^0.5.21",

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GraphQLModule } from '@nestjs/graphql';
55
import { TypeOrmModule } from '@nestjs/typeorm';
66

77
import { AuthModule } from './auth/auth.module';
8+
import { CustomCacheModule } from './cache/custom-cache.module';
89
import { getEnvPath } from './common/helper/env.helper';
910
import { SettingService } from './common/shared/services/setting.service';
1011
import { SharedModule } from './common/shared/shared.module';
@@ -35,6 +36,7 @@ import { UserModule } from './user/user.module';
3536
AuthModule,
3637
UploadModule,
3738
HealthModule,
39+
CustomCacheModule.forRoot(),
3840
],
3941
})
4042
export class AppModule {}

src/cache/custom-cache-interceptor.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
CallHandler,
3+
ExecutionContext,
4+
Injectable,
5+
NestInterceptor,
6+
} from '@nestjs/common';
7+
import { Reflector } from '@nestjs/core';
8+
import { GqlExecutionContext } from '@nestjs/graphql';
9+
10+
import { Observable, lastValueFrom } from 'rxjs';
11+
12+
import { CUSTOM_CACHE, CustomCacheOptions } from './custom-cache.decorator';
13+
import { CustomCacheService } from './custom-cache.service';
14+
15+
@Injectable()
16+
export class CustomCacheInterceptor implements NestInterceptor {
17+
constructor(
18+
private readonly customCacheService: CustomCacheService,
19+
private readonly reflector: Reflector,
20+
) {}
21+
22+
async intercept(
23+
context: ExecutionContext,
24+
next: CallHandler,
25+
): Promise<Observable<any>> {
26+
const handler = context.getHandler();
27+
const options = this.reflector.get<CustomCacheOptions>(
28+
CUSTOM_CACHE,
29+
handler,
30+
);
31+
32+
if (!options) {
33+
return next.handle();
34+
}
35+
36+
const args = this.getArgs(context);
37+
38+
const customKey = `${context.getClass().name}.${handler.name}`;
39+
const result = async () => await lastValueFrom(next.handle());
40+
41+
await this.customCacheService.setCache({
42+
options,
43+
args,
44+
result,
45+
customKey,
46+
});
47+
48+
return next.handle();
49+
}
50+
51+
private getArgs(context: ExecutionContext): unknown[] {
52+
const ctx = GqlExecutionContext.create(context);
53+
const req = ctx.getContext().req;
54+
return req.body;
55+
}
56+
}

src/cache/custom-cache.decorator.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const CUSTOM_CACHE = Symbol('CUSTOM_CACHE');
4+
export interface CustomCacheOptions {
5+
key?: string;
6+
7+
ttl?: number;
8+
9+
logger?: (...args: unknown[]) => unknown;
10+
}
11+
12+
export const CustomCache = (options: CustomCacheOptions = {}) =>
13+
SetMetadata(CUSTOM_CACHE, options);

src/cache/custom-cache.module.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { CacheModule, CacheModuleOptions } from '@nestjs/cache-manager';
2+
import { DynamicModule, Module, OnModuleInit } from '@nestjs/common';
3+
import { APP_INTERCEPTOR, DiscoveryModule } from '@nestjs/core';
4+
5+
import { CustomCacheInterceptor } from './custom-cache-interceptor';
6+
import { CustomCacheService } from './custom-cache.service';
7+
8+
@Module({})
9+
export class CustomCacheModule implements OnModuleInit {
10+
constructor(private readonly customCacheService: CustomCacheService) {}
11+
12+
static forRoot(options?: CacheModuleOptions): DynamicModule {
13+
return {
14+
module: CustomCacheModule,
15+
imports: [CacheModule.register(options), DiscoveryModule],
16+
providers: [
17+
CustomCacheService,
18+
{ provide: APP_INTERCEPTOR, useClass: CustomCacheInterceptor },
19+
],
20+
global: true,
21+
};
22+
}
23+
24+
onModuleInit() {
25+
this.customCacheService.registerAllCache();
26+
}
27+
}

src/cache/custom-cache.service.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
2+
import { Inject, Injectable } from '@nestjs/common';
3+
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
4+
5+
import { CUSTOM_CACHE, CustomCacheOptions } from './custom-cache.decorator';
6+
7+
@Injectable()
8+
export class CustomCacheService {
9+
constructor(
10+
private readonly discoveryService: DiscoveryService,
11+
private readonly metadataScanner: MetadataScanner,
12+
private readonly reflector: Reflector,
13+
@Inject(CACHE_MANAGER)
14+
private readonly cacheManager: Cache,
15+
) {}
16+
17+
registerAllCache() {
18+
return this.discoveryService
19+
.getProviders()
20+
.filter((wrapper) => wrapper.isDependencyTreeStatic())
21+
.filter(({ instance }) => instance && Object.getPrototypeOf(instance))
22+
.forEach(({ instance }) => {
23+
const prototype = Object.getPrototypeOf(instance);
24+
const methods = this.metadataScanner.getAllMethodNames(prototype);
25+
26+
methods.forEach(this.registerCache(instance));
27+
});
28+
}
29+
30+
private registerCache(instance: object) {
31+
return (methodName: string) => {
32+
const methodRef = instance[methodName];
33+
34+
const options = this.reflector.get<CustomCacheOptions>(
35+
CUSTOM_CACHE,
36+
methodRef,
37+
);
38+
if (!options) {
39+
return;
40+
}
41+
42+
const customKey = `${instance.constructor.name}.${methodName}`;
43+
44+
const methodOverride = async (...args: unknown[]) => {
45+
const result = async () => await methodRef.apply(instance, args);
46+
47+
return this.setCache({ customKey, options, result, args });
48+
};
49+
50+
Object.defineProperty(instance, methodName, {
51+
value: methodOverride.bind(instance),
52+
});
53+
};
54+
}
55+
56+
async setCache({
57+
options,
58+
args,
59+
result: _result,
60+
customKey,
61+
}: {
62+
options: CustomCacheOptions;
63+
args: unknown[];
64+
result: () => Promise<unknown>;
65+
customKey: string;
66+
}) {
67+
const { key: cacheKey = customKey, ttl = Infinity, logger } = options;
68+
69+
const argsAddedKey = cacheKey + JSON.stringify(args);
70+
71+
const cachedValue = await this.cacheManager.get(argsAddedKey);
72+
if (Boolean(cachedValue)) {
73+
logger?.('Cache Hit', { cacheKey });
74+
75+
return cachedValue;
76+
}
77+
78+
const result = await _result();
79+
80+
await this.cacheManager.set(argsAddedKey, result, ttl);
81+
logger?.('Cached', { cacheKey });
82+
83+
return result;
84+
}
85+
}

src/graphql-schema.gql

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
1616
scalar DateTime
1717

1818
input GetManyInput {
19-
"""
20-
count or data or all, default = data
21-
"""
19+
"""count or data or all, default = data"""
2220
dataType: String
2321

2422
"""
@@ -39,14 +37,10 @@ type GetUserType {
3937
}
4038

4139
input IPagination {
42-
"""
43-
Started from 0
44-
"""
40+
"""Started from 0"""
4541
page: Int!
4642

47-
"""
48-
Size of page
49-
"""
43+
"""Size of page"""
5044
size: Int!
5145
}
5246

@@ -98,9 +92,7 @@ input UpdateUserInput {
9892
username: String
9993
}
10094

101-
"""
102-
The `Upload` scalar type represents a file upload.
103-
"""
95+
"""The `Upload` scalar type represents a file upload."""
10496
scalar Upload
10597

10698
type User {
@@ -111,4 +103,4 @@ type User {
111103
role: String!
112104
updatedAt: DateTime!
113105
username: String!
114-
}
106+
}

src/user/user.resolver.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
22

33
import GraphQLJSON from 'graphql-type-json';
44

5+
import { CustomCache } from 'src/cache/custom-cache.decorator';
56
import { UseAuthGuard } from 'src/common/decorators/auth-guard.decorator';
67
import { CurrentQuery } from 'src/common/decorators/query.decorator';
78
import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input';
@@ -17,6 +18,7 @@ export class UserResolver {
1718

1819
@Query(() => GetUserType)
1920
@UseAuthGuard('admin')
21+
@CustomCache({ logger: console.log, ttl: 1000 })
2022
getManyUserList(
2123
@Args({ name: 'input', nullable: true })
2224
qs: GetManyInput<User>,

src/user/user.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable } from '@nestjs/common';
22

3+
import { CustomCache } from 'src/cache/custom-cache.decorator';
34
import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types';
45

56
import { User } from './entities/user.entity';
@@ -10,6 +11,7 @@ import { UserRepository } from './user.repository';
1011
export class UserService {
1112
constructor(private readonly userRepository: UserRepository) {}
1213

14+
@CustomCache({ logger: console.log, ttl: 1000 })
1315
getMany(qs: RepoQuery<User> = {}, gqlQuery?: string) {
1416
return this.userRepository.getMany(qs, gqlQuery);
1517
}

0 commit comments

Comments
 (0)