feat:添加Redis缓存服务
- 实现Redis服务接口和抽象层 - 提供真实Redis服务实现 (RealRedisService) - 提供文件模拟Redis服务 (FileRedisService) 用于开发测试 - 支持基本的Redis操作:get、set、del、exists、ttl - 添加Redis模块配置和依赖注入
This commit is contained in:
200
src/core/redis/README.md
Normal file
200
src/core/redis/README.md
Normal file
@@ -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
|
||||
204
src/core/redis/file-redis.service.ts
Normal file
204
src/core/redis/file-redis.service.ts
Normal file
@@ -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<string, { value: string; expireAt?: number }> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.initializeStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化存储
|
||||
*/
|
||||
private async initializeStorage(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
this.data.clear();
|
||||
await this.saveData();
|
||||
this.logger.log('清空所有Redis数据');
|
||||
}
|
||||
}
|
||||
127
src/core/redis/real-redis.service.ts
Normal file
127
src/core/redis/real-redis.service.ts
Normal file
@@ -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<string>('REDIS_HOST', 'localhost'),
|
||||
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||
password: this.configService.get<string>('REDIS_PASSWORD') || undefined,
|
||||
db: this.configService.get<number>('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<void> {
|
||||
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<string | null> {
|
||||
try {
|
||||
return await this.redis.get(key);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取Redis键失败: ${key}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
try {
|
||||
return await this.redis.ttl(key);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取Redis键TTL失败: ${key}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async flushall(): Promise<void> {
|
||||
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连接已断开');
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/core/redis/redis.interface.ts
Normal file
53
src/core/redis/redis.interface.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Redis接口定义
|
||||
* 定义统一的Redis操作接口,支持文件存储和真实Redis切换
|
||||
*/
|
||||
export interface IRedisService {
|
||||
/**
|
||||
* 设置键值对
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param ttl 过期时间(秒)
|
||||
*/
|
||||
set(key: string, value: string, ttl?: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取值
|
||||
* @param key 键
|
||||
* @returns 值或null
|
||||
*/
|
||||
get(key: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 删除键
|
||||
* @param key 键
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
del(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查键是否存在
|
||||
* @param key 键
|
||||
* @returns 是否存在
|
||||
*/
|
||||
exists(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 设置过期时间
|
||||
* @param key 键
|
||||
* @param ttl 过期时间(秒)
|
||||
*/
|
||||
expire(key: string, ttl: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取剩余过期时间
|
||||
* @param key 键
|
||||
* @returns 剩余时间(秒),-1表示永不过期,-2表示不存在
|
||||
*/
|
||||
ttl(key: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 清空所有数据
|
||||
*/
|
||||
flushall(): Promise<void>;
|
||||
}
|
||||
34
src/core/redis/redis.module.ts
Normal file
34
src/core/redis/redis.module.ts
Normal file
@@ -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<string>('USE_FILE_REDIS', 'true') === 'true';
|
||||
const nodeEnv = configService.get<string>('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 {}
|
||||
Reference in New Issue
Block a user