Redis والتخزين المؤقت المتقدم

بناء مشروع طبقة تخزين مؤقت (الجزء الأول)

20 دقيقة الدرس 27 من 30

بناء مشروع طبقة تخزين مؤقت (الجزء الأول)

لنبني طبقة تخزين مؤقت جاهزة للإنتاج لواجهة برمجة تطبيقات RESTful باستخدام Express.js و Redis. سيوضح هذا المشروع أنماط التخزين المؤقت الواقعية وأفضل الممارسات.

بنية المشروع

سنبني واجهة برمجة تطبيقات لكتالوج المنتجات مع استراتيجيات تخزين مؤقت متعددة:

// هيكل المشروع
product-api/
├── src/
│ ├── config/
│ │ └── redis.js // إعدادات Redis
│ ├── middleware/
│ │ ├── cache.js // وسيط الذاكرة المؤقتة
│ │ └── rateLimiter.js // تحديد المعدل
│ ├── services/
│ │ ├── cacheService.js // طبقة تجريد الذاكرة المؤقتة
│ │ └── productService.js // منطق الأعمال
│ ├── controllers/
│ │ └── productController.js
│ ├── routes/
│ │ └── products.js
│ └── app.js
├── package.json
└── .env
ميزات المشروع:
  • نمط cache-aside لبيانات المنتج
  • إبطال الذاكرة المؤقتة عند التحديثات
  • تسخين تلقائي للذاكرة المؤقتة
  • مقاييس نجاح/فشل الذاكرة المؤقتة
  • تحديد المعدل باستخدام Redis
  • معالجة اتصال Redis بأناقة

إعداد API باستخدام Express

أولاً، لنقم بإعداد تطبيق Express الأساسي:

// package.json
{
"name": "product-api",
"version": "1.0.0",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"express": "^4.18.2",
"redis": "^4.6.0",
"dotenv": "^16.0.3",
"mongoose": "^7.0.0"
},
"devDependencies": {
"nodemon": "^2.0.20"
}
}
// .env
PORT=3000
MONGODB_URI=mongodb://localhost:27017/productdb
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
CACHE_TTL=3600
NODE_ENV=development
// src/app.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const redisClient = require('./config/redis');
const productRoutes = require('./routes/products');

const app = express();
const PORT = process.env.PORT || 3000;

// الوسيطات
app.use(express.json());

// نقطة نهاية فحص الصحة
app.get('/health', async (req, res) => {
const health = {
uptime: process.uptime(),
timestamp: Date.now(),
mongodb: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected',
redis: redisClient.isReady ? 'connected' : 'disconnected'
};
res.json(health);
});

// المسارات
app.use('/api/products', productRoutes);

// معالجة الأخطاء
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});

// الاتصال بـ MongoDB
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('MongoDB متصل'))
.catch(err => console.error('خطأ في اتصال MongoDB:', err));

// بدء الخادم
app.listen(PORT, () => {
console.log(`الخادم يعمل على المنفذ ${PORT}`);
});

// الإغلاق الأنيق
process.on('SIGTERM', async () => {
console.log('تلقي SIGTERM، الإغلاق بشكل أنيق...');
await redisClient.quit();
await mongoose.connection.close();
process.exit(0);
});

إعداد اتصال Redis

أنشئ عميل Redis قوي مع معالجة الأخطاء ومنطق إعادة الاتصال:

// src/config/redis.js
const redis = require('redis');

const redisClient = redis.createClient({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('تم الوصول إلى حد إعادة الاتصال بـ Redis');
return new Error('فشل إعادة الاتصال بـ Redis');
}
// التراجع الأسي
return Math.min(retries * 100, 3000);
}
},
password: process.env.REDIS_PASSWORD || undefined,
database: 0
});

// معالجات الأحداث
redisClient.on('connect', () => {
console.log('عميل Redis يتصل...');
});

redisClient.on('ready', () => {
console.log('عميل Redis جاهز');
});

redisClient.on('error', (err) => {
console.error('خطأ في Redis:', err);
});

redisClient.on('reconnecting', () => {
console.log('عميل Redis يعيد الاتصال...');
});

// الاتصال بـ Redis
redisClient.connect().catch(err => {
console.error('فشل الاتصال بـ Redis:', err);
});

module.exports = redisClient;

تنفيذ نمط Cache-Aside

أنشئ خدمة تخزين مؤقت قابلة لإعادة الاستخدام مع نمط cache-aside:

// src/services/cacheService.js
const redisClient = require('../config/redis');

class CacheService {
constructor() {
this.defaultTTL = parseInt(process.env.CACHE_TTL) || 3600;
this.metrics = {
hits: 0,
misses: 0,
errors: 0
};
}

/**
* الحصول على البيانات من الذاكرة المؤقتة أو جلبها من المصدر
* @param {string} key - مفتاح الذاكرة المؤقتة
* @param {Function} fetchFn - دالة لجلب البيانات عند فقدان الذاكرة المؤقتة
* @param {number} ttl - وقت البقاء بالثواني
*/
async get(key, fetchFn, ttl = this.defaultTTL) {
try {
// محاولة الحصول من الذاكرة المؤقتة
const cached = await redisClient.get(key);

if (cached) {
this.metrics.hits++;
return JSON.parse(cached);
}

// فقدان الذاكرة المؤقتة - جلب من المصدر
this.metrics.misses++;
const data = await fetchFn();

// التخزين في الذاكرة المؤقتة (نار وننسى)
this.set(key, data, ttl).catch(err => {
console.error('خطأ في تعيين الذاكرة المؤقتة:', err);
});

return data;

} catch (err) {
this.metrics.errors++;
console.error('خطأ في الحصول على الذاكرة المؤقتة:', err);
// التراجع إلى جلب البيانات مباشرة
return await fetchFn();
}
}

/**
* تعيين البيانات في الذاكرة المؤقتة
*/
async set(key, value, ttl = this.defaultTTL) {
try {
const serialized = JSON.stringify(value);
await redisClient.setEx(key, ttl, serialized);
return true;
} catch (err) {
console.error('خطأ في تعيين الذاكرة المؤقتة:', err);
return false;
}
}

/**
* حذف المفتاح من الذاكرة المؤقتة
*/
async del(key) {
try {
await redisClient.del(key);
return true;
} catch (err) {
console.error('خطأ في حذف الذاكرة المؤقتة:', err);
return false;
}
}

/**
* حذف مفاتيح متعددة تطابق النمط
*/
async delPattern(pattern) {
try {
const keys = await redisClient.keys(pattern);
if (keys.length > 0) {
await redisClient.del(keys);
}
return keys.length;
} catch (err) {
console.error('خطأ في حذف نمط الذاكرة المؤقتة:', err);
return 0;
}
}

/**
* الحصول على مقاييس الذاكرة المؤقتة
*/
getMetrics() {
const total = this.metrics.hits + this.metrics.misses;
return {
hits: this.metrics.hits,
misses: this.metrics.misses,
errors: this.metrics.errors,
hitRate: total > 0 ? (this.metrics.hits / total * 100).toFixed(2) : 0
};
}

/**
* إعادة تعيين المقاييس
*/
resetMetrics() {
this.metrics = { hits: 0, misses: 0, errors: 0 };
}
}

module.exports = new CacheService();

وسيط الذاكرة المؤقتة

أنشئ وسيط Express للتخزين المؤقت التلقائي للاستجابة:

// src/middleware/cache.js
const cacheService = require('../services/cacheService');

/**
* وسيط الذاكرة المؤقتة لمسارات Express
* @param {number} ttl - وقت البقاء بالثواني
* @param {Function} keyFn - دالة اختيارية لتوليد مفتاح الذاكرة المؤقتة
*/
const cacheMiddleware = (ttl = 3600, keyFn = null) => {
return async (req, res, next) => {
// تخزين مؤقت لطلبات GET فقط
if (req.method !== 'GET') {
return next();
}

// توليد مفتاح الذاكرة المؤقتة
const cacheKey = keyFn
? keyFn(req)
: `cache:${req.originalUrl || req.url}`;

try {
// محاولة الحصول من الذاكرة المؤقتة
const cached = await cacheService.get(
cacheKey,
() => null, // لا تجلب عند الفقدان، دع معالج المسار يفعل ذلك
ttl
);

if (cached) {
// تعيين رأس الذاكرة المؤقتة
res.set('X-Cache', 'HIT');
return res.json(cached);
}

// فقدان الذاكرة المؤقتة - اعترض الاستجابة
res.set('X-Cache', 'MISS');
const originalJson = res.json.bind(res);

res.json = (data) => {
// تخزين مؤقت للاستجابات الناجحة فقط
if (res.statusCode === 200 && data) {
cacheService.set(cacheKey, data, ttl).catch(err => {
console.error('فشل تخزين الاستجابة مؤقتاً:', err);
});
}
return originalJson(data);
};

next();

} catch (err) {
console.error('خطأ في وسيط الذاكرة المؤقتة:', err);
// الاستمرار دون تخزين مؤقت عند الخطأ
next();
}
};
};

