دليلك الشامل لإنشاء حل واجهة برمجة تطبيقات (API) احترافي للدردشة باستخدام Sockets في NodeJS للمبتدئين
مقدمة: بناء حل متكامل لواجهة برمجة تطبيقات الدردشة باستخدام Sockets في NodeJS
هل تساءلت يوماً كيف تعمل تطبيقات الدردشة خلف الكواليس؟ اليوم، سأصحبك في جولة تفصيلية حول كيفية إنشاء تطبيق يعتمد على REST و Sockets، مبني على أساس NodeJS و ExpressJS، ويستخدم MongoDB كقاعدة بيانات. لقد عملت على محتوى هذا المقال لأكثر من أسبوع، وآمل حقاً أن يكون مفيداً للجميع.
المتطلبات الأساسية لبدء العمل
قبل الغوص في تفاصيل التطبيق، تأكد من توفر المتطلبات التالية على جهازك:
- إعداد MongoDB على جهازك: دليل التثبيت
- تثبيت Node/NPM على جهازك: رابط التثبيت (أنا أستخدم الإصدار Node v12.18.0).
المحاور الرئيسية التي سنتناولها في هذا الدليل
نظرة عامة على البنية الأساسية
- إنشاء خادم Express.
- كيفية إجراء عمليات التحقق (التحقق من صحة البيانات) لواجهة برمجة التطبيقات (API validations).
- إنشاء هيكل أساسي للتطبيق بأكمله.
إعداد قاعدة البيانات والمستخدمين
- إعداد MongoDB (التثبيت، الإعداد في Express).
- إنشاء واجهة برمجة تطبيقات (API) للمستخدمين وقاعدة البيانات الخاصة بهم (إنشاء مستخدم، الحصول على مستخدم بواسطة المعرف (ID)، الحصول على جميع المستخدمين، حذف مستخدم بواسطة المعرف (ID)).
فهم المصادقة والـ Middlewares
- فهم ماهية الـ middleware.
- مصادقة JWT (JSON Web Tokens) (فك التشفير/التشفير) – middleware لتسجيل الدخول.
إدارة الاتصالات عبر WebSockets
- فئة Web Socket التي تتعامل مع الأحداث عند قطع اتصال المستخدم، إضافة هويته، الانضمام إلى غرفة دردشة، أو كتم صوت غرفة دردشة.
نموذج قاعدة بيانات غرف الدردشة والرسائل
- مناقشة نموذج قاعدة بيانات غرف الدردشة ورسائل الدردشة.
وظائف واجهة برمجة تطبيقات الدردشة (Chat API)
- بدء محادثة بين المستخدمين.
- إنشاء رسالة في غرفة الدردشة.
- عرض محادثة لغرفة دردشة بواسطة معرفها.
- وضع علامة "مقروء" على محادثة بأكملها (مشابه لتطبيق WhatsApp).
- الحصول على المحادثات الأخيرة من جميع الدردشات (مشابه لتطبيق Facebook Messenger).
ميزات إضافية (Bonus API)
- حذف غرفة دردشة بواسطة معرفها مع جميع رسائلها المرتبطة.
- حذف رسالة بواسطة معرفها.
قبل أن نبدأ، أردت أن أتطرق إلى بعض الأساسيات في الفيديوهات التالية:
- فهم أساسيات ExpressJS.
- ما هي الـ routes؟ الـ controllers؟
- كيف نسمح بـ CORS (Cross-Origin Resource Sharing)؟
- كيف نسمح للمستخدم النهائي بإرسال البيانات بتنسيق JSON في طلب API؟
أتحدث عن كل هذا وأكثر (بما في ذلك اتفاقيات REST) في هذا الفيديو:

وهنا رابط GitHub للرمز المصدري الكامل لهذا الفيديو [Chapter 0]. يرجى الاطلاع على ملف README.md للرمز المصدري الخاص بـ "[Chapter 0]". يحتوي على جميع روابط التعلم ذات الصلة التي أذكرها في الفيديو، بالإضافة إلى برنامج تعليمي رائع لمدة نصف ساعة حول Postman.
إضافة التحقق من صحة واجهة برمجة التطبيقات (API validation) لنقاط النهاية
في الفيديو أدناه، ستتعلم كيفية كتابة التحقق المخصص الخاص بك باستخدام مكتبة تسمى "make-validation":

هنا رابط GitHub للرمز المصدري الكامل لهذا الفيديو [Chapter 0]. وهنا رابط مكتبة make-validation: [GitHub][npm][example]. يمكن العثور على الرمز المصدري الكامل لهذا البرنامج التعليمي هنا. إذا كان لديك أي ملاحظات، يرجى التواصل معي على http://twitter.com/adeelibr. إذا أعجبك هذا البرنامج التعليمي، يرجى ترك نجمة على مستودع GitHub.
البدء الفعلي: إعداد مشروع الدردشة
الآن بعد أن عرفت أساسيات ExpressJS وكيفية التحقق من استجابة المستخدم، لنبدأ.
إنشاء هيكل المشروع وتثبيت التبعيات
ابدأ بإنشاء مجلد باسم chat-app:
mkdir chat-app;
cd chat-app;
ثم قم بتهيئة مشروع npm جديد في مجلد مشروعك الرئيسي عن طريق كتابة ما يلي:
npm init -y
وقم بتثبيت الحزم التالية:
npm i cors @withvoid/make-validation express jsonwebtoken mongoose morgan socket.io uuid --save;
npm i nodemon --save-dev;
وفي قسم scripts في ملف package.json الخاص بك، أضف السكريبتين التاليين:
"scripts": {
"start": "nodemon server/index.js",
"start:server": "node server/index.js"
},
يجب أن يبدو ملف package.json الخاص بك الآن بهذا الشكل:
{
"name": "chapter-1-chat",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "nodemon server/index.js",
"start:server": "node server/index.js"
},
"dependencies": {
"@withvoid/make-validation": "1.0.5",
"cors": "2.8.5",
"express": "4.16.1",
"jsonwebtoken": "8.5.1",
"mongoose": "5.9.18",
"morgan": "1.9.1",
"socket.io": "2.3.0",
"uuid": "8.1.0"
},
"devDependencies": {
"nodemon": "2.0.4"
}
}
ممتاز! الآن في مجلد مشروعك الرئيسي، أنشئ مجلداً جديداً باسم server:
cd chat-app;
mkdir server;
cd server;
داخل مجلد server، أنشئ ملفاً باسم index.js وأضف المحتوى التالي إليه:
import http from "http";
import express from "express";
import logger from "morgan";
import cors from "cors";
// routes
import indexRouter from "./routes/index.js";
import userRouter from "./routes/user.js";
import chatRoomRouter from "./routes/chatRoom.js";
import deleteRouter from "./routes/delete.js";
// middlewares
import { decode } from './middlewares/jwt.js'
const app = express();
/** Get port from environment and store in Express. */
const port = process.env.PORT || "3000";
app.set("port", port);
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use("/", indexRouter);
app.use("/users", userRouter);
app.use("/room", decode, chatRoomRouter);
app.use("/delete", deleteRouter);
/** catch 404 and forward to error handler */
app.use('*', (req, res) => {
return res.status(404).json({ success: false, message: 'API endpoint doesnt exist' })
});
/** Create HTTP server. */
const server = http.createServer(app);
/** Listen on provided port, on all network interfaces. */
server.listen(port);
/** Event listener for HTTP server "listening" event. */
server.on("listening", () => {
console.log(`Listening on port:: http://localhost:${port}/`)
});
تعريف المسارات (Routes)
لنضف المسارات لـ indexRouter و userRouter و chatRoomRouter و deleteRouter. في مجلد مشروعك الرئيسي، أنشئ مجلداً باسم routes. داخل مجلد routes، أضف الملفات التالية: index.js، user.js، chatRoom.js، delete.js.
لنبدأ بإضافة المحتوى لـ routes/index.js أولاً:
import express from 'express';
// controllers
import users from '../controllers/user.js';
// middlewares
import { encode } from '../middlewares/jwt.js';
const router = express.Router();
router
.post('/login/:userId', encode, (req, res, next) => {
});
export default router;
لنضف المحتوى لـ routes/user.js تالياً:
import express from 'express';
// controllers
import user from '../controllers/user.js';
const router = express.Router();
router
.get('/', user.onGetAllUsers)
.post('/', user.onCreateUser)
.get('/:id', user.onGetUserById)
.delete('/:id', user.onDeleteUserById)
export default router;
والآن لنضف المحتوى لـ routes/chatRoom.js:
import express from 'express';
// controllers
import chatRoom from '../controllers/chatRoom.js';
const router = express.Router();
router
.get('/', chatRoom.getRecentConversation)
.get('/:roomId', chatRoom.getConversationByRoomId)
.post('/initiate', chatRoom.initiate)
.post('/:roomId/message', chatRoom.postMessage)
.put('/:roomId/mark-read', chatRoom.markConversationReadByRoomId)
export default router;
أخيراً، لنضف المحتوى لـ routes/delete.js:
import express from 'express';
// controllers
import deleteController from '../controllers/delete.js';
const router = express.Router();
router
.delete('/room/:roomId', deleteController.deleteRoomById)
.delete('/message/:messageId', deleteController.deleteMessageById)
export default router;
إنشاء وحدات التحكم (Controllers)
ممتاز، الآن بعد أن تم وضع مساراتنا، لنضف وحدات التحكم لكل مسار. أنشئ مجلداً جديداً باسم controllers. داخل هذا المجلد، أنشئ الملفات التالية: user.js، chatRoom.js، delete.js.
لنبدأ بـ controllers/user.js:
export default {
onGetAllUsers: async (req, res) => {
},
onGetUserById: async (req, res) => {
},
onCreateUser: async (req, res) => {
},
onDeleteUserById: async (req, res) => {
},
}
بعد ذلك، لنضف المحتوى في controllers/chatRoom.js:
export default {
initiate: async (req, res) => {
},
postMessage: async (req, res) => {
},
getRecentConversation: async (req, res) => {
},
getConversationByRoomId: async (req, res) => {
},
markConversationReadByRoomId: async (req, res) => {
},
}
وأخيراً، لنضف المحتوى لـ controllers/delete.js:
export default {
deleteRoomById: async (req, res) => {},
deleteMessageById: async (req, res) => {},
}
حتى الآن، أضفنا وحدات تحكم فارغة لكل مسار، لذا فهي لا تفعل الكثير بعد. سنضيف الوظائف بعد قليل. شيء واحد آخر – لنضف مجلداً جديداً باسم middlewares وداخل هذا المجلد أنشئ ملفاً باسم jwt.js. ثم أضف المحتوى التالي إليه:
import jwt from 'jsonwebtoken';
export const decode = (req, res, next) => {}
export const encode = async (req, res, next) => {}
سأتحدث عما يفعله هذا الملف بعد قليل، لذا لن تجاهله في الوقت الحالي.

