diff --git a/src/core/redis/README.md b/src/core/redis/README.md new file mode 100644 index 0000000..524aa99 --- /dev/null +++ b/src/core/redis/README.md @@ -0,0 +1,200 @@ +# Redis 适配器 + +这个Redis适配器提供了一个统一的接口,可以在本地开发环境使用文件存储模拟Redis,在生产环境使用真实的Redis服务。 + +## 功能特性 + +- 🔄 **自动切换**: 根据环境变量自动选择文件存储或真实Redis +- 📁 **文件存储**: 本地开发时使用JSON文件模拟Redis功能 +- ⚡ **真实Redis**: 生产环境连接真实Redis服务器 +- 🕒 **过期支持**: 完整支持TTL和自动过期清理 +- 🔒 **类型安全**: 使用TypeScript接口确保类型安全 +- 📊 **日志记录**: 详细的操作日志和错误处理 + +## 环境配置 + +### 开发环境 (.env) +```bash +# 使用文件模拟Redis +USE_FILE_REDIS=true +NODE_ENV=development + +# Redis配置(文件模式下不会使用) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +``` + +### 生产环境 (.env.production) +```bash +# 使用真实Redis +USE_FILE_REDIS=false +NODE_ENV=production + +# Redis服务器配置 +REDIS_HOST=your_redis_host +REDIS_PORT=6379 +REDIS_PASSWORD=your_redis_password +REDIS_DB=0 +``` + +## 使用方法 + +### 1. 在模块中导入 +```typescript +import { Module } from '@nestjs/common'; +import { RedisModule } from './core/redis/redis.module'; + +@Module({ + imports: [RedisModule], + // ... +}) +export class YourModule {} +``` + +### 2. 在服务中注入 +```typescript +import { Injectable, Inject } from '@nestjs/common'; +import { IRedisService } from './core/redis/redis.interface'; + +@Injectable() +export class YourService { + constructor( + @Inject('REDIS_SERVICE') private readonly redis: IRedisService, + ) {} + + async example() { + // 设置键值对,30秒后过期 + await this.redis.set('user:123', 'user_data', 30); + + // 获取值 + const value = await this.redis.get('user:123'); + + // 检查是否存在 + const exists = await this.redis.exists('user:123'); + + // 删除键 + await this.redis.del('user:123'); + } +} +``` + +## API 接口 + +### set(key, value, ttl?) +设置键值对,可选过期时间 +```typescript +await redis.set('key', 'value', 60); // 60秒后过期 +await redis.set('key', 'value'); // 永不过期 +``` + +### get(key) +获取值,不存在或已过期返回null +```typescript +const value = await redis.get('key'); +``` + +### del(key) +删除键,返回是否删除成功 +```typescript +const deleted = await redis.del('key'); +``` + +### exists(key) +检查键是否存在 +```typescript +const exists = await redis.exists('key'); +``` + +### expire(key, ttl) +设置键的过期时间 +```typescript +await redis.expire('key', 300); // 5分钟后过期 +``` + +### ttl(key) +获取键的剩余过期时间 +```typescript +const remaining = await redis.ttl('key'); +// -1: 永不过期 +// -2: 键不存在 +// >0: 剩余秒数 +``` + +### flushall() +清空所有数据 +```typescript +await redis.flushall(); +``` + +## 文件存储详情 + +### 数据存储位置 +- 数据目录: `./redis-data/` +- 数据文件: `./redis-data/redis.json` + +### 过期清理 +- 自动清理: 每分钟检查并清理过期键 +- 访问时清理: 获取数据时自动检查过期状态 +- 持久化: 数据变更时自动保存到文件 + +### 数据格式 +```json +{ + "key1": { + "value": "data", + "expireAt": 1640995200000 + }, + "key2": { + "value": "permanent_data" + } +} +``` + +## 切换模式 + +### 自动切换规则 +1. `NODE_ENV=development` 且 `USE_FILE_REDIS=true` → 文件存储 +2. `USE_FILE_REDIS=false` → 真实Redis +3. 生产环境默认使用真实Redis + +### 手动切换 +修改环境变量后重启应用即可切换模式: +```bash +# 切换到文件模式 +USE_FILE_REDIS=true + +# 切换到Redis模式 +USE_FILE_REDIS=false +``` + +## 测试 + +运行Redis适配器测试: +```bash +npm run build +node test-redis-adapter.js +``` + +## 注意事项 + +1. **数据迁移**: 文件存储和Redis之间的数据不会自动同步 +2. **性能**: 文件存储适合开发测试,生产环境建议使用Redis +3. **并发**: 文件存储不支持高并发,仅适用于单进程开发环境 +4. **备份**: 生产环境请确保Redis数据的备份和高可用配置 + +## 故障排除 + +### 文件权限错误 +确保应用有权限在项目目录创建 `redis-data` 文件夹 + +### Redis连接失败 +检查Redis服务器配置和网络连接: +```bash +# 测试Redis连接 +redis-cli -h your_host -p 6379 ping +``` + +### 模块导入错误 +确保在使用Redis服务的模块中正确导入了RedisModule \ No newline at end of file diff --git a/src/core/redis/file-redis.service.ts b/src/core/redis/file-redis.service.ts new file mode 100644 index 0000000..6cdb20e --- /dev/null +++ b/src/core/redis/file-redis.service.ts @@ -0,0 +1,204 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { IRedisService } from './redis.interface'; + +/** + * 文件模拟Redis服务 + * 在本地开发环境中使用文件系统模拟Redis功能 + */ +@Injectable() +export class FileRedisService implements IRedisService { + private readonly logger = new Logger(FileRedisService.name); + private readonly dataDir = path.join(process.cwd(), 'redis-data'); + private readonly dataFile = path.join(this.dataDir, 'redis.json'); + private data: Map = new Map(); + + constructor() { + this.initializeStorage(); + } + + /** + * 初始化存储 + */ + private async initializeStorage(): Promise { + try { + // 确保数据目录存在 + await fs.mkdir(this.dataDir, { recursive: true }); + + // 尝试加载现有数据 + await this.loadData(); + + // 启动过期清理任务 + this.startExpirationCleanup(); + + this.logger.log('文件Redis服务初始化完成'); + } catch (error) { + this.logger.error('初始化文件Redis服务失败', error); + } + } + + /** + * 从文件加载数据 + */ + private async loadData(): Promise { + try { + const fileContent = await fs.readFile(this.dataFile, 'utf-8'); + const jsonData = JSON.parse(fileContent); + + this.data = new Map(); + for (const [key, item] of Object.entries(jsonData)) { + const typedItem = item as { value: string; expireAt?: number }; + // 检查是否已过期 + if (!typedItem.expireAt || typedItem.expireAt > Date.now()) { + this.data.set(key, typedItem); + } + } + + this.logger.log(`从文件加载了 ${this.data.size} 条Redis数据`); + } catch (error) { + // 文件不存在或格式错误,使用空数据 + this.data = new Map(); + this.logger.log('初始化空的Redis数据存储'); + } + } + + /** + * 保存数据到文件 + */ + private async saveData(): Promise { + try { + const jsonData = Object.fromEntries(this.data); + await fs.writeFile(this.dataFile, JSON.stringify(jsonData, null, 2)); + } catch (error) { + this.logger.error('保存Redis数据到文件失败', error); + } + } + + /** + * 启动过期清理任务 + */ + private startExpirationCleanup(): void { + setInterval(() => { + this.cleanExpiredKeys(); + }, 60000); // 每分钟清理一次过期键 + } + + /** + * 清理过期的键 + */ + private cleanExpiredKeys(): void { + const now = Date.now(); + let cleanedCount = 0; + + for (const [key, item] of this.data.entries()) { + if (item.expireAt && item.expireAt <= now) { + this.data.delete(key); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + this.logger.log(`清理了 ${cleanedCount} 个过期的Redis键`); + this.saveData(); // 保存清理后的数据 + } + } + + async set(key: string, value: string, ttl?: number): Promise { + const item: { value: string; expireAt?: number } = { value }; + + if (ttl && ttl > 0) { + item.expireAt = Date.now() + ttl * 1000; + } + + this.data.set(key, item); + await this.saveData(); + + this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`); + } + + async get(key: string): Promise { + const item = this.data.get(key); + + if (!item) { + return null; + } + + // 检查是否过期 + if (item.expireAt && item.expireAt <= Date.now()) { + this.data.delete(key); + await this.saveData(); + return null; + } + + return item.value; + } + + async del(key: string): Promise { + const existed = this.data.has(key); + this.data.delete(key); + + if (existed) { + await this.saveData(); + this.logger.debug(`删除Redis键: ${key}`); + } + + return existed; + } + + async exists(key: string): Promise { + const item = this.data.get(key); + + if (!item) { + return false; + } + + // 检查是否过期 + if (item.expireAt && item.expireAt <= Date.now()) { + this.data.delete(key); + await this.saveData(); + return false; + } + + return true; + } + + async expire(key: string, ttl: number): Promise { + const item = this.data.get(key); + + if (item) { + item.expireAt = Date.now() + ttl * 1000; + await this.saveData(); + this.logger.debug(`设置Redis键过期时间: ${key}, TTL: ${ttl}秒`); + } + } + + async ttl(key: string): Promise { + const item = this.data.get(key); + + if (!item) { + return -2; // 键不存在 + } + + if (!item.expireAt) { + return -1; // 永不过期 + } + + const remaining = Math.ceil((item.expireAt - Date.now()) / 1000); + + if (remaining <= 0) { + // 已过期,删除键 + this.data.delete(key); + await this.saveData(); + return -2; + } + + return remaining; + } + + async flushall(): Promise { + this.data.clear(); + await this.saveData(); + this.logger.log('清空所有Redis数据'); + } +} \ No newline at end of file diff --git a/src/core/redis/real-redis.service.ts b/src/core/redis/real-redis.service.ts new file mode 100644 index 0000000..ce80ca0 --- /dev/null +++ b/src/core/redis/real-redis.service.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { IRedisService } from './redis.interface'; + +/** + * 真实Redis服务 + * 连接到真实的Redis服务器 + */ +@Injectable() +export class RealRedisService implements IRedisService, OnModuleDestroy { + private readonly logger = new Logger(RealRedisService.name); + private redis: Redis; + + constructor(private configService: ConfigService) { + this.initializeRedis(); + } + + /** + * 初始化Redis连接 + */ + private initializeRedis(): void { + const redisConfig = { + host: this.configService.get('REDIS_HOST', 'localhost'), + port: this.configService.get('REDIS_PORT', 6379), + password: this.configService.get('REDIS_PASSWORD') || undefined, + db: this.configService.get('REDIS_DB', 0), + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, + lazyConnect: true, + }; + + this.redis = new Redis(redisConfig); + + this.redis.on('connect', () => { + this.logger.log('Redis连接成功'); + }); + + this.redis.on('error', (error) => { + this.logger.error('Redis连接错误', error); + }); + + this.redis.on('close', () => { + this.logger.warn('Redis连接关闭'); + }); + } + + async set(key: string, value: string, ttl?: number): Promise { + try { + if (ttl && ttl > 0) { + await this.redis.setex(key, ttl, value); + } else { + await this.redis.set(key, value); + } + this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`); + } catch (error) { + this.logger.error(`设置Redis键失败: ${key}`, error); + throw error; + } + } + + async get(key: string): Promise { + try { + return await this.redis.get(key); + } catch (error) { + this.logger.error(`获取Redis键失败: ${key}`, error); + throw error; + } + } + + async del(key: string): Promise { + try { + const result = await this.redis.del(key); + this.logger.debug(`删除Redis键: ${key}, 结果: ${result > 0}`); + return result > 0; + } catch (error) { + this.logger.error(`删除Redis键失败: ${key}`, error); + throw error; + } + } + + async exists(key: string): Promise { + try { + const result = await this.redis.exists(key); + return result > 0; + } catch (error) { + this.logger.error(`检查Redis键存在性失败: ${key}`, error); + throw error; + } + } + + async expire(key: string, ttl: number): Promise { + try { + await this.redis.expire(key, ttl); + this.logger.debug(`设置Redis键过期时间: ${key}, TTL: ${ttl}秒`); + } catch (error) { + this.logger.error(`设置Redis键过期时间失败: ${key}`, error); + throw error; + } + } + + async ttl(key: string): Promise { + try { + return await this.redis.ttl(key); + } catch (error) { + this.logger.error(`获取Redis键TTL失败: ${key}`, error); + throw error; + } + } + + async flushall(): Promise { + try { + await this.redis.flushall(); + this.logger.log('清空所有Redis数据'); + } catch (error) { + this.logger.error('清空Redis数据失败', error); + throw error; + } + } + + onModuleDestroy(): void { + if (this.redis) { + this.redis.disconnect(); + this.logger.log('Redis连接已断开'); + } + } +} \ No newline at end of file diff --git a/src/core/redis/redis.interface.ts b/src/core/redis/redis.interface.ts new file mode 100644 index 0000000..101b57f --- /dev/null +++ b/src/core/redis/redis.interface.ts @@ -0,0 +1,53 @@ +/** + * Redis接口定义 + * 定义统一的Redis操作接口,支持文件存储和真实Redis切换 + */ +export interface IRedisService { + /** + * 设置键值对 + * @param key 键 + * @param value 值 + * @param ttl 过期时间(秒) + */ + set(key: string, value: string, ttl?: number): Promise; + + /** + * 获取值 + * @param key 键 + * @returns 值或null + */ + get(key: string): Promise; + + /** + * 删除键 + * @param key 键 + * @returns 是否删除成功 + */ + del(key: string): Promise; + + /** + * 检查键是否存在 + * @param key 键 + * @returns 是否存在 + */ + exists(key: string): Promise; + + /** + * 设置过期时间 + * @param key 键 + * @param ttl 过期时间(秒) + */ + expire(key: string, ttl: number): Promise; + + /** + * 获取剩余过期时间 + * @param key 键 + * @returns 剩余时间(秒),-1表示永不过期,-2表示不存在 + */ + ttl(key: string): Promise; + + /** + * 清空所有数据 + */ + flushall(): Promise; +} \ No newline at end of file diff --git a/src/core/redis/redis.module.ts b/src/core/redis/redis.module.ts new file mode 100644 index 0000000..843cae8 --- /dev/null +++ b/src/core/redis/redis.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { FileRedisService } from './file-redis.service'; +import { RealRedisService } from './real-redis.service'; +import { IRedisService } from './redis.interface'; + +/** + * Redis模块 + * 根据环境变量自动选择文件存储或真实Redis服务 + */ +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: 'REDIS_SERVICE', + useFactory: (configService: ConfigService): IRedisService => { + const useFileRedis = configService.get('USE_FILE_REDIS', 'true') === 'true'; + const nodeEnv = configService.get('NODE_ENV', 'development'); + + // 在开发环境或明确配置使用文件Redis时,使用文件存储 + if (nodeEnv === 'development' || useFileRedis) { + return new FileRedisService(); + } else { + return new RealRedisService(configService); + } + }, + inject: [ConfigService], + }, + FileRedisService, + RealRedisService, + ], + exports: ['REDIS_SERVICE'], +}) +export class RedisModule {} \ No newline at end of file