/**
* وسيط لإبطال الذاكرة المؤقتة حسب النمط
*/
const invalidateCacheMiddleware = (patternFn) => {
return async (req, res, next) => {
// تخزين res.json الأصلي للاستدعاء بعد الإبطال
const originalJson = res.json.bind(res);

res.json = async (data) => {
// الإبطال فقط عند العمليات الناجحة
if (res.statusCode >= 200 && res.statusCode < 300) {
const pattern = patternFn(req);
await cacheService.delPattern(pattern);
}
return originalJson(data);
};

next();
};
};

module.exports = {
cacheMiddleware,
invalidateCacheMiddleware
};
استراتيجية مفتاح الذاكرة المؤقتة: استخدم مفاتيح وصفية وهرمية مثل `cache:products:list`, `cache:products:123`, `cache:categories:5:products` لتمكين الإبطال القائم على النمط وسهولة التصحيح.

نموذج المنتج والخدمة

أنشئ نموذج بيانات المنتج وطبقة الخدمة:

// src/models/product.js (نموذج Mongoose)
const mongoose = require('mongoose');

const productSchema = new mongoose.Schema({
name: { type: String, required: true, index: true },
description: String,
price: { type: Number, required: true, min: 0 },
category: { type: String, required: true, index: true },
stock: { type: Number, default: 0 },
imageUrl: String,
tags: [String],
isActive: { type: Boolean, default: true }
}, {
timestamps: true
});

// فهارس للاستعلامات الشائعة
productSchema.index({ category: 1, price: 1 });
productSchema.index({ name: 'text', description: 'text' });

module.exports = mongoose.model('Product', productSchema);
// src/services/productService.js
const Product = require('../models/product');
const cacheService = require('./cacheService');

class ProductService {
/**
* الحصول على جميع المنتجات مع التخزين المؤقت
*/
async getAll(filters = {}) {
const cacheKey = `products:list:${JSON.stringify(filters)}`;

return await cacheService.get(
cacheKey,
async () => {
const query = { isActive: true };
if (filters.category) query.category = filters.category;
if (filters.minPrice) query.price = { $gte: filters.minPrice };
if (filters.maxPrice) query.price = { ...query.price, $lte: filters.maxPrice };

return await Product.find(query)
.sort({ createdAt: -1 })
.limit(100)
.lean();
},
300 // 5 دقائق TTL للقائمة
);
}

/**
* الحصول على المنتج بالمعرف مع التخزين المؤقت
*/
async getById(id) {
const cacheKey = `products:${id}`;

return await cacheService.get(
cacheKey,
async () => {
const product = await Product.findById(id).lean();
if (!product) {
throw new Error('المنتج غير موجود');
}
return product;
},
3600 // ساعة واحدة TTL للمنتجات الفردية
);
}

/**
* إنشاء منتج جديد
*/
async create(productData) {
const product = await Product.create(productData);

// إبطال ذاكرة القائمة المؤقتة
await cacheService.delPattern('products:list:*');

return product;
}

/**
* تحديث المنتج وإبطال الذاكرة المؤقتة
*/
async update(id, updates) {
const product = await Product.findByIdAndUpdate(id, updates, { new: true });

if (!product) {
throw new Error('المنتج غير موجود');
}

// إبطال ذاكرة المنتج المحدد والقوائم المؤقتة
await cacheService.del(`products:${id}`);
await cacheService.delPattern('products:list:*');

return product;
}

/**
* حذف المنتج
*/
async delete(id) {
const product = await Product.findByIdAndDelete(id);

if (!product) {
throw new Error('المنتج غير موجود');
}

// إبطال الذاكرة المؤقتة
await cacheService.del(`products:${id}`);
await cacheService.delPattern('products:list:*');

return product;
}
}

module.exports = new ProductService();
تمرين: أنشئ متحكم منتج يستخدم productService وينفذ نقاط النهاية التالية:
  • GET /api/products - قائمة جميع المنتجات (مع فلتر فئة اختياري)
  • GET /api/products/:id - الحصول على منتج واحد
  • POST /api/products - إنشاء منتج جديد
  • PUT /api/products/:id - تحديث المنتج
  • DELETE /api/products/:id - حذف المنتج
أضف وسيط ذاكرة مؤقتة مناسباً ومنطق إبطال لكل مسار.
ملخص الجزء الأول: لقد بنينا أساس طبقة التخزين المؤقت لدينا مع إدارة اتصال Redis، خدمة ذاكرة مؤقتة قابلة لإعادة الاستخدام تنفذ نمط cache-aside، وسيط Express للتخزين المؤقت التلقائي، وخدمة منتج مع إبطال ذاكرة مؤقتة متكامل. في الجزء الثاني، سنضيف إدارة الجلسات وتحديد المعدل والميزات في الوقت الفعلي.