الانتهاء من الهيكل الأساسي للتطبيق
لقد انتهينا من الهيكل الأساسي للتعليمات البرمجية. لقد قمنا بما يلي:
- إنشاء خادم Express يستمع على المنفذ
3000. - إضافة CORS (Cross-Origin Resource Sharing) إلى ملف
server.jsالخاص بنا. - إضافة مسجل (logger) إلى ملف
server.jsالخاص بنا. - وإضافة معالجات المسارات (route handlers) مع وحدات تحكم فارغة.
لا شيء معقد حتى الآن لم أغطيه في الفيديوهات أعلاه.
إعداد MongoDB في تطبيقنا
قبل إضافة MongoDB إلى قاعدة التعليمات البرمجية الخاصة بنا، تأكد من تثبيته على جهازك عن طريق تشغيل أحد الأوامر التالية:
- لمستخدمي Windows، دليل التثبيت هنا.
- لمستخدمي macOS، دليل التثبيت هنا.
- لمستخدمي Linux، دليل التثبيت هنا.
إذا واجهت مشكلات في تثبيت MongoDB، فقط أخبرني على https://twitter.com/adeelibr وسأحاول كتابة دليل مخصص لك أو إنشاء فيديو تعليمي للتثبيت. 🙂
أنا أستخدم Robo3T كواجهة رسومية لـ MongoDB. الآن يجب أن يكون لديك مثيل MongoDB يعمل و Robo3T مثبتاً. (يمكنك استخدام أي عميل واجهة رسومية تفضله لهذا الغرض. أنا أحب Robo3T كثيراً لذا أستخدمه. كما أنه مفتوح المصدر.)
إليك فيديو قصير وجدته على YouTube يقدم مقدمة مدتها 6 دقائق عن Robo3T:

بمجرد أن يصبح مثيل MongoDB جاهزاً للعمل، لنبدأ بدمج MongoDB في التعليمات البرمجية الخاصة بنا أيضاً.
تهيئة إعدادات قاعدة البيانات
في مجلدك الرئيسي، أنشئ مجلداً جديداً باسم config. داخل هذا المجلد، أنشئ ملفاً باسم index.js وأضف المحتوى التالي:
const config = {
db: {
url: 'localhost:27017',
name: 'chatdb'
}
}
export default config
عادةً ما يكون المنفذ الافتراضي الذي تعمل عليه مثيلات MongoDB هو 27017. هنا نحدد معلومات حول عنوان URL لقاعدة البيانات الخاصة بنا (التي توجد في db) واسم قاعدة البيانات وهو chatdb (يمكنك تسميتها بأي اسم تريده).
بعد ذلك، أنشئ ملفاً جديداً باسم config/mongo.js وأضف المحتوى التالي:
import mongoose from 'mongoose'
import config from './index.js'
const CONNECTION_URL = `mongodb://${config.db.url}/${config.db.name}`
mongoose.connect(CONNECTION_URL, {
useNewUrlParser: true,
useUnifiedTopology: true
})
mongoose.connection.on('connected', () => {
console.log('Mongo has connected succesfully')
})
mongoose.connection.on('reconnected', () => {
console.log('Mongo has reconnected')
})
mongoose.connection.on('error', error => {
console.log('Mongo connection has an error', error)
mongoose.disconnect()
})
mongoose.connection.on('disconnected', () => {
console.log('Mongo connection is disconnected')
})
بعد ذلك، قم باستيراد config/mongo.js في ملف server/index.js الخاص بك بهذا الشكل:
. .
// mongo connection
import "./config/mongo.js";
// routes
import indexRouter from "./routes/index.js";
إذا ضللت في أي وقت، فإن الرمز المصدري الكامل لهذا البرنامج التعليمي موجود هنا.
شرح خطوات الاتصال بـ MongoDB
دعنا نناقش ما نفعله هنا خطوة بخطوة:
- نقوم أولاً باستيراد ملف
config.jsالخاص بنا فيconfig/mongo.js. - بعد ذلك، نمرر القيمة إلى
CONNECTION_URLالخاص بنا بهذا الشكل:
const CONNECTION_URL = `mongodb://${config.db.url}/${config.db.name}`
- ثم باستخدام
CONNECTION_URL، نقوم بإنشاء اتصال Mongo، عن طريق القيام بذلك:
mongoose.connect(CONNECTION_URL, { useNewUrlParser: true, useUnifiedTopology: true })
هذا يخبر mongoose بإنشاء اتصال بقاعدة البيانات باستخدام تطبيق Node/Express الخاص بنا. الخيارات التي نمنحها لـ Mongo هنا هي:
useNewUrlParser: لقد أهمل برنامج تشغيل MongoDB محلل سلسلة الاتصال الحالي. تخبرuseNewUrlParser: truemongoose باستخدام المحلل الجديد بواسطة Mongo. (إذا تم تعيينه علىtrue، يجب علينا توفير منفذ قاعدة بيانات فيCONNECTION_URL.)useUnifiedTopology: القيمة الافتراضيةfalse. عيّنها علىtrueللموافقة على استخدام محرك إدارة الاتصال الجديد لبرنامج تشغيل MongoDB. يجب عليك تعيين هذا الخيار علىtrue، باستثناء الحالة غير المحتملة التي تمنعك من الحفاظ على اتصال مستقر.
بعد ذلك، نضيف ببساطة معالجات أحداث mongoose بهذا الشكل:
mongoose.connection.on('connected', () => {
console.log('Mongo has connected succesfully')
})
mongoose.connection.on('reconnected', () => {
console.log('Mongo has reconnected')
})
mongoose.connection.on('error', error => {
console.log('Mongo connection has an error', error)
mongoose.disconnect()
})
mongoose.connection.on('disconnected', () => {
console.log('Mongo connection is disconnected')
})
- سيتم استدعاء
connectedبمجرد إنشاء اتصال قاعدة البيانات. - سيتم استدعاء
disconnectedعند تعطيل اتصال Mongo الخاص بك. - يتم استدعاء
errorإذا كان هناك خطأ في الاتصال بقاعدة بيانات Mongo الخاصة بك. - يتم استدعاء حدث
reconnectedعندما تفقد قاعدة البيانات الاتصال ثم تحاول إعادة الاتصال بنجاح.
بمجرد الانتهاء من ذلك، ما عليك سوى الانتقال إلى ملف server/index.js الخاص بك واستيراد config/mongo.js. وهذا كل شيء. الآن عندما تبدأ تشغيل الخادم الخاص بك عن طريق كتابة هذا:
npm start;
يجب أن ترى شيئاً كهذا:

