Add Redis support for rate limiting and caching in service template
- Integrated Redis for distributed rate limiting using `rate-limit-redis`. - Updated `README.md` to include Redis caching strategy and configuration details. - Enhanced architecture diagram to reflect Redis usage. - Added new dependencies for `ioredis`, `opossum`, and their respective type definitions. - Updated application configuration to include Redis URL. - Improved bilingual documentation throughout the service template.
This commit is contained in:
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@@ -310,9 +310,18 @@ importers:
|
||||
helmet:
|
||||
specifier: ^7.1.0
|
||||
version: 7.2.0
|
||||
ioredis:
|
||||
specifier: ^5.3.2
|
||||
version: 5.8.2
|
||||
opossum:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
prom-client:
|
||||
specifier: ^15.1.3
|
||||
version: 15.1.3
|
||||
rate-limit-redis:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1(express-rate-limit@7.5.1)
|
||||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.25.76
|
||||
@@ -332,12 +341,18 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.25
|
||||
'@types/ioredis':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
'@types/jest':
|
||||
specifier: ^29.5.11
|
||||
version: 29.5.14
|
||||
'@types/node':
|
||||
specifier: ^20.11.0
|
||||
version: 20.19.27
|
||||
'@types/opossum':
|
||||
specifier: ^8.1.9
|
||||
version: 8.1.9
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.19.27)
|
||||
@@ -1113,7 +1128,6 @@ packages:
|
||||
|
||||
/@ioredis/commands@1.4.0:
|
||||
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||
dev: false
|
||||
|
||||
/@isaacs/cliui@8.0.2:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
@@ -3187,6 +3201,15 @@ packages:
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
dev: true
|
||||
|
||||
/@types/ioredis@5.0.0:
|
||||
resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==}
|
||||
deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed.
|
||||
dependencies:
|
||||
ioredis: 5.8.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@types/istanbul-lib-coverage@2.0.6:
|
||||
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
||||
dev: true
|
||||
@@ -3252,6 +3275,12 @@ packages:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
/@types/opossum@8.1.9:
|
||||
resolution: {integrity: sha512-Jm/tYxuJFefiwRYs+/EOsUP3ktk0c8siMgAHPLnA4PXF4wKghzcjqf88dY+Xii5jId5Txw4JV0FMKTpjbd7KJA==}
|
||||
dependencies:
|
||||
'@types/node': 20.19.27
|
||||
dev: true
|
||||
|
||||
/@types/pg-pool@2.0.6:
|
||||
resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==}
|
||||
dependencies:
|
||||
@@ -4167,7 +4196,6 @@ packages:
|
||||
/cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/co@4.6.0:
|
||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||
@@ -4405,7 +4433,6 @@ packages:
|
||||
/denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
dev: false
|
||||
|
||||
/depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
@@ -5618,7 +5645,6 @@ packages:
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
@@ -6488,7 +6514,6 @@ packages:
|
||||
|
||||
/lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
dev: false
|
||||
|
||||
/lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
@@ -6496,7 +6521,6 @@ packages:
|
||||
|
||||
/lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
dev: false
|
||||
|
||||
/lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
@@ -6876,6 +6900,11 @@ packages:
|
||||
engines: {node: '>=0.10'}
|
||||
dev: false
|
||||
|
||||
/opossum@9.0.0:
|
||||
resolution: {integrity: sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw==}
|
||||
engines: {node: ^24 || ^22 || ^20}
|
||||
dev: false
|
||||
|
||||
/optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -7247,6 +7276,15 @@ packages:
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/rate-limit-redis@4.3.1(express-rate-limit@7.5.1):
|
||||
resolution: {integrity: sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express-rate-limit: '>= 6'
|
||||
dependencies:
|
||||
express-rate-limit: 7.5.1(express@4.22.1)
|
||||
dev: false
|
||||
|
||||
/raw-body@2.5.3:
|
||||
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -7307,14 +7345,12 @@ packages:
|
||||
/redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
dev: false
|
||||
|
||||
/reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
@@ -7644,7 +7680,6 @@ packages:
|
||||
|
||||
/standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
dev: false
|
||||
|
||||
/statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
|
||||
@@ -26,6 +26,7 @@ graph TD
|
||||
end
|
||||
|
||||
DB[(PostgreSQL Database)]
|
||||
Redis[(Redis Cache / State)]
|
||||
Jaeger[Jaeger Tracing]
|
||||
|
||||
Client -->|HTTP Request| LB
|
||||
@@ -39,6 +40,7 @@ graph TD
|
||||
|
||||
Controller -->|Business Logic| Service
|
||||
Service -->|Data Query| Repo
|
||||
Service -->|Cache| Redis
|
||||
|
||||
Repo -->|SQL| DB
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
- **Tracing**: OpenTelemetry/Jaeger integration / Tích hợp OpenTelemetry/Jaeger.
|
||||
- **Resilience / Khả năng phục hồi**:
|
||||
- Graceful shutdown / Đóng ứng dụng an toàn.
|
||||
- Rate limiting / Giới hạn tốc độ request.
|
||||
- Rate limiting (Distributed via Redis) / Giới hạn tốc độ request (Phân tán qua Redis).
|
||||
- Circuit Breaker / Ngắt mạch.
|
||||
- Health checks (liveness/readiness) / Kiểm tra sức khỏe hệ thống.
|
||||
- **Caching**: Redis caching strategy / Chiến lược caching với Redis.
|
||||
- **Security / Bảo mật**: Helmet & CORS configured / Đã cấu hình Helmet & CORS.
|
||||
|
||||
## Project Structure / Cấu trúc Dự án
|
||||
@@ -35,7 +37,7 @@ src/
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm
|
||||
- Docker (optional for local DB)
|
||||
- Docker (Redis required)
|
||||
|
||||
### Installation / Cài đặt
|
||||
|
||||
@@ -50,6 +52,7 @@ src/
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
TRACING_ENABLED=false
|
||||
```
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"opossum": "^9.0.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"rate-limit-redis": "^4.3.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,8 +41,10 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ioredis": "^5.0.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/opossum": "^8.1.9",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.9.1",
|
||||
"ts-jest": "^29.1.2",
|
||||
|
||||
@@ -15,6 +15,7 @@ const envSchema = z.object({
|
||||
SERVICE_NAME: z.string().default('microservice-template'),
|
||||
TRACING_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
JAEGER_ENDPOINT: z.string().optional(),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -61,4 +62,8 @@ export const appConfig = {
|
||||
enabled: config.TRACING_ENABLED === 'true',
|
||||
jaegerEndpoint: config.JAEGER_ENDPOINT,
|
||||
},
|
||||
|
||||
// EN: Redis URL
|
||||
// VI: URL Redis
|
||||
redisUrl: config.REDIS_URL,
|
||||
};
|
||||
|
||||
37
services/_template/src/config/redis.config.ts
Normal file
37
services/_template/src/config/redis.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Redis from 'ioredis';
|
||||
import { appConfig } from './app.config';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
// EN: Redis connection instance
|
||||
// VI: Instance kết nối Redis
|
||||
let redisClient: Redis | undefined;
|
||||
|
||||
/**
|
||||
* EN: Get or create Redis client
|
||||
* VI: Lấy hoặc tạo Redis client
|
||||
*/
|
||||
export const getRedisClient = (): Redis => {
|
||||
if (!redisClient) {
|
||||
redisClient = new Redis(appConfig.redisUrl, {
|
||||
// EN: Retry strategy
|
||||
// VI: Chiến lược thử lại
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
// EN: Reconnect on error
|
||||
// VI: Tự động kết nối lại khi lỗi
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
logger.error('Redis connection error', { error: err.message });
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('Redis connected successfully');
|
||||
});
|
||||
}
|
||||
|
||||
return redisClient;
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { RedisStore } from 'rate-limit-redis';
|
||||
import { connectDatabase } from './config/database.config';
|
||||
import { appConfig } from './config/app.config';
|
||||
import { getRedisClient } from './config/redis.config';
|
||||
import { createRouter } from './routes';
|
||||
import { requestLogger } from './middlewares/logger.middleware';
|
||||
import { errorHandler, notFoundHandler } from './middlewares/error.middleware';
|
||||
@@ -39,6 +41,13 @@ app.use(
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
// EN: Use Redis for distributed rate limiting
|
||||
// VI: Sử dụng Redis để giới hạn rate phân tán
|
||||
store: new RedisStore({
|
||||
// @ts-expect-error - rate-limit-redis types mismatch with ioredis
|
||||
sendCommand: (...args: string[]) => getRedisClient().call(...args),
|
||||
}),
|
||||
|
||||
});
|
||||
app.use('/api', limiter);
|
||||
|
||||
|
||||
71
services/_template/src/modules/common/cache.service.ts
Normal file
71
services/_template/src/modules/common/cache.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getRedisClient } from '../../config/redis.config';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
/**
|
||||
* EN: Service for caching data (Redis wrapper)
|
||||
* VI: Service cho việc caching dữ liệu (Redis wrapper)
|
||||
*/
|
||||
export class CacheService {
|
||||
/**
|
||||
* EN: Get value from cache
|
||||
* VI: Lấy giá trị từ cache
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await getRedisClient().get(key);
|
||||
if (!data) return null;
|
||||
return JSON.parse(data) as T;
|
||||
} catch (error) {
|
||||
logger.error('Cache get error', { key, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Set value in cache
|
||||
* VI: Lưu giá trị vào cache
|
||||
*/
|
||||
async set(key: string, value: any, ttlSeconds?: number): Promise<void> {
|
||||
try {
|
||||
const stringValue = JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await getRedisClient().setex(key, ttlSeconds, stringValue);
|
||||
} else {
|
||||
await getRedisClient().set(key, stringValue);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Cache set error', { key, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get from cache or fetch from source if missing
|
||||
* VI: Lấy từ cache hoặc lấy từ nguồn nếu không có
|
||||
*/
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
fetchFn: () => Promise<T>,
|
||||
ttlSeconds: number = 300
|
||||
): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const data = await fetchFn();
|
||||
await this.set(key, data, ttlSeconds);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Delete from cache
|
||||
* VI: Xóa khỏi cache
|
||||
*/
|
||||
async del(key: string): Promise<void> {
|
||||
try {
|
||||
await getRedisClient().del(key);
|
||||
} catch (error) {
|
||||
logger.error('Cache del error', { key, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheService = new CacheService();
|
||||
50
services/_template/src/modules/common/circuit-breaker.ts
Normal file
50
services/_template/src/modules/common/circuit-breaker.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import CircuitBreaker from 'opossum';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
/**
|
||||
* EN: Circuit Breaker Configuration
|
||||
* VI: Cấu hình Circuit Breaker
|
||||
*/
|
||||
const defaultOptions: CircuitBreaker.Options = {
|
||||
timeout: 3000, // 3 seconds
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000, // 30 seconds
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Create a circuit breaker for an async function
|
||||
* VI: Tạo circuit breaker cho một hàm bất đồng bộ
|
||||
*
|
||||
* @param action - Async function to protect
|
||||
* @param name - Name of the circuit breaker
|
||||
* @param options - Override default options
|
||||
*/
|
||||
export const createCircuitBreaker = <TArgs extends any[], TResult>(
|
||||
action: (...args: TArgs) => Promise<TResult>,
|
||||
name: string,
|
||||
options: Partial<CircuitBreaker.Options> = {}
|
||||
): CircuitBreaker<TArgs, TResult> => {
|
||||
const breaker = new CircuitBreaker(action, {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
name,
|
||||
});
|
||||
|
||||
breaker.on('open', () => {
|
||||
logger.warn(`Circuit Breaker OPEN: ${name}`);
|
||||
});
|
||||
|
||||
breaker.on('halfOpen', () => {
|
||||
logger.info(`Circuit Breaker HALF-OPEN: ${name}`);
|
||||
});
|
||||
|
||||
breaker.on('close', () => {
|
||||
logger.info(`Circuit Breaker CLOSED: ${name}`);
|
||||
});
|
||||
|
||||
breaker.on('fallback', () => {
|
||||
logger.warn(`Circuit Breaker FALLBACK: ${name}`);
|
||||
});
|
||||
|
||||
return breaker;
|
||||
};
|
||||
Reference in New Issue
Block a user