From 9cd074acf15a55573053b9d80d78d11781bf6d63 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 27 Dec 2025 11:40:26 +0700 Subject: [PATCH] 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. --- pnpm-lock.yaml | 53 +++++++++++--- services/_template/ARCHITECTURE.md | 2 + services/_template/README.md | 7 +- services/_template/package.json | 5 ++ services/_template/src/config/app.config.ts | 5 ++ services/_template/src/config/redis.config.ts | 37 ++++++++++ services/_template/src/main.ts | 9 +++ .../src/modules/common/cache.service.ts | 71 +++++++++++++++++++ .../src/modules/common/circuit-breaker.ts | 50 +++++++++++++ 9 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 services/_template/src/config/redis.config.ts create mode 100644 services/_template/src/modules/common/cache.service.ts create mode 100644 services/_template/src/modules/common/circuit-breaker.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd39195c..a3e7cb5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==} diff --git a/services/_template/ARCHITECTURE.md b/services/_template/ARCHITECTURE.md index 325f4ee1..8440b9e6 100644 --- a/services/_template/ARCHITECTURE.md +++ b/services/_template/ARCHITECTURE.md @@ -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 diff --git a/services/_template/README.md b/services/_template/README.md index d8c48a3f..57417f0e 100644 --- a/services/_template/README.md +++ b/services/_template/README.md @@ -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 ``` diff --git a/services/_template/package.json b/services/_template/package.json index 033c859c..97f9394c 100644 --- a/services/_template/package.json +++ b/services/_template/package.json @@ -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", diff --git a/services/_template/src/config/app.config.ts b/services/_template/src/config/app.config.ts index 51bba4e8..c9f34b32 100644 --- a/services/_template/src/config/app.config.ts +++ b/services/_template/src/config/app.config.ts @@ -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, }; diff --git a/services/_template/src/config/redis.config.ts b/services/_template/src/config/redis.config.ts new file mode 100644 index 00000000..26e77967 --- /dev/null +++ b/services/_template/src/config/redis.config.ts @@ -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; +}; diff --git a/services/_template/src/main.ts b/services/_template/src/main.ts index ac095cc1..036dbe82 100644 --- a/services/_template/src/main.ts +++ b/services/_template/src/main.ts @@ -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); diff --git a/services/_template/src/modules/common/cache.service.ts b/services/_template/src/modules/common/cache.service.ts new file mode 100644 index 00000000..7819a4df --- /dev/null +++ b/services/_template/src/modules/common/cache.service.ts @@ -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(key: string): Promise { + 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 { + 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( + key: string, + fetchFn: () => Promise, + ttlSeconds: number = 300 + ): Promise { + const cached = await this.get(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 { + try { + await getRedisClient().del(key); + } catch (error) { + logger.error('Cache del error', { key, error }); + } + } +} + +export const cacheService = new CacheService(); diff --git a/services/_template/src/modules/common/circuit-breaker.ts b/services/_template/src/modules/common/circuit-breaker.ts new file mode 100644 index 00000000..e889adb2 --- /dev/null +++ b/services/_template/src/modules/common/circuit-breaker.ts @@ -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 = ( + action: (...args: TArgs) => Promise, + name: string, + options: Partial = {} +): CircuitBreaker => { + 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; +};