إذا رأيت هذا، فقد أضفت Mongo بنجاح إلى تطبيقك. تهانينا! إذا علقت هنا لسبب ما، فأخبرني على twitter.com/adeelibr وسأحاول حل المشكلة لك. 🙂
إعداد قسم API الأول للمستخدمين
لن يحتوي إعداد API الخاص بنا لـ users/ على رمز مصادقة لهذا البرنامج التعليمي، لأن تركيزي الرئيسي هو تعليمك حول تطبيق الدردشة هنا.
نموذج مخطط المستخدم (User Model Scheme)
لننشئ نموذجنا الأول (مخطط قاعدة البيانات) لمجموعة user. أنشئ مجلداً جديداً باسم models. داخل هذا المجلد، أنشئ ملفاً باسم User.js وأضف المحتوى التالي:
import mongoose from "mongoose";
import { v4 as uuidv4 } from "uuid";
export const USER_TYPES = {
CONSUMER: "consumer",
SUPPORT: "support",
};
const userSchema = new mongoose.Schema(
{
_id: {
type: String,
default: () => uuidv4().replace(/\-/g, ""),
},
firstName: String,
lastName: String,
type: String,
},
{
timestamps: true,
collection: "users",
}
);
export default mongoose.model("User", userSchema);
دعنا نقسم هذا إلى أجزاء:
export const USER_TYPES = {
CONSUMER: "consumer",
SUPPORT: "support",
};
سيكون لدينا نوعان من المستخدمين بشكل أساسي: consumer و support. لقد كتبتها بهذه الطريقة لأنني أريد ضمان التحقق من صحة API وقاعدة البيانات برمجياً، وهو ما سأتحدث عنه لاحقاً.
بعد ذلك، ننشئ مخططاً لكيفية ظهور document واحد (كائن/عنصر/إدخال/صف) داخل مجموعة user الخاصة بنا (المجموعة مكافئة لجدول MySQL). نحددها بهذا الشكل:
const userSchema = new mongoose.Schema(
{
_id: {
type: String,
default: () => uuidv4().replace(/\-/g, ""),
},
firstName: String,
lastName: String,
type: String,
},
{
timestamps: true,
collection: "users",
}
);
هنا نخبر mongoose أنه بالنسبة لمستند واحد في مجموعة users الخاصة بنا، نريد أن يكون الهيكل بهذا الشكل:
{
id: String // سيحصل على سلسلة عشوائية افتراضياً بفضل uuidv4
firstName: String,
lastName: String,
type: String // يمكن أن يكون من نوعين consumer/support
}
في الجزء الثاني من المخطط لدينا شيء من هذا القبيل:
{
timestamps: true,
collection: "users",
}
تعيين timestamps إلى true سيضيف شيئين إلى مخططنا: قيمة تاريخ createdAt و updatedAt. في كل مرة ننشئ فيها إدخالاً جديداً، سيتم تحديث createdAt تلقائياً وسيتم تحديث updatedAt بمجرد تحديث إدخال في قاعدة البيانات باستخدام mongoose. يتم كل هذا تلقائياً بواسطة mongoose.
الجزء الثاني هو collection. يوضح هذا ما سيكون عليه اسم مجموعتي داخل قاعدة البيانات الخاصة بي. أنا أخصص لها اسم users.
ثم أخيراً، سنقوم بتصدير الكائن بهذا الشكل:
export default mongoose.model("User", userSchema);
لذا، فإن mongoose.model يأخذ معاملين هنا:
- اسم النموذج، وهو
Userهنا. - المخطط المرتبط بهذا النموذج، وهو
userSchemaفي هذه الحالة.
ملاحظة: بناءً على اسم النموذج، وهو User في هذه الحالة، لا نضيف مفتاح collection في قسم المخطط. سيأخذ اسم User هذا ويضيف إليه حرف s وينشئ مجموعة باسمه، والتي تصبح users.
رائع، الآن لدينا نموذجنا الأول. إذا علقت في أي مكان، فقط ألق نظرة على الرمز المصدري.
إنشاء API لمستخدم جديد (POST request)
بعد ذلك، لنكتب وحدة التحكم الأولى لهذا المسار: .post('/', user.onCreateUser). انتقل إلى controllers/user.js واستورد شيئين في الأعلى:
// utils
import makeValidation from '@withvoid/make-validation';
// models
import UserModel, { USER_TYPES } from '../models/User.js';
هنا نستورد مكتبة التحقق التي تحدثت عنها في الفيديو في الأعلى. نستورد أيضاً نموذج المستخدم الخاص بنا جنباً إلى جنب مع USER_TYPES من نفس الملف. هذا ما تمثله USER_TYPES:
export const USER_TYPES = {
CONSUMER: "consumer",
SUPPORT: "support",
};
بعد ذلك، ابحث عن وحدة التحكم onCreateUser وأضف المحتوى التالي إليها:
onCreateUser: async (req, res) => {
try {
const validation = makeValidation(types => ({
payload: req.body,
checks: {
firstName: { type: types.string },
lastName: { type: types.string },
type: { type: types.enum, options: { enum: USER_TYPES } },
}
}));
if (!validation.success) return res.status(400).json(validation);
const { firstName, lastName, type } = req.body;
const user = await UserModel.createUser(firstName, lastName, type);
return res.status(200).json({ success: true, user });
} catch (error) {
return res.status(500).json({ success: false, error: error })
}
},
دعنا نقسم هذا إلى قسمين. أولاً، نتحقق من صحة استجابة المستخدم عن طريق القيام بذلك:
const validation = makeValidation(types => ({
payload: req.body,
checks: {
firstName: { type: types.string },
lastName: { type: types.string },
type: { type: types.enum, options: { enum: USER_TYPES } },
}
}));
if (!validation.success) return res.status(400).json({ ...validation });
يرجى التأكد من أنك شاهدت الفيديو (أعلاه) حول validate an API request in Node using custom validation or by using make-validation library. هنا نستخدم مكتبة make-validation (التي انتهيت من إنشائها أثناء كتابة هذا البرنامج التعليمي). أتحدث عن استخدامها في الفيديو في بداية هذا البرنامج التعليمي. كل ما نفعله هنا هو تمرير req.body إلى payload. ثم في عمليات التحقق (checks) نضيف كائناً حيث نخبر مقابل كل key ما هي متطلبات كل نوع، على سبيل المثال:
firstName: { type: types.string },
هنا نخبره أن firstName من نوع string. إذا نسي المستخدم إضافة هذه القيمة أثناء استدعاء API، أو إذا كان النوع ليس string، فسيتم إلقاء خطأ. سيعيد المتغير validation كائناً بثلاثة أشياء: {success: boolean, message: string, errors: object}. إذا كانت validation.success خطأ، فإننا ببساطة نعيد كل شيء من التحقق ونعطيه للمستخدم برمز حالة 400.
بمجرد الانتهاء من التحقق ونعرف أن البيانات التي نحصل عليها صالحة، فإننا نقوم بما يلي:
const { firstName, lastName, type } = req.body;
const user = await UserModel.createUser(firstName, lastName, type);
return res.status(200).json({ success: true, user });
ثم نقوم بتفكيك firstName و lastName و type من req.body ونمرر تلك القيم إلى UserModel.createUser الخاص بنا. إذا سار كل شيء على ما يرام، فإنه ببساطة يعيد success: true مع المستخدم الجديد الذي تم إنشاؤه جنباً إلى جنب مع حالة 200.
إذا حدث خطأ في أي مكان في هذه العملية، فإنه يلقي خطأ وينتقل إلى كتلة catch:
catch (error) {
return res.status(500).json({ success: false, error: error })
}
هناك، نعيد ببساطة رسالة خطأ جنباً إلى جنب مع حالة HTTP 500.
الشيء الوحيد الذي نفتقده هنا هو طريقة UserModel.createUser(). لذا لنعد إلى ملف models/User.js ونضيفها:
userSchema.statics.createUser = async function (firstName, lastName, type) {
try {
const user = await this.create({ firstName, lastName, type });
return user;
} catch (error) {
throw error;
}
}
export default mongoose.model("User", userSchema);
كل ما نفعله هنا هو إضافة طريقة ثابتة (static method) إلى userSchema الخاص بنا تسمى createUser والتي تأخذ 3 معلمات: firstName و lastName و type.
بعد ذلك نستخدم هذا:
const user = await this.create({ firstName, lastName, type });
الجزء this هنا مهم جداً، لأننا نكتب طريقة ثابتة على userSchema. كتابة this ستضمن أننا نجري عمليات على كائن userSchema.
شيء واحد يجب ملاحظته هنا هو أن userSchema.statics.createUser = async function (firstName, lastName, type) => {} لن يعمل. إذا استخدمت دالة سهم (=> arrow function)، فسيتم فقدان سياق this ولن يعمل. إذا كنت ترغب في معرفة المزيد عن static methods في mongoose، فراجع هذا المثال القصير والمفيد للوثائق هنا.
الآن بعد أن قمنا بإعداد كل شيء، لنبدأ تشغيل الطرفية عن طريق تشغيل الأمر التالي في مجلد المشروع الرئيسي:
npm start;
انتقل إلى Postman، وقم بإعداد طلب POST على API هذا http://localhost:3000/users، وأضف النص التالي إلى API:
{
firstName: 'John'
lastName: 'Doe',
type: 'consumer'
}
مثل هذا:

يمكنك أيضاً الحصول على مجموعة Postman API الكاملة من هنا حتى لا تضطر إلى كتابة واجهات برمجة التطبيقات مراراً وتكراراً.
رائع – لقد انتهينا للتو من إنشاء أول API لنا. لننشئ عدداً قليلاً من واجهات برمجة تطبيقات المستخدم قبل الانتقال إلى جزء الدردشة لأنه لا توجد دردشة بدون مستخدمين (إلا إذا كان لدينا روبوتات، ولكن الروبوتات هي مستخدمون أيضاً؟).
الحصول على مستخدم بواسطة معرفه (ID) – GET request
بعد ذلك، نحتاج إلى كتابة API يتيح لنا الحصول على مستخدم بواسطة معرفه (ID). لذا بالنسبة لمسارنا .get('/:id', user.onGetUserById)، لنكتب وحدة التحكم الخاصة به. انتقل إلى controllers/user.js وبالنسبة للطريقة onGetUserById، اكتب هذا:
onGetUserById: async (req, res) => {
try {
const user = await UserModel.getUserById(req.params.id);
return res.status(200).json({ success: true, user });
} catch (error) {
return res.status(500).json({ success: false, error: error })
}
},
رائع، هذا يبدو مباشراً. لنضف UserModel.getUserById() في ملف models/User.js الخاص بنا. أضف هذه الطريقة أسفل الطريقة الثابتة (static method) الأخيرة التي كتبتها:
userSchema.statics.getUserById = async function (id) {
try {
const user = await this.findOne({ _id: id });
if (!user) throw ({ error: 'No user with this id found' });
return user;
} catch (error) {
throw error;
}
}
نمرر معامل id ونلف دالتنا في try/catch. هذا مهم جداً عند استخدام async/await. السطور التي يجب التركيز عليها هنا هي هذين السطرين:
const user = await this.findOne({ _id: id });
if (!user) throw ({ error: 'No user with this id found' });
نستخدم طريقة findOne الخاصة بـ mongoose للعثور على إدخال بواسطة id. نعلم أن عنصراً واحداً فقط موجود في المجموعة بواسطة هذا id لأن id فريد. إذا لم يتم العثور على مستخدم، فإننا ببساطة نلقي خطأ برسالة No user with this id found. وهذا كل شيء!
لنبدأ تشغيل الخادم الخاص بنا:
npm start;
افتح Postman وأنشئ طلب GET على http://localhost:3000/users/:id. ملاحظة: أنا أستخدم معرف آخر مستخدم أنشأناه للتو.

أحسنت صنعاً! عمل جيد. بقي اثنان من واجهات برمجة التطبيقات لقسم المستخدم الخاص بنا.
الحصول على جميع المستخدمين – GET request
بالنسبة لمسارنا في .get('/', user.onGetAllUsers)، لنضف معلومات إلى وحدة التحكم الخاصة به. انتقل إلى controllers/user.js وأضف الرمز في طريقة onGetAllUsers():
onGetAllUsers: async (req, res) => {
try {
const users = await UserModel.getUsers();
return res.status(200).json({ success: true, users });
} catch (error) {
return res.status(500).json({ success: false, error: error })
}
},
بعد ذلك، لننشئ الطريقة الثابتة لـ getUsers() في ملف models/User.js. أسفل الطريقة الثابتة الأخيرة التي كتبتها في هذا الملف، اكتب:
userSchema.statics.getUsers = async function () {
try {
const users = await this.find();
return users;
} catch (error) {
throw error;
}
}
نستخدم طريقة mongoose المسماة await this.find(); للحصول على جميع السجلات لمجموعة users الخاصة بنا وإعادتها. ملاحظة: أنا لا أتعامل مع الترحيل (pagination) في API المستخدمين لدينا لأن هذا ليس التركيز الرئيسي هنا. سأتحدث عن الترحيل بمجرد الانتقال إلى واجهات برمجة تطبيقات الدردشة الخاصة بنا.
لنبدأ تشغيل الخادم الخاص بنا:
npm start;
افتح Postman وأنشئ طلب GET لهذا المسار http://localhost:3000/users:

لقد قمت بإنشاء عدد قليل من المستخدمين الإضافيين. 🙂
حذف مستخدم بواسطة معرفه (ID) – DELETE request (قسم إضافي، يمكنك تخطيه إذا أردت)
لننشئ مسارنا الأخير لحذف مستخدم بواسطة معرفه. بالنسبة للمسار .delete('/:id', user.onDeleteUserById)، انتقل إلى وحدة التحكم الخاصة به في controllers/user.js واكتب هذا الرمز في طريقة onDeleteUserById():
onDeleteUserById: async (req, res) => {
try {
const user = await UserModel.deleteByUserById(req.params.id);
return res.status(200).json({ success: true, message: `Deleted a count of ${user.deletedCount} user.` });
} catch (error) {
return res.status(500).json({ success: false, error: error })
}
},
لنضف الطريقة الثابتة deleteByUserById في models/User.js:
userSchema.statics.deleteByUserById = async function (id) {
try {
const result = await this.remove({ _id: id });
return result;
} catch (error) {
throw error;
}
}
نمرر id هنا كمعامل ثم نستخدم طريقة mongoose المسماة this.remove لحذف عنصر سجل من مجموعة معينة. في هذه الحالة، إنها مجموعة users.
لنبدأ تشغيل الخادم الخاص بنا:
npm start;
انتقل إلى Postman وأنشئ مسار DELETE جديد:

بهذا نختتم قسم API للمستخدم. بعد ذلك سنتناول كيفية مصادقة المسارات باستخدام رمز مصادقة. هذا هو آخر شيء أريد التطرق إليه قبل الانتقال إلى قسم الدردشة – لأن جميع واجهات برمجة تطبيقات الدردشة ستكون مصادقة.
فهم الـ Middlewares في ExpressJS وكتابتها
كيف يمكننا كتابتها؟ عن طريق إضافة JWT middleware في تطبيقك:

وهنا رابط GitHub للرمز المصدري الكامل لهذا الفيديو [Chapter 0]. ومرة أخرى، يمكن العثور على جميع المعلومات ذات الصلة في ملف README.md.
بالعودة إلى قاعدة التعليمات البرمجية الخاصة بنا، لننشئ JWT middleware لمصادقة مساراتنا. انتقل إلى middlewares/jwt.js وأضف ما يلي:
import jwt from 'jsonwebtoken';
// models
import UserModel from '../models/User.js';
const SECRET_KEY = 'some-secret-key';
export const encode = async (req, res, next) => {
try {
const { userId } = req.params;
const user = await UserModel.getUserById(userId);
const payload = {
userId: user._id,
userType: user.type,
};
const authToken = jwt.sign(payload, SECRET_KEY);
console.log('Auth', authToken);
req.authToken = authToken;
next();
} catch (error) {
return res.status(400).json({ success: false, message: error.error });
}
}
export const decode = (req, res, next) => {
if (!req.headers['authorization']) {
return res.status(400).json({ success: false, message: 'No access token provided' });
}
const accessToken = req.headers.authorization.split(' ')[1];
try {
const decoded = jwt.verify(accessToken, SECRET_KEY);
req.userId = decoded.userId;
req.userType = decoded.type;
return next();
} catch (error) {
return res.status(401).json({ success: false, message: error.message });
}
}
شرح طريقة encode
لنناقش طريقة encode أولاً:
export const encode = async (req, res, next) => {
try {
const { userId } = req.params;
const user = await UserModel.getUserById(userId);
const payload = {
userId: user._id,
userType: user.type,
};
const authToken = jwt.sign(payload, SECRET_KEY);
console.log('Auth', authToken);
req.authToken = authToken;
next();
} catch (error) {
return res.status(400).json({ success: false, message: error.error });
}
}
دعنا نمر عليها خطوة بخطوة. نحصل على userId من req.params الخاص بنا. إذا تذكرت من الفيديو السابق، فإن req.params هو /:<identifier> المحدد في قسم المسارات لدينا.
بعد ذلك، نستخدم طريقة const user = await UserModel.getUserById(userId); التي أنشأناها مؤخراً للحصول على معلومات المستخدم. إذا كان موجوداً، أي – وإلا فإن هذا السطر سيلقي خطأ وسينتقل مباشرة إلى كتلة catch حيث سنعيد المستخدم باستجابة 400 ورسالة خطأ.
ولكن إذا حصلنا على استجابة من طريقة getUserById، فإننا ننشئ حمولة (payload):
const payload = {
userId: user._id,
userType: user.type,
};
بعد ذلك، نوقع هذه الحمولة في JWT باستخدام ما يلي:
const authToken = jwt.sign(payload, SECRET_KEY);
بمجرد توقيع JWT، نقوم بما يلي:
req.authToken = authToken;
next();
نقوم بتعيينها إلى req.authToken الخاص بنا ثم نرسل هذه المعلومات كـ next().
شرح طريقة decode
بعد ذلك، لنناقش طريقة decode:
export const decode = (req, res, next) => {
if (!req.headers['authorization']) {
return res.status(400).json({ success: false, message: 'No access token provided' });
}
const accessToken = req.headers.authorization.split(' ')[1];
try {
const decoded = jwt.verify(accessToken, SECRET_KEY);
req.userId = decoded.userId;
req.userType = decoded.type;
return next();
} catch (error) {
return res.status(401).json({ success: false, message: error.message });
}
}
دعنا نقسم هذا:
if (!req.headers['authorization']) {
return res.status(400).json({ success: false, message: 'No access token provided' });
}
أولاً نتحقق مما إذا كان رأس authorization موجوداً أم لا. إذا لم يكن كذلك، فإننا ببساطة نعيد رسالة خطأ للمستخدم.
ثم نقوم بذلك:
const accessToken = req.headers.authorization.split(' ')[1];
يتم split(' ') بواسطة مسافة ثم نحصل على الفهرس الثاني للمصفوفة عن طريق الوصول إلى فهرسها [1] لأن الاصطلاح هو authorization: Bearer <auth-token>. هل تريد قراءة المزيد عن هذا؟ تحقق من هذا الموضوع الجيد على Quora.
ثم نحاول فك تشفير الرمز المميز الخاص بنا:
try {
const decoded = jwt.verify(accessToken, SECRET_KEY);
req.userId = decoded.userId;
req.userType = decoded.type;
return next();
} catch (error) {
return res.status(401).json({ success: false, message: error.message });
}
إذا لم يكن هذا ناجحاً، فستقوم jwt.verify(accessToken, SECRET_KEY) ببساطة بإلقاء خطأ وسينتقل الرمز الخاص بنا إلى كتلة catch على الفور. إذا كان ناجحاً، فيمكننا فك تشفيره. نحصل على userId و type من الرمز المميز ونحفظهما كـ req.userId, req.userType ونضغط ببساطة على next().
الآن، للمضي قدماً، سيكون لكل مسار يمر عبر decode middleware هذا id المستخدم الحالي ونوعه. هذا كل ما يتعلق بقسم الـ middleware.
إنشاء مسار تسجيل الدخول (Login Route) – POST request
لننشئ مسار login حتى نتمكن من طلب معلومات المستخدم وإعطاء رمز مميز في المقابل (لأنهم سيحتاجون إلى رمز مميز للوصول إلى بقية واجهات برمجة تطبيقات الدردشة).
انتقل إلى ملف routes/index.js والصق المحتوى التالي:
import express from 'express';
// middlewares
import { encode } from '../middlewares/jwt.js';
const router = express.Router();
router
.post('/login/:userId', encode, (req, res, next) => {
return res
.status(200)
.json({ success: true, authorization: req.authToken });
});
export default router;
كل ما نفعله هو إضافة encode middleware إلى مسار http://localhost:3000/login/:<user-id> [POST] الخاص بنا. إذا سار كل شيء بسلاسة، سيحصل المستخدم على رمز authorization.
ملاحظة: أنا لا أضيف تدفق تسجيل دخول/تسجيل، لكنني ما زلت أرغب في التطرق إلى JWT/middleware في هذا البرنامج التعليمي. عادة ما تتم المصادقة بطريقة مماثلة. الإضافة الوحيدة هنا هي أن المستخدم لا يقدم معرفه. يقدمون اسم المستخدم وكلمة المرور (التي نتحقق منها في قاعدة البيانات)، وإذا كان كل شيء صحيحاً، فإننا نمنحهم رمز مصادقة.
إذا علقت في أي مكان حتى هذه النقطة، فقط اكتب لي على twitter.com/adeelibr، وبهذه الطريقة يمكنني تحسين المحتوى. يمكنك أيضاً الكتابة لي إذا كنت ترغب في تعلم شيء آخر. للتذكير، الرمز المصدري الكامل متاح هنا. لست مضطراً للبرمجة مع هذا البرنامج التعليمي، ولكن إذا فعلت ذلك، فستترسخ المفاهيم بشكل أفضل.
لنختبر مسار /login الخاص بنا الآن. ابدأ تشغيل الخادم الخاص بك:
npm start;
لنقم بتشغيل Postman. أنشئ طلب POST جديد http://localhost:3000/login/<user-id>:

عندما يكون معرف المستخدم صحيحاً:

عندما يكون معرف المستخدم غير صالح.
بهذا نكون قد انتهينا من تدفق تسجيل الدخول أيضاً. كان هذا كثيراً. ولكن الآن يمكننا التركيز فقط على مسارات الدردشة الخاصة بنا.
إنشاء فئة Web Socket
ستتعامل فئة Web Socket هذه مع الأحداث عندما يقطع المستخدم الاتصال، أو ينضم إلى غرفة دردشة، أو يريد كتم صوت غرفة دردشة. لذا لننشئ فئة web-socket التي ستدير الـ sockets لنا.
أنشئ مجلداً جديداً باسم utils. داخل هذا المجلد، أنشئ ملفاً باسم WebSockets.js وأضف المحتوى التالي:
class WebSockets {
users = [];
connection(client) {
// event fired when the chat room is disconnected
client.on("disconnect", () => {
this.users = this.users.filter(
(user) => user.socketId !== client.id
);
});
// add identity of user mapped to the socket id
client.on("identity", (userId) => {
this.users.push({
socketId: client.id,
userId: userId,
});
});
// subscribe person to chat & other user as well
client.on("subscribe", (room, otherUserId = "") => {
this.subscribeOtherUser(room, otherUserId);
client.join(room);
});
// mute a chat room
client.on("unsubscribe", (room) => {
client.leave(room);
});
}
subscribeOtherUser(room, otherUserId) {
const userSockets = this.users.filter(
(user) => user.userId === otherUserId
);
userSockets.map((userInfo) => {
const socketConn = global.io.sockets.connected(userInfo.socketId);
if (socketConn) {
socketConn.join(room);
}
});
}
}
export default new WebSockets();
تحتوي فئة WebSockets على ثلاثة أشياء رئيسية هنا:
- مصفوفة
users. - طريقة
connection. - اشتراك أعضاء غرفة الدردشة بها.
subscribeOtherUser.
دعنا نقسم هذا. لدينا فئة:
class WebSockets {
}
export default new WebSocket();
ننشئ فئة ونصدر مثيلاً لتلك الفئة. داخل الفئة لدينا مصفوفة users فارغة. ستحتوي هذه المصفوفة على قائمة بجميع المستخدمين النشطين المتصلين الذين يستخدمون تطبيقنا.
بعد ذلك لدينا طريقة connection، وهي جوهر هذه الفئة:
connection(client) {
// event fired when the chat room is disconnected
client.on("disconnect", () => {
this.users = this.users.filter(
(user) => user.socketId !== client.id
);
});
// add identity of user mapped to the socket id
client.on("identity", (userId) => {
this.users.push({
socketId: client.id,
userId: userId,
});
});
// subscribe person to chat & other user as well
client.on("subscribe", (room, otherUserId = "") => {
this.subscribeOtherUser(room, otherUserId);
client.join(room);
});
// mute a chat room
client.on("unsubscribe", (room) => {
client.leave(room);
});
}
تأخذ طريقة connection معاملًا يسمى client (سيكون العميل هنا هو مثيل خادمنا، سأتحدث عن هذا أكثر بعد قليل). نأخذ المعامل client ونضيف بعض الأحداث إليه:
client.on('disconnect')// عندما يفقد اتصال المستخدم، سيتم استدعاء هذه الطريقة.client.on('identity')// عندما يسجل المستخدم الدخول من الواجهة الأمامية، سيقومون بإنشاء اتصال بخادمنا عن طريق إعطاء هويتهم.client.on('subscribe')// عندما ينضم مستخدم إلى غرفة دردشة، يتم استدعاء هذه الطريقة.client.on('unsubscribe')// عندما يغادر مستخدم أو يريد كتم صوت غرفة دردشة.
لنناقش disconnect:
client.on("disconnect", () => {
this.users = this.users.filter(
(user) => user.socketId !== client.id
);
});
بمجرد قطع الاتصال، نقوم بتشغيل عامل تصفية على مصفوفة users. حيث نجد user.id === client.id، نقوم بإزالته من مصفوفة sockets الخاصة بنا. (client هنا يأتي من معامل الدالة.)
لنناقش identity:
client.on("identity", (userId) => {
this.users.push({
socketId: client.id,
userId: userId,
});
});
عندما يسجل المستخدم الدخول من خلال تطبيق الواجهة الأمامية web/android/ios، سيقومون بإنشاء اتصال socket مع تطبيق الواجهة الخلفية الخاص بنا واستدعاء طريقة identity هذه. سيرسلون أيضاً معرف المستخدم الخاص بهم. سنأخذ معرف المستخدم هذا ومعرف العميل (id) (معرف socket الفريد الخاص بالمستخدم الذي ينشئه socket.io عندما يقومون بإنشاء اتصال مع BE الخاص بنا).
بعد ذلك لدينا unsubscribe:
client.on("unsubscribe", (room) => {
client.leave(room);
});
يمرر المستخدم معرف room ونخبر client.leave() بإزالة المستخدم الحالي الذي يستدعي هذه الطريقة من غرفة دردشة معينة.
بعد ذلك لدينا subscribe:
client.on("subscribe", (room, otherUserId = "") => {
this.subscribeOtherUser(room, otherUserId);
client.join(room);
});
عندما ينضم مستخدم إلى غرفة دردشة، سيخبرنا عن الغرفة التي يريد الانضمام إليها جنباً إلى جنب مع الشخص الآخر الذي هو جزء من غرفة الدردشة هذه. ملاحظة: سنرى لاحقاً أنه عندما نبدأ غرفة دردشة، نحصل على جميع المستخدمين المرتبطين بهذه الغرفة في استجابة API.
في رأيي: شيء آخر كان بإمكاننا فعله هنا هو عندما يرسل المستخدم رقم الغرفة، يمكننا إجراء استعلام قاعدة بيانات لمعرفة جميع أعضاء غرفة الدردشة وجعلهم ينضمون إذا كانوا متصلين بالإنترنت في الوقت الحالي (أي، في قائمة مستخدمينا).
يتم تعريف طريقة subscribeOtherUser بهذا الشكل:
subscribeOtherUser(room, otherUserId) {
const userSockets = this.users.filter(
(user) => user.userId === otherUserId
);
userSockets.map((userInfo) => {
const socketConn = global.io.sockets.connected(userInfo.socketId);
if (socketConn) {
socketConn.join(room);
}
});
}
نمرر room و otherUserId كمعاملات لهذه الدالة. باستخدام otherUserId، نقوم بالتصفية على مصفوفة this.users الخاصة بنا ويتم تخزين جميع النتائج المطابقة في مصفوفة userSockets. قد تفكر – كيف يمكن لمستخدم واحد أن يكون له عدة وجودات في مصفوفة المستخدمين؟ حسناً، فكر في سيناريو حيث قام نفس المستخدم بتسجيل الدخول من تطبيق الويب الخاص به وهاتفه المحمول. سيؤدي ذلك إلى إنشاء اتصالات socket متعددة لنفس المستخدم.
بعد ذلك، نقوم بالربط على userSockets. لكل عنصر في هذه المصفوفة، نمرره إلى هذه الطريقة: const socketConn = global.io.sockets.connected(userInfo.socketId). سأتحدث أكثر عن global.io.sockets.connected بعد قليل. ولكن ما يفعله هذا في البداية هو أنه يأخذ userInfo.socketId وإذا كان موجوداً في اتصال socket الخاص بنا، فسيعيد الاتصال، وإلا فسيعيد null.
بعد ذلك، نرى ببساطة ما إذا كان socketConn متاحاً. إذا كان كذلك، فإننا نأخذ socketConn هذا ونجعل هذا الاتصال ينضم إلى room الممرر في الدالة:
if (socketConn) {
socketConn.join(room);
}
وهذا كل شيء بالنسبة لفئة WebSockets الخاصة بنا. لنستورد هذا الملف في ملف server/index.js الخاص بنا:
import socketio from "socket.io";
// mongo connection
import "./config/mongo.js";
// socket configuration
import WebSockets from "./utils/WebSockets.js";
لذا فقط قم باستيراد socket.io واستيراد WebSockets في مكان ما في الأعلى. بعد ذلك، حيث نقوم بإنشاء خادمنا، أضف المحتوى أسفل هذا:
/** Create HTTP server. */
const server = http.createServer(app);
/** Create socket connection */
global.io = socketio.listen(server);
global.io.on('connection', WebSockets.connection)
تم إنشاء server ونقوم بشيئين: تعيين global.io إلى socketio.listen(server) (بمجرد أن يبدأ المنفذ في الاستماع على server، تبدأ الـ sockets في الاستماع للأحداث التي تحدث على هذا المنفذ أيضاً.) ثم نقوم بتعيين طريقة global.io.on('connection', WebSockets.connection). في كل مرة يقوم فيها شخص ما من الواجهة الأمامية بإنشاء اتصال socket، سيتم استدعاء طريقة connection التي ستستدعي فئة Websockets الخاصة بنا وداخل تلك الفئة طريقة connection. global.io مكافئ لكائن windows في المتصفح. ولكن نظراً لعدم وجود windows في NodeJS، فإننا نستخدم global.io. كل ما نضعه في global.io متاح في التطبيق بأكمله. هذا هو نفس global.io الذي استخدمناه في فئة WebSockets داخل طريقة subscribeOtherUser.
إذا ضللت هنا، فإليك الرمز المصدري الكامل لتطبيق الدردشة هذا. لا تتردد أيضاً في إرسال رسالة لي بملاحظاتك وسأحاول تحسين محتوى هذا البرنامج التعليمي.
مناقشة نموذج قاعدة بيانات غرف الدردشة ورسائل الدردشة
قبل البدء بالدردشة، أعتقد أنه من المهم جداً مناقشة نموذج قاعدة البيانات الذي سننشئ عليه تطبيق الدردشة الخاص بنا. ألق نظرة على الفيديو أدناه:

