تطوير الويب

Pub/Sub لحظي بـ Redis 8: إشعارات وchat

4 min de lecture

📌 المقال الرئيسي: Redis 8: caching، queues، pub/sub، streams

آليّة publish/subscribe في Redis تُتيح بثّ أحداث لحظيًّا بين عدّة عمليات. ناشر يُرسل رسالة على قناة مُسَمَّاة؛ كلّ المُشترَكين يتلقّونها فورًا. يبني هذا الدليل تطبيق chat لحظي بـ WebSocket + Socket.IO + Redis pub/sub، قابل للتوسيع الأفقي على عدّة خوادم Node.js.

المتطلّبات

  • Redis 8 يعمل.
  • Node.js 22 LTS.
  • أساسيات async/await وWebSocket.
  • الوقت: 60 دقيقة.

الخطوة 1 — فهم نموذج pub/sub في Redis

ثلاثة أوامر تُكَوِّن واجهة pub/sub: SUBSCRIBE canal1 [canal2 ...] يُشترك عميل في قناة أو أكثر؛ PUBLISH canal message يُرسل رسالة؛ PSUBSCRIBE pattern يُشترك في pattern. ملاحظة: عميل في وضع subscribe لا يستطيع تنفيذ أوامر عادية على هذا الاتّصال — لذلك ضرورة اتّصالَين في تطبيق.

# Terminal 1: مُشترَك
redis-cli --user appuser -a "$PWD" SUBSCRIBE notifications

# Terminal 2: ناشر
redis-cli --user appuser -a "$PWD" PUBLISH notifications '{"user":"salim","action":"login"}'

Terminal 1 يعرض الرسالة فورًا. زمن الاستجابة على شبكة محلّية دون مللي ثانية.

الخطوة 2 — تطبيق Node.js مع Socket.IO

Socket.IO framework WebSocket المرجعي في Node.js. مقترنًا بـ Redis pub/sub عبر adapter @socket.io/redis-adapter، يصير قابلًا للتوسيع الأفقي.

mkdir chat-redis-demo
cd chat-redis-demo
npm init -y
npm install express socket.io @socket.io/redis-adapter ioredis

الخطوة 3 — كود خادم الـ chat

// server.js
import { createServer } from 'http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'ioredis';
import express from 'express';

const app = express();
app.use(express.static('public'));
const httpServer = createServer(app);
const io = new Server(httpServer, { cors: { origin: '*' } });

const pubClient = new (await import('ioredis')).default({
  host: '127.0.0.1', port: 6379,
  username: 'appuser', password: process.env.REDIS_PASSWORD
});
const subClient = pubClient.duplicate();

io.adapter(createAdapter(pubClient, subClient));

io.on('connection', (socket) => {
  console.log('Client ' + socket.id + ' connecté');

  socket.on('rejoindre-salle', (salle) => {
    socket.join(salle);
    socket.to(salle).emit('utilisateur-rejoint', { socketId: socket.id, salle });
  });

  socket.on('message', ({ salle, contenu }) => {
    const payload = { de: socket.id, contenu, ts: Date.now() };
    io.to(salle).emit('message', payload);
  });

  socket.on('disconnect', () => {
    console.log('Client ' + socket.id + ' déconnecté');
  });
});

httpServer.listen(3000, () => console.log('Chat sur http://localhost:3000'));

اتّصالان لـ Redis: pubClient للنشر وsubClient مُكَرَّر للاشتراك. الـ adapter يُعيد كتابة io.to(room).emit() شفافيًّا إلى PUBLISH على Redis، يستلمها كلّ instances Socket.IO المُتَّصِلة بنفس Redis. شَغِّل عدّة instances مع PM2 أو Docker Compose.

الخطوة 4 — عميل web مُختزَل

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Chat Redis</title></head>
<body>
  <h1>Chat temps réel</h1>
  <input id="salle" placeholder="Nom de la salle" value="general">
  <button id="rejoindre">Rejoindre</button>
  <ul id="messages"></ul>
  <input id="msg" placeholder="Votre message...">
  <button id="envoyer">Envoyer</button>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();
    let salleActuelle = null;

    document.getElementById('rejoindre').onclick = () => {
      salleActuelle = document.getElementById('salle').value;
      socket.emit('rejoindre-salle', salleActuelle);
    };

    document.getElementById('envoyer').onclick = () => {
      const contenu = document.getElementById('msg').value;
      socket.emit('message', { salle: salleActuelle, contenu });
      document.getElementById('msg').value = '';
    };

    socket.on('message', (m) => {
      const li = document.createElement('li');
      li.textContent = m.de.slice(0,4) + ': ' + m.contenu;
      document.getElementById('messages').appendChild(li);
    });

    socket.on('utilisateur-rejoint', (u) => {
      const li = document.createElement('li');
      li.style.color = '#888';
      li.textContent = u.socketId.slice(0,4) + ' a rejoint';
      document.getElementById('messages').appendChild(li);
    });
  </script>
