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

التخزين المؤقت لاستجابات API

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

التخزين المؤقت لاستجابات API

التخزين المؤقت لاستجابات API أمر بالغ الأهمية لبناء خدمات ويب عالية الأداء. من خلال التخزين المؤقت لاستجابات API في Redis، يمكنك تقليل حمل قاعدة البيانات، وتقليل أوقات الاستجابة، وتحسين قابلية التوسع. يغطي هذا الدرس أنماطاً عملية للتخزين المؤقت لاستجابات REST API في Node.js.

Middleware التخزين المؤقت الأساسي لـ API

أنشئ middleware بسيطة تخزن طلبات GET مؤقتاً:

const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis();

// middleware التخزين المؤقت الأساسية
function apiCache(durationSeconds) {
  return async (req, res, next) => {
    // تخزين طلبات GET فقط مؤقتاً
    if (req.method !== 'GET') {
      return next();
    }

    const key = `cache:${req.originalUrl}`;

    try {
      // حاول الحصول على الاستجابة المخزنة مؤقتاً
      const cachedResponse = await redis.get(key);

      if (cachedResponse) {
        console.log('إصابة ذاكرة التخزين المؤقت:', key);
        return res.json(JSON.parse(cachedResponse));
      }

      console.log('فشل ذاكرة التخزين المؤقت:', key);

      // اعترض res.json لتخزين الاستجابة مؤقتاً
      const originalJson = res.json.bind(res);
      res.json = function(data) {
        // تخزين الاستجابة مؤقتاً
        redis.setex(key, durationSeconds, JSON.stringify(data));
        return originalJson(data);
      };

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

// الاستخدام
app.get('/api/products',
  apiCache(300), // التخزين المؤقت لمدة 5 دقائق
  async (req, res) => {
    const products = await db.query('SELECT * FROM products');
    res.json(products);
  }
);
نصيحة: قم دائماً بالتخزين المؤقت لطلبات GET فقط. يجب عدم تخزين طلبات POST وPUT وDELETE وPATCH مؤقتاً لأنها تعدل البيانات.

تصميم مفتاح ذاكرة التخزين المؤقت المتقدم

صمم مفاتيح ذاكرة تخزين مؤقت تأخذ في الاعتبار معاملات الاستعلام والترقيم والمرشحات:

function generateCacheKey(req) {
  const baseKey = req.path;
  const params = req.query;

  // فرز معاملات الاستعلام للحصول على مفاتيح متسقة
  const sortedParams = Object.keys(params)
    .sort()
    .map(key => `${key}=${params[key]}`)
    .join('&');

  return sortedParams
    ? `cache:${baseKey}?${sortedParams}`
    : `cache:${baseKey}`;
}

// middleware محسنة مع توليد مفتاح مخصص
function smartApiCache(durationSeconds, options = {}) {
  const { keyGenerator = generateCacheKey } = options;

  return async (req, res, next) => {
    if (req.method !== 'GET') return next();

    const key = keyGenerator(req);

    try {
      const cachedResponse = await redis.get(key);

      if (cachedResponse) {
        const data = JSON.parse(cachedResponse);
        res.set('X-Cache', 'HIT');
        return res.json(data);
      }

      res.set('X-Cache', 'MISS');

      const originalJson = res.json.bind(res);
      res.json = function(data) {
        redis.setex(key, durationSeconds, JSON.stringify(data));
        return originalJson(data);
      };

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

// الاستخدام مع معاملات الاستعلام
app.get('/api/products', smartApiCache(300), async (req, res) => {
  const { category, page = 1, limit = 20 } = req.query;
  const products = await db.products.find({ category, page, limit });
  res.json(products);
});
ملاحظة: يجب أن تكون مفاتيح ذاكرة التخزين المؤقت حتمية. قم دائماً بفرز معاملات الاستعلام للتأكد من أن /api/products?page=1&limit=20 و /api/products?limit=20&page=1 تولد نفس مفتاح ذاكرة التخزين المؤقت.

التخزين المؤقت الشرطي

تخزين الاستجابات مؤقتاً فقط بناءً على رموز الحالة وأنواع المحتوى:

function conditionalCache(durationSeconds) {
  return async (req, res, next) => {
    if (req.method !== 'GET') return next();

    const key = generateCacheKey(req);

    try {
      const cached = await redis.get(key);
      if (cached) {
        const { status, headers, body } = JSON.parse(cached);
        res.status(status).set(headers).json(body);
        return;
      }

      const originalJson = res.json.bind(res);
      res.json = function(data) {
        // تخزين الاستجابات الناجحة فقط مؤقتاً (رموز حالة 2xx)
        if (res.statusCode >= 200 && res.statusCode < 300) {
          const cacheData = {
            status: res.statusCode,
            headers: res.getHeaders(),
            body: data
          };
          redis.setex(key, durationSeconds, JSON.stringify(cacheData));
        }
        return originalJson(data);
      };

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

التخزين المؤقت الخاص بالمستخدم

تخزين الاستجابات مؤقتاً لكل مستخدم لـ APIs المصادق عليها:

function userSpecificCache(durationSeconds) {
  return async (req, res, next) => {
    if (req.method !== 'GET') return next();

    // تضمين معرف المستخدم في مفتاح ذاكرة التخزين المؤقت
    const userId = req.user?.id || 'anonymous';
    const baseKey = generateCacheKey(req);
    const key = `${baseKey}:user:${userId}`;

    try {
      const cached = await redis.get(key);
      if (cached) {
        return res.json(JSON.parse(cached));
      }

      const originalJson = res.json.bind(res);
      res.json = function(data) {
        redis.setex(key, durationSeconds, JSON.stringify(data));
        return originalJson(data);
      };

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

// الاستخدام
app.get('/api/user/dashboard',
  authenticateUser, // middleware المصادقة
  userSpecificCache(120), // التخزين المؤقت لمدة دقيقتين لكل مستخدم
  async (req, res) => {
    const dashboard = await getDashboardData(req.user.id);
    res.json(dashboard);
  }
);
تحذير: عند التخزين المؤقت لبيانات خاصة بالمستخدم، تأكد من أن مفاتيح ذاكرة التخزين المؤقت تتضمن معرفات المستخدمين لمنع تسرب البيانات بين المستخدمين. لا تخزن أبداً معلومات شخصية حساسة مؤقتاً دون تشفير.

إبطال ذاكرة التخزين المؤقت

نفذ إبطال ذاكرة التخزين المؤقت عند تعديل البيانات:

// دالة مساعدة لإبطال ذاكرة التخزين المؤقت
async function invalidateCache(pattern) {
  try {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
      console.log(`تم إبطال ${keys.length} إدخال ذاكرة تخزين مؤقت`);
    }
  } catch (error) {
    console.error('خطأ إبطال ذاكرة التخزين المؤقت:', error);
  }
}

// الإبطال على POST/PUT/DELETE
app.post('/api/products', async (req, res) => {
  const product = await db.products.create(req.body);

  // إبطال جميع ذاكرات التخزين المؤقت لقائمة المنتجات
  await invalidateCache('cache:/api/products*');

  res.status(201).json(product);
});

app.put('/api/products/:id', async (req, res) => {
  const product = await db.products.update(req.params.id, req.body);

  // إبطال ذاكرات التخزين المؤقت للمنتج المحدد والقائمة
  await invalidateCache(`cache:/api/products/${req.params.id}*`);
  await invalidateCache('cache:/api/products?*');
  await invalidateCache('cache:/api/products');

  res.json(product);
});

app.delete('/api/products/:id', async (req, res) => {
  await db.products.delete(req.params.id);

  // إبطال جميع ذاكرات التخزين المؤقت ذات الصلة
  await invalidateCache('cache:/api/products*');

  res.status(204).send();
});
نصيحة: استخدم Redis SCAN بدلاً من KEYS في الإنتاج لمجموعات المفاتيح الكبيرة لتجنب حظر خادم Redis. أمر KEYS محظور ويمكن أن يسبب مشاكل في الأداء.

رؤوس ذاكرة التخزين المؤقت للتخزين المؤقت من جانب العميل

ادمج التخزين المؤقت في Redis مع رؤوس ذاكرة التخزين المؤقت لـ HTTP:

function apiCacheWithHeaders(redisTTL, httpMaxAge) {
  return async (req, res, next) => {
    if (req.method !== 'GET') return next();

    const key = generateCacheKey(req);

    try {
      const cached = await redis.get(key);

      if (cached) {
        res.set({
          'Cache-Control': `public, max-age=${httpMaxAge}`,
          'X-Cache': 'HIT'
        });
        return res.json(JSON.parse(cached));
      }

      const originalJson = res.json.bind(res);
      res.json = function(data) {
        res.set({
          'Cache-Control': `public, max-age=${httpMaxAge}`,
          'X-Cache': 'MISS'
        });
        redis.setex(key, redisTTL, JSON.stringify(data));
        return originalJson(data);
      };

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

// التخزين المؤقت في Redis لمدة 5 دقائق، المتصفح لمدة دقيقة واحدة
app.get('/api/config',
  apiCacheWithHeaders(300, 60),
  async (req, res) => {
    const config = await getAppConfig();
    res.json(config);
  }
);

تحليلات ذاكرة التخزين المؤقت

تتبع مقاييس أداء ذاكرة التخزين المؤقت:

const cacheStats = {
  hits: 0,
  misses: 0,
  errors: 0
};

function analyticsCache(durationSeconds) {
  return async (req, res, next) => {
    if (req.method !== 'GET') return next();

    const key = generateCacheKey(req);
    const startTime = Date.now();

    try {
      const cached = await redis.get(key);

      if (cached) {
        cacheStats.hits++;
        const duration = Date.now() - startTime;
        res.set({
          'X-Cache': 'HIT',
          'X-Cache-Time': `${duration}ms`
        });
        return res.json(JSON.parse(cached));
      }

      cacheStats.misses++;

      const originalJson = res.json.bind(res);
      res.json = function(data) {
        const duration = Date.now() - startTime;
        res.set({
          'X-Cache': 'MISS',
          'X-Response-Time': `${duration}ms`
        });
        redis.setex(key, durationSeconds, JSON.stringify(data));
        return originalJson(data);
      };

      next();
    } catch (error) {
      cacheStats.errors++;
      console.error('خطأ ذاكرة التخزين المؤقت:', error);
      next();
    }
  };
}

// نقطة نهاية إحصائيات ذاكرة التخزين المؤقت
app.get('/api/cache/stats', (req, res) => {
  const total = cacheStats.hits + cacheStats.misses;
  const hitRate = total > 0 ? (cacheStats.hits / total * 100).toFixed(2) : 0;

  res.json({
    hits: cacheStats.hits,
    misses: cacheStats.misses,
    errors: cacheStats.errors,
    hitRate: `${hitRate}%`,
    total
  });
});
ملاحظة: معدل إصابة ذاكرة التخزين المؤقت فوق 80% يشير عادةً إلى تخزين مؤقت فعال. أقل من 50% يشير إلى أن TTL لذاكرة التخزين المؤقت الخاصة بك قد يكون قصيراً جداً أو أن بياناتك تتغير بشكل متكرر جداً.
تمرين: أنشئ blog API مع ثلاث نقاط نهاية: قائمة المنشورات (مُرَقَّمَة)، تفاصيل منشور واحد، وتعليقات المنشور. نفذ التخزين المؤقت مع TTL لمدة 10 دقائق للقوائم، TTL لمدة 30 دقيقة للتفاصيل، والإبطال عند إنشاء/تحديث المنشورات. أضف تحليلات ذاكرة التخزين المؤقت وقس معدلات الإصابة بعد 100 طلب.