الآن بعد أن أصبحت لديك فكرة واضحة عن كيفية هيكلة الدردشة لدينا، لنبدأ بإنشاء نموذج غرفة الدردشة الخاصة بنا. انتقل إلى مجلد models الخاص بك وأنشئ الملف التالي ChatRoom.js. أضف المحتوى التالي إليه:
import mongoose from "mongoose";
import { v4 as uuidv4 } from "uuid";
export const CHAT_ROOM_TYPES = {
CONSUMER_TO_CONSUMER: "consumer-to-consumer",
CONSUMER_TO_SUPPORT: "consumer-to-support",
};
const chatRoomSchema = new mongoose.Schema(
{
_id: {
type: String,
default: () => uuidv4().replace(/\-/g, ""),
},
userIds: Array,
type: String,
chatInitiator: String,
},
{
timestamps: true,
collection: "chatrooms",
}
);
chatRoomSchema.statics.initiateChat = async function (
userIds,
type,
chatInitiator
) {
try {
const availableRoom = await this.findOne({
userIds: {
$size: userIds.length,
$all: [...userIds],
},
type,
});
if (availableRoom) {
return {
isNew: false,
message: 'retrieving an old chat room',
chatRoomId: availableRoom._doc._id,
type: availableRoom._doc.type,
};
}
const newRoom = await this.create({
userIds,
type,
chatInitiator,
});
return {
isNew: true,
message: 'creating a new chatroom',
chatRoomId: newRoom._doc._id,
type: newRoom._doc.type,
};
} catch (error) {
console.log('error on start chat method', error);
throw error;
}
};
export default mongoose.model("ChatRoom", chatRoomSchema);
لدينا ثلاثة أشياء تحدث هنا:
- لدينا ثابت لـ
CHAT_ROOM_TYPESالذي يحتوي على نوعين فقط. - نحدد مخطط
ChatRoomالخاص بنا. - نضيف طريقة ثابتة لبدء الدردشة.
واجهات برمجة تطبيقات متعلقة بالدردشة
بدء محادثة بين المستخدمين (/room/initiate [POST request])
لنناقش طريقتنا الثابتة المعرفة في models/ChatRoom.js المسماة initiateChat:
chatRoomSchema.statics.initiateChat = async function (
userIds,
type,
chatInitiator
) {
try {
const availableRoom = await this.findOne({
userIds: {
$size: userIds.length,
$all: [...userIds],
},
type,
});
if (availableRoom) {
return {
isNew: false,
message: 'retrieving an old chat room',
chatRoomId: availableRoom._doc._id,
type: availableRoom._doc.type,
};
}
const newRoom = await this.create({
userIds,
type,
chatInitiator,
});
return {
isNew: true,
message: 'creating a new chatroom',
chatRoomId: newRoom._doc._id,
type: newRoom._doc.type,
};
} catch (error) {
console.log('error on start chat method', error);
throw error;
}
}
تأخذ هذه الدالة ثلاثة معلمات:
userIds(مصفوفة من المستخدمين).type(نوع غرفة الدردشة).chatInitiator(المستخدم الذي أنشأ غرفة الدردشة).
بعد ذلك، نقوم بشيئين هنا: إما إرجاع مستند غرفة دردشة موجود أو إنشاء مستند جديد. دعنا نقسم هذا:
const availableRoom = await this.findOne({
userIds: {
$size: userIds.length,
$all: [...userIds],
},
type,
});
if (availableRoom) {
return {
isNew: false,
message: 'retrieving an old chat room',
chatRoomId: availableRoom._doc._id,
type: availableRoom._doc.type,
};
}
أولاً باستخدام this.findOne() API في mongoose، نجد جميع غرف الدردشة حيث يتم استيفاء المعايير التالية:
userIds: {
$size: userIds.length,
$all: [...userIds]
},
type: type,
يمكنك قراءة المزيد عن عامل التشغيل $size هنا، والمزيد عن عامل التشغيل $all هنا. نحن نتحقق للعثور على مستند غرفة دردشة حيث يوجد عنصر في مجموعة غرف الدردشة الخاصة بنا حيث تكون userIds هي نفسها التي نمررها إلى هذه الدالة (بغض النظر عن ترتيب معرفات المستخدم)، وطول userIds هو نفس طول userIds.length الذي نمرره عبر الدالة. كما نتحقق من أن نوع غرفة الدردشة يجب أن يكون هو نفسه. إذا تم العثور على شيء من هذا القبيل، فإننا ببساطة نعيد غرفة الدردشة الموجودة.
وإلا فإننا ننشئ غرفة دردشة جديدة ونعيدها عن طريق القيام بذلك:
const newRoom = await this.create({
userIds,
type,
chatInitiator
});
return {
isNew: true,
message: 'creating a new chatroom',
chatRoomId: newRoom._doc._id,
type: newRoom._doc.type,
};
ننشئ غرفة جديدة ونعيد الاستجابة. لدينا أيضاً مفتاح isNew حيث، إذا كانت تسترد غرفة دردشة قديمة، فإننا نعيّنها على false وإلا true.
بعد ذلك، بالنسبة لمسارك الذي تم إنشاؤه في routes/chatRoom.js والذي يسمى post('/initiate', chatRoom.initiate)، انتقل إلى وحدة التحكم المناسبة له في controllers/chatRoom.js وأضف ما يلي في طريقة initiate:
initiate: async (req, res) => {
try {
const validation = makeValidation(types => ({
payload: req.body,
checks: {
userIds: {
type: types.array,
options: { unique: true, empty: false, stringOnly: true }
},
type: {
type: types.enum,
options: { enum: CHAT_ROOM_TYPES }
},
}
}));
if (!validation.success) return res.status(400).json({ ...validation });
const { userIds, type } = req.body;
const { userId: chatInitiator } = req;
const allUserIds = [...userIds, chatInitiator];
const chatRoom = await ChatRoomModel.initiateChat(allUserIds, type, chatInitiator);
return res.status(200).json({ success: true, chatRoom });
} catch (error) {
return res.status(500).json({ success: false, error: error })
}
},
نستخدم مكتبة make-validation هنا للتحقق من طلب المستخدم. بالنسبة لـ initiate API، نتوقع من المستخدم إرسال مصفوفة من users وتحديد نوع chat-room الذي يتم إنشاؤه. بمجرد اجتياز التحقق، يحدث ما يلي:
const { userIds, type } = req.body;
const { userId: chatInitiator } = req;
const allUserIds = [...userIds, chatInitiator];
const chatRoom = await ChatRoomModel.initiateChat(allUserIds, type, chatInitiator);
return res.status(200).json({ success: true, chatRoom });
شيء واحد يجب ملاحظته هنا هو أن userIds, type يأتيان من req.body بينما userId الذي يتم تسميته كـ chatInitiatorId يأتي من req بفضل decode middleware الخاص بنا. إذا تذكرت، لقد أرفقنا app.use("/room", decode, chatRoomRouter); في ملف server/index.js الخاص بنا. هذا يعني أن هذا المسار /room/initiate مصادق عليه. لذا const { userId: chatInitiator } = req; هو معرف المستخدم الحالي الذي قام بتسجيل الدخول. نحن ببساطة نستدعي طريقة initiateChat الخاصة بنا من ChatRoomModel ونمرر لها allUserIds, type, chatInitiator. أياً كانت النتيجة، فإننا ببساطة نمررها للمستخدم.
لنقم بتشغيل هذا ونرى ما إذا كان يعمل (إليك فيديو لي وأنا أفعل ذلك):