</body></html>

الخطوة 5 — نمط fan-out للإشعارات

// service-commande.js
async function creerCommande(commande) {
  // ... منطق العمل ...
  await pubClient.publish('commandes:creees', JSON.stringify({
    id:     commande.id,
    userId: commande.userId,
    total:  commande.total,
    ts:     Date.now()
  }));
}

// service-email.js (عملية منفصلة)
const sub = new Redis(/* config */);
sub.subscribe('commandes:creees');
sub.on('message', async (canal, payload) => {
  const cmd = JSON.parse(payload);
  await envoyerEmailConfirmation(cmd.userId, cmd.id);
});

// service-stock.js (عملية أخرى)
const sub2 = new Redis(/* config */);
sub2.subscribe('commandes:creees');
sub2.on('message', async (canal, payload) => {
  const cmd = JSON.parse(payload);
  await decrementerStock(cmd.id);
});

هذه المعمارية تفصل الخدمات: إضافة مستهلِك جديد لا يتطلّب تغييرًا في الناشر. الحدّ: إن كانت خدمة email ساقطة وقت PUBLISH، الرسالة مفقودة. لأحداث العمل الحرجة، فضّل Redis Streams.

الخطوة 6 — PSUBSCRIBE لـ patterns

// اشتراك بكلّ قنوات منطقة
sub.psubscribe('region:riyadh:*');

sub.on('pmessage', (pattern, canal, msg) => {
  console.log('Reçu sur ' + canal + ' : ' + msg);
});

// الناشر ينشر على قنوات محدّدة:
pub.publish('region:riyadh:commandes',  JSON.stringify(c));
pub.publish('region:riyadh:livraisons', JSON.stringify(l));
pub.publish('region:dubai:commandes',   JSON.stringify(c2));

المُشترك عبر pattern region:riyadh:* يستقبل الرسالتين الأوّليتين دون الثالثة (Dubai). الـ patterns تدعم *، ?، و[abc]. انتبه: PSUBSCRIBE أكثر كلفة قليلًا.

الخطوة 7 — Sentinel وadapter

const pubClient = new Redis({
  sentinels: [
    { host: 'redis-sentinel-1', port: 26379 },
    { host: 'redis-sentinel-2', port: 26379 },
    { host: 'redis-sentinel-3', port: 26379 }
  ],
  name: 'mymaster',
  username: 'appuser',
  password: process.env.REDIS_PASSWORD
});

عند failover، Sentinel ينتخب maître جديدًا وioredis يُعيد الاتّصال تلقائيًّا.

الخطوة 8 — حدود pub/sub في Redis

ثلاثة حدود مهمّة. أوّلًا: الرسائل غير مُداومة: إن لم يكن مُشترك يسمع وقت PUBLISH، الرسالة مفقودة. ثانيًا: لا ضمان تسليم — عميل بطيء يمتلئ buffer قد يُفصَل مع فقدان رسائل. ثالثًا: الترتيب مضمون لكلّ قناة لكن ليس بين القنوات. لحالات تتطلّب استمرارية أو ordering صارم، استعمل Redis Streams.

pub/sub يبقى مثاليًّا لـ: إشعارات UI لحظية، broadcasts عابرة (حضور المستخدم، typing)، إبطال cache موزَّع، ونشر أحداث لمستهلِكين idempotents.

أخطاء شائعة

الخطأ السبب الحلّ
Cannot execute command while subscribed اتّصال واحد للاشتراك والأوامر أنشئ اتّصالَين عبر client.duplicate()
رسائل مفقودة عشوائيًّا عميل بطيء، buffer ممتلئ، فصل قسري زد client-output-buffer-limit pubsub
رسائل مكرّرة Subscribe في حلقة عند إعادة الاتّصال اشترك مرّة، استمع ‘ready’ لإعادة subscribe
زمن استجابة مرتفع إشباع CPU monothread لـ Redis انتقل إلى Cluster أو عدّة Redis مخصَّصة

الأدلّة التالية

🔝 العودة للدليل الرئيسي

FAQ

Pub/Sub Redis أم WebSocket صرف؟ WebSocket يُدير اتّصال متصفّح↔خادم. Redis pub/sub يُدير broadcast بين خوادم Node.js. لـ instance واحد، WebSocket يكفي. للتوسيع الأفقي، Redis pub/sub ضروري.

كم subscribers أقصى؟ Redis يُدير عشرات الآلاف بلا صعوبة على قناة واحدة. الحدّ العملي هو عرض النطاق الشبكي. لـ 100,000+، فكّر في معمارية pub/sub هرمية.

كيف نوثّق WebSocket؟ مَرِّر JWT في query string عند io()، تحقّق منه في handler connection، خزّن الهوية في socket.data. لا تثق ببيانات العميل دون تحقّق.

مراجع

  • Pub/Sub Redis — التوثيق الرسمي
  • Socket.IO Redis Adapter
  • أمر SUBSCRIBE
  • أمر PUBLISH

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité