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:
Ho Ngoc Hai
2025-12-27 11:40:26 +07:00
parent 7d68f61b82
commit 9cd074acf1
9 changed files with 228 additions and 11 deletions

53
pnpm-lock.yaml generated
View File

@@ -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==}

View File

@@ -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

View File

@@ -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
```

View File

@@ -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",

View File

@@ -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,
};

View 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;
};

View File

@@ -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);

View 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();

View 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;
};