إنشاء رسالة في غرفة الدردشة (/:roomId/message) – POST request
لننشئ رسالة لغرفة الدردشة التي أنشأناها للتو مع pikachu. ولكن قبل إنشاء رسالة، نحتاج إلى إنشاء نموذج لـ chatmessages الخاص بنا. لذا لنفعل ذلك أولاً. في مجلد models الخاص بك، أنشئ ملفاً جديداً باسم ChatMessage.js وأضف المحتوى التالي إليه:
import mongoose from "mongoose";
import { v4 as uuidv4 } from "uuid";
const MESSAGE_TYPES = {
TYPE_TEXT: "text",
};
const readByRecipientSchema = new mongoose.Schema(
{
_id: false,
readByUserId: String,
readAt: {
type: Date,
default: Date.now(),
},
},
{
timestamps: false,
}
);
const chatMessageSchema = new mongoose.Schema(
{
_id: {
type: String,
default: () => uuidv4().replace(/\-/g, ""),
},
chatRoomId: String,
message: mongoose.Schema.Types.Mixed,
type: {
type: String,
default: () => MESSAGE_TYPES.TYPE_TEXT,
},
postedByUser: String,
readByRecipients: [readByRecipientSchema],
},
{
timestamps: true,
collection: "chatmessages",
}
);
chatMessageSchema.statics.createPostInChatRoom = async function (
chatRoomId,
message,
postedByUser
) {
try {
const post = await this.create({
chatRoomId,
message,
postedByUser,
readByRecipients: { readByUserId: postedByUser }
});
const aggregate = await this.aggregate([
// get post where _id = post._id
{ $match: { _id: post._id } },
// do a join on another table called users, and
// get me a user whose _id = postedByUser
{
$lookup: {
from: 'users',
localField: 'postedByUser',
foreignField: '_id',
as: 'postedByUser',
}
},
{ $unwind: '$postedByUser' },
// do a join on another table called chatrooms, and
// get me a chatroom whose _id = chatRoomId
{
$lookup: {
from: 'chatrooms',
localField: 'chatRoomId',
foreignField: '_id',
as: 'chatRoomInfo',
}
},
{ $unwind: '$chatRoomInfo' },
{ $unwind: '$chatRoomInfo.userIds' },
// do a join on another table called users, and
// get me a user whose _id = userIds
{
$lookup: {
from: 'users',
localField: 'chatRoomInfo.userIds',
foreignField: '_id',
as: 'chatRoomInfo.userProfile',
}
},
{ $unwind: '$chatRoomInfo.userProfile' },
// group data
{
$group: {
_id: '$chatRoomInfo._id',
postId: { $last: '$_id' },
chatRoomId: { $last: '$chatRoomInfo._id' },
message: { $last: '$message' },
type: { $last: '$type' },
postedByUser: { $last: '$postedByUser' },
readByRecipients: { $last: '$readByRecipients' },
chatRoomInfo: { $addToSet: '$chatRoomInfo.userProfile' },
createdAt: { $last: '$createdAt' },
updatedAt: { $last: '$updatedAt' },
}
}
]);
return aggregate[0];
} catch (error) {
throw error;
}
}
export default mongoose.model("ChatMessage", chatMessageSchema);
هناك عدة أمور تحدث هنا:
- لدينا كائن
MESSAGE_TYPESالذي يحتوي على نوع واحد فقط يسمىtext. - نحدد مخططنا لـ
chatmessageوreadByRecipient. - ثم نكتب طريقتنا الثابتة لـ
createPostInChatRoom.
أعلم أن هذا كثير من المحتوى، ولكن تحمل معي. لنكتب فقط وحدة التحكم للمسار الذي ينشئ هذه الرسالة. بالنسبة للمسار المحدد في API الخاص بنا routes/chatRoom.js والذي يسمى .post('/:roomId/message', chatRoom.postMessage)، لننتقل إلى وحدة التحكم الخاصة به في controllers/chatRoom.js ونحددها:
postMessage: async (req, res) => {
try {
const { roomId } = req.params;
const validation = makeValidation(types => ({
payload: req.body,
checks: {
messageText: { type: types.string },
}
}));
if (!validation.success) return res.status(400).json({ ...validation });
const messagePayload = {
messageText: req.body.messageText,
};
const currentLoggedUser = req.userId;
const post = await ChatMessageModel.createPostInChatRoom(roomId, messagePayload, currentLoggedUser);
global.io.sockets.in(roomId).emit('new message', { message: post });
return res.status(200).json({ success: true, post });
} catch (error) {
return res.status(500).json({ success: false, error: error })
}
},
رائع، دعنا نناقش ما نفعله هنا. العوامل التي نوقشت في هذا الفيديو هي:
$match$last$addToSet$lookup$unwind$group
عرض محادثة لغرفة دردشة بواسطة معرفها (Get request)
الآن بعد أن قمنا بما يلي:
- إنشاء غرفة دردشة.
- القدرة على إضافة رسائل في غرفة الدردشة تلك.
لنرى المحادثة بأكملها لتلك الدردشة أيضاً (مع الترحيل). بالنسبة لمسارك .get('/:roomId', chatRoom.getConversationByRoomId) في routes/chatRoom.js، افتح وحدة التحكم الخاصة به في ملف controllers/chatRoom.js وأضف المحتوى التالي إلى غرفة الدردشة:
getConversationByRoomId: async (req, res) => {
try {
const { roomId } = req.params;
const room = await ChatRoomModel.getChatRoomByRoomId(roomId)
if (!room) {
return res.status(400).json({
success: false,
message: 'No room exists for this id',
})
}
const users = await UserModel.getUserByIds(room.userIds);
const options = {
page: parseInt(req.query.page) || 0,
limit: parseInt(req.query.limit) || 10,
};
const conversation = await ChatMessageModel.getConversationByRoomId(roomId, options);
return res.status(200).json({ success: true, conversation, users });
} catch (error) {
return res.status(500).json({ success: false, error });
}
},
بعد ذلك، لننشئ طريقة ثابتة جديدة في ملف ChatRoomModel الخاص بنا تسمى getChatRoomByRoomId في models/ChatRoom.js:
chatRoomSchema.statics.getChatRoomByRoomId = async function (roomId) {
try {
const room = await this.findOne({ _id: roomId });
return room;
} catch (error) {
throw error;
}
}
مباشر جداً – نحن نحصل على الغرفة بواسطة roomId هنا.
بعد ذلك، في UserModel الخاص بنا، أنشئ طريقة ثابتة تسمى getUserByIds في ملف models/User.js:
userSchema.statics.getUserByIds = async function (ids) {
try {
const users = await this.find({ _id: { $in: ids } });
return users;
} catch (error) {
throw error;
}
}
العامل المستخدم هنا هو $in – سأتحدث عن هذا بعد قليل.
ثم أخيراً، انتقل إلى نموذج ChatMessage الخاص بك في models/ChatMessage.js واكتب طريقة ثابتة جديدة تسمى getConversationByRoomId:
chatMessageSchema.statics.getConversationByRoomId = async function (chatRoomId, options = {}) {
try {
return this.aggregate([
{ $match: { chatRoomId } },
{ $sort: { createdAt: -1 } },
// do a join on another table called users, and
// get me a user whose _id = postedByUser
{
$lookup: {
from: 'users',
localField: 'postedByUser',
foreignField: '_id',
as: 'postedByUser',
}
},
{ $unwind: "$postedByUser" },
// apply pagination
{ $skip: options.page * options.limit },
{ $limit: options.limit },
{ $sort: { createdAt: 1 } },
]);
} catch (error) {
throw error;
}
}
دعنا نناقش كل ما قمنا به حتى الآن: جميع الرموز المصدرية متاحة هنا.
وضع علامة "مقروء" على محادثة بأكملها (ميزة مشابهة لـ WhatsApp)
بمجرد تسجيل دخول الشخص الآخر وعرضه لمحادثة لغرفة معينة، نحتاج إلى وضع علامة "مقروء" على تلك المحادثة من جانبه. للقيام بذلك، في ملف routes/chatRoom.js الخاص بك للمسار:
put('/room/:roomId/mark-read', chatRoom.markConversationReadByRoomId)
انتقل إلى وحدة التحكم المناسبة له في controllers/chatRoom.js وأضف المحتوى التالي في وحدة التحكم markConversationReadByRoomId.
markConversationReadByRoomId: async (req, res) => {
try {
const { roomId } = req.params;
const room = await ChatRoomModel.getChatRoomByRoomId(roomId)
if (!room) {
return res.status(400).json({
success: false,
message: 'No room exists for this id',
})
}
const currentLoggedUser = req.userId;
const result = await ChatMessageModel.markMessageRead(roomId, currentLoggedUser);
return res.status(200).json({ success: true, data: result });
} catch (error) {
console.log(error);
return res.status(500).json({ success: false, error });
}
},
كل ما نفعله هنا هو التحقق أولاً مما إذا كانت الغرفة موجودة أم لا. إذا كانت كذلك، نتابع. نأخذ req.user.id كـ currentLoggedUser ونمرره إلى الدالة التالية:
ChatMessageModel.markMessageRead(roomId, currentLoggedUser);
والتي يتم تعريفها في نموذج ChatMessage الخاص بنا بهذا الشكل:
chatMessageSchema.statics.markMessageRead = async function (
chatRoomId,
currentUserOnlineId
) {
try {
return this.updateMany(
{
chatRoomId,
'readByRecipients.readByUserId': { $ne: currentUserOnlineId }
},
{
$addToSet: {
readByRecipients: { readByUserId: currentUserOnlineId }
}
},
{ multi: true }
);
} catch (error) {
throw error;
}
}
حالة استخدام محتملة هي أن المستخدم قد لا يكون قد قرأ آخر 15 رسالة بمجرد فتح محادثة غرفة معينة. يجب أن يتم وضع علامة "مقروء" عليها جميعاً. لذا نستخدم دالة this.updateMany بواسطة mongoose. يتم تعريف الاستعلام نفسه في خطوتين:
- البحث.
- التحديث.
ويمكن تحديث عدة عبارات. للعثور على قسم، قم بما يلي:
{
chatRoomId,
'readByRecipients.readByUserId': { $ne: currentUserOnlineId }
},
هذا يعني أنني أريد العثور على جميع منشورات الرسائل في مجموعة chatmessages حيث يتطابق chatRoomId ولا تتطابق مصفوفة readByRecipients. userId الذي أمرره إلى هذه الدالة هو currentUserOnlineId. بمجرد حصوله على جميع المستندات التي تتطابق مع المعايير، يحين وقت تحديثها:
{
$addToSet: {
readByRecipients: { readByUserId: currentUserOnlineId }
}
},
ستقوم $addToSet ببساطة بدفع إدخال جديد إلى مصفوفة readByRecipients. هذا مثل Array.push ولكن لـ Mongo.
بعد ذلك، نريد أن نخبر mongoose ألا يقوم بتحديث السجل الأول الذي يجده فحسب، بل أيضاً تحديث جميع السجلات التي تتطابق فيها الحالة. لذا نقوم بما يلي:
{ multi: true }
وهذا كل شيء – نعيد البيانات كما هي.
لنقم بتشغيل API هذا. ابدأ تشغيل الخادم:
npm start;
افتح Postman وأنشئ طلب PUT جديد لاختبار هذا المسار localhost:3000/room/<room=id-here>/mark-read:

قسم إضافي
- كيفية حذف غرفة دردشة وجميع رسائلها المرتبطة.
- كيفية حذف رسالة بواسطة معرف رسالتها.
وبهذا نكون قد انتهينا! لقد كان هذا الكثير من التعلم اليوم. يمكنك العثور على الرمز المصدري لهذا البرنامج التعليمي هنا. تواصل معي على Twitter بملاحظاتك – يسعدني أن أسمع إذا كان لديك أي اقتراحات للتحسينات.
إذا أعجبك هذا المقال، يرجى إعطاء مستودع GitHub نجمة والاشتراك في قناتي على YouTube. إذا قرأت حتى هذا الحد، اشكر المؤلف لتظهر له اهتمامك.
الخلاصة التقنية
قدم هذا الدليل الشامل منهجية واضحة لبناء حل API احترافي للدردشة باستخدام NodeJS و ExpressJS، مع الاستفادة من قوة MongoDB للتعامل مع البيانات و Socket.IO لتحقيق الاتصالات الفورية. لقد غطينا الجوانب الأساسية من إعداد المشروع، وإدارة المستخدمين، ومصادقة JWT، وصولاً إلى آليات الدردشة المتقدمة مثل بدء المحادثات وإدارة الرسائل وتحديد حالة القراءة. يعتبر استخدام الـ middlewares لتعزيز الأمان والتحقق من صحة البيانات أمراً حيوياً، بينما توفر فئة WebSockets بنية قابلة للتوسع لإدارة تفاعلات المستخدمين في الوقت الفعلي. هذا النهج يضمن تطبيقاً قوياً وفعالاً، مما يجعله نقطة انطلاق ممتازة للمطورين المبتدئين الراغبين في بناء تطبيقات دردشة تفاعلية وواقعية.