البحث في الموقع
المحتوى عن 'nodejs'.
-
نكمل في هذا المقال تطوير تطبيق الواجهة البرمجية REST الذي بدأناه في مقال سابق، حيث نضيف للتطبيق المميزات التالية: Mongoose التي تسمح لنا بالعمل مع قاعدة البيانات MongoDB بدلًا من كائن DAO المقيم في الذاكرة. استيثاق Authentication ﻹضافة إمكانيات منح اﻷذونات كي يتمكن مستثمرو الواجهة البرمجية من استخدام مفتاح تشفير ويب JSON Web Token للوصول بأمان إلى نقاط وصول الواجهة البرمجية. اختبارات مؤتمتة: باستخدام إطار عمل الاختبارات Mocha، والمكتبة Chai والوحدة البرمجية SuperTest، للمساعدة في التحقق من المحدودية عند زيادة حجم الشيفرة وتغيّرها. كما سنضيف تباعًا مكتبات للتحقق من صحة المدخلات ومكتبات خاصة بآمان التطبيق، ,وسنكتسب بعض الخبرة في التعامل مع مدير الحاويات Docker. تثبيت MongoDB على هيئة حاوية لنبدأ باستبدال قاعدة البيانات المقيمة في الذاكرة التي استخدمناها مسبقا بقاعدة بيانات حقيقة هي MongoDB. باﻹمكان طبعًا تثبيت القاعدة محليًا على جهازك، لكن ونظرًا للاختلافات بين بيئات التشغيل (إصدارات مختلفة لنظام التشغيل مثلًا) والتي قد تسبب بعض المشكلات، سنستخدم مدير الحاويات Docker وهو أداة معيارية حاليًا في صناعة البرمجيات. وكل ما عليك فعله هو تثبيت Docker ثم Docker Compose، وتأكد بعدها من التثبيت بتنفيذ اﻷمر docker -v على الطرفية كي ترى نسخة Docker Composer التي ثُبتت على جهازك. لتشغيل MongoDBضمن جذر المشروع، سننشئ أولًا ملف YAML يُدعى docker.compose.yml يضم الشيفرة التالية: version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017" يتيح Docker Composer تشغيل عدة حاويات في نفس الوقت باستخدام ملف تهيئة واحد. لهذا سنستخدمه اﻵن لتشغيل MongoDB دون أن نثبتها على الجهاز: sudo docker-compose up -d يشغّل اﻷمر up الحاوية المحددة، مصغيًا إلى منفذ MongoDB27017، بينما تفصل d- اﻷمر عن الطرفية ليعمل التطبيق مستقلًا، وستكون النتيجة مشابهة لما يلي إذا جرى كل شيء دون مشاكل: Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done تنشئ التعليمة السابقة أيضًا الملجد في جذر المشروع، لهذا عليك إضافة سطر بالمجلد data في الملف الذي ينتهي باللاحقة gitignore. وعندما نريد إيقاف الحاوية التي تحتوي MongoDB، علينا فقط تنفيذ الأمر sudo docker -compose down: Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default هذا كل ما تحتاج معرفته لتشغيل MongoDB مع الواجهة البرمجية REST. تأكد اﻵن من تنفيذ اﻷمر sudo docker-compose up -d حتى تكون MongoDB جاهزة للاستخدام مع التطبيق. استخدام Mongoose للوصول إلى MongoDB نستخدم مكتبة نمذجة البيانات Mongoose في الاتصال مع قاعدة البيانات MongoDB. وعلى الرغم من سهول استخدام Mongoose، سيفيدك الاطلاع على توثيقها لتعلّم كل اﻹمكانيات التي تقدمها للتطبيقات الفعلية. استخدم سطر اﻷوامر التالي لتثبيت Mongoose: npm i mongoose لنبدأ بإعداد خدمة Mongoose ﻹدارة الاتصال مع قاعدة البيانات MongoDB، وطالما أن عدة موارد تتشارك هذه الخدمة، سنضيفها إلى الملجد common مباشرة. نستخدم أيضًا الكائن mongooseOptions لتخصيص خيارات Mongoose التالية (على الرغم من أن استخدامه ليس إلزاميًا): useNeewUrlPrser: إن لم يُضبط هذا الخيار على القيمة true ستعرض Mongoose تحذير بأن هذه الخاصية عرضة للإهمال في النسخ المستقبلية depreciation warning. useUnified Topology: يوصي توثيق Mongoose بضبط قيمة هذا الخيار على true، وذلك لاستخدام محرك إدارة اتصال أحدث. serverSelectionTimeoutMS: لغايات تصميمية تتعلق بتطبيقنا، وفي حال نسيت تشغيل MongoDB قبل Node.js، سيكون اختيارك مدة أقل من المدة الافتراضية 30 ثانية سبيلًا لعرض معلومات عن MongoDB مباشرةً بانتظار استجابة الواجهة الخلفية. useFindAndModify: تلغي القيمة false لهذا الخيار عرض التحذير deprecation warning، وقد أشار توثيق Mongoose إلى التحذير الناتج عن هذا الخيار من بين جميع الخيارات اﻷخرى. إذ يدفع هذا الخيار Mongoose لاستخدام ميزات أصلية أحدث لقاعدة البيانات MongoDB بدلًا من كائنات Mongoose أقدم. إليك الشيفرة النهائية للملف common/services/mongoose.service.ts بعد جمع الخيارات السابقة مع بعض اﻹعدادات المتعلقة بإعادة محاولة الاتصال: import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService(); تأكد أنك تميّز بين الدالة ()connect التي توفرها مكتبة Mongoose وبين دالة الخدمة الخاصة بنا connectWithRetry: تحاول mongoose.connect الاتصال بخدمة MongoDB الخاصة بنا (التي نشغلها باستخدام docker-compose) والذي ينتهي بعد زمن يُحدده الخيار serverSelectionTimeoutMS بالميلي ثانية. تعيد الدالة connectWithRetry محاولة الاتصال السابقة في حال بدأ التطبيق العمل قبل عمل خدمة MongoDB. وطالما أنها دالة بانية لصنف متفرّد singleton، وبالتالي ستعمل هذه الدالة مرة واحدة، لكنها ستحاول استدعاء ()connect باستمرار يتخلل ذلك فترة توقف مدتها retrySeconds ثانية عند ينتهي الوقت المخصص لمحاولة الاتصال. ستكون الخطوة التالية استبدال البيانات المقيمة في الذاكرة بقاعدة البيانات MongoDB! إزالة قاعدة البيانات المقيمة في الذاكرة وإضافة MongoDB استخدمنا سابقًا قاعدة البيانات المقيمة في الذاكرة كي نركّز في عملنا على بقية الوحدات البرمجية التي نبنيها. ولكي نستخدم MongoDB اﻵن، علينا أن نعيد بناء الملف users.dao.ts بالكامل، ويتطلب اﻷمر بداية إدراج اعتمادية أخرى: import mongooseService from '../../common/services/mongoose.service'; نزيل اﻵن كل ما يتعلق بتعريف الصنف UserDao ما عدا الدالة البانية، ثم نعيد بناءه بإنشاء تخطيط Schema لبيانات المستخدم يعمل مع Mongoose قبل الدالة البانية: Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema); تعرّف الشيفرة السابقة مجموعة Mongoose الخاصة بنا، وتضيف ميزة خاصة لا تمتلكها قاعدة البيانات المقيمة في الذاكرة وهي الخيار select:false للحقل password لإخفاء هذا الحقل كلما طلبنا قائمة بأسماء المستخدمين. قد يبدو تخطيط المستخدم مألوفًا فهو يشبه تخطيط كائن نقل البيانات DTO الذي تعاملنا معه، لكن الفرق الرئيسي هو أننا نعرّف الحقول التي يجب أن تتواجد ضمن مجموعة MongoDB التي تُدعى Users، بينما يُعرّف الكائن DTO الحقول المقبولة في طلبات HTTP. مع ذلك لن يتغير هذا الجزء المتعلق بكائن DTO، إذ سندرج كائنات DTO الثلاث التي عرّفناها سابقًا في أعلى الملف users.dao.ts. لكن وقبل كتابة شيفرة العمليات اﻷساسية CRUD على قاعدة البيانات، سنجري تغييرين على كائنات DTO. التغير اﻷول على كائن DTO: الحقل id_ مقابل الحقل id نزيل الحقل id من كائنات DT لأن Mongoose تقدم الحقل id_ تلقائيًا، إذ يأتي ضمن معاملات طلب الوجهة (المسار route). وانتبه إلى أن نماذج Mongoose تزوّدنا بمستخلص افتراضي للمعرّف id، لهذا عطلّنا هذا الخيار كالتالي:{ id: false } لتفادي أي التباس. ولأن هذا اﻷمر يعطّل مرجعنا إلى user.id ضمن الدالة الوسيطة ()validateSameEmailBelongToSameUser، سنتحتاج إلى user._id بدلًا عنه. تستخدم بعض قواعد البيانات id وتستخدم أخرى id_ للإشارة إلى حقل المعرّف. لكن في تطبيقنا الذي يستخدم Mongoose، انتبهنا ببساطة إلى أي ترميز نستخدمه في كل مرحلة من مراحل كتابة الشيفرة، لكن مع ذلك سيظهر الاختلاف لمستثمري الواجهة البرمجية كما يوضح الشكل التالي: (تظهر الصورة أعلاه استخدام وظهور معرفات المستخدم في مشروع الواجهة البرمجية REST بصورته النهائية. ولاحظ كيف يختلف ترميز حقل المعرّف باختلاف مصدر المعرّف سواء كان معامل لطلب مباشر، أو بيانات مشفرة بطريقة JWT أو سجل قاعدة بيانات مُحضر حديثًا). التغيير الثاني: إعداد السماحيات المبنية على تفعيل رايات محددة سنغيّر أيضًا الراية permissionLevel إلى permissionFlags في كائنات DTO لنعكس عملنا على منظومة سماحيات أكثر تعقيدًا، إضافة إلى تعريف مخطط Mongoose السابق userSchema. كائنات DTO ومبدأ عدم التكرار DRY تذكّر أن ما يضمه كائن DTO هي فقط الحقول التي نريد تمريرها بين الواجهة البرمجية وقاعدة البيانات، وقد يبدو اﻷمر بأنه تداخل أو تكرار في البيانات الموجودة في نموذج التعامل مع البيانات وتلك الموجودة في كائنات DTO وانتهاكًا لمبدأ "عدم التكرار"، لكن لا تبالغ في تطبيق هذا المبدأ على حساب اﻷمان الذي يكون في أفضل حالاته مع اﻹعدادات الافتراضية. فعندما يتطلب اﻷمر مثلًا إضافة حقل في مكان واحد، من المحتمل عندها أن يعرضه المطوّرون في الواجهة البرمجية دون قصد وغير منتبهين إلى أن تخزين البيانات ونقلها سياقان منفصلان وقد يتطلب كلًا منهما مجموعات مختلفة من المتطلبات. يمكننا بعد الانتهاء من التغييرات على كائنات DTO تنفيذ توابع العمليات اﻷساسية CRUD (بعد الدالة البانية UsersDao)، وسنبدأ بعملية إنشاء مستخدم جديد: async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; } لاحظ أننا نلغي قيمة permissionFlags التي قد يرسلها مستثمر الواجهة البرمجية عبر userFields, ونستبدلها بالقيمة 1. ننتقل تاليًا إلى عملية القراءة التي لها وظائف الحصول على مستخدم من خلال معرّفه أو من خلال البريد اﻹلكتروني أو الحصول على قائمة المستخدمين ضمن صفحات: async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); } ستكفي دالة DAO واحدة لتحديث مستخدم لأن دالة Mongoose في الخلفية findOneAndUpdate قادرة على تحديث المستند بأكمله أو تحديث جزء منه. وانتبه إلى أن دالتنا الخاصة تأخذ أحد القيمتين PatchUserDto أو PutUserDto للمعامل userFields من خلال استخدام نوع الاجتماع (|) في TypeScript: async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; } يبلغ الخيار new: true مكتبة Mongoose بأن تعيد الكائن كما هو بعد التحديث بدلًا عما كانه أصلًا. أما عملية الحذف فهي موجزة في Mongoose: async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); } قد يلاحظ القارئ أن كل استدعاء للدوال اﻷعضاء للصنف User مقترن باستدعاء للدالة ()exec، وهذا اﻷمر اختياري لكنه مفضّل بين مطوري Mongoose لأنه يقدم طريقة أفضل في تتبع حالة المكدس عند التنقيح. علينا اﻻن بعد الانتهاء من كتابة شيفرة كائن DAO تحديث الملف users.service.ts قليلًا حتى يتماشى مع الدوال الجديدة. ولا حاجة هنا ﻹعادة بناء الملف، بل فقط ثلاث لمسات بسيطة: @@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); } يبقى استدعاء بقية الدوال كما هو تمامًا، لأننا أبقينا الهيكلية التي أنشأناها سابقًا على الرغم من إعادة بناء الصنف UsersDao مع بعض الاستثناءات: نستخدم اﻵن الدالة ()updateUserById لتنفيذ العمليتين PUT و PATCH (والسبب كما ذكرنا سابقًا أننا ندعم النهج النمطي لتنفيذ واجهات برمجية REST، بعيدًا عن بعض التوصيات التي لا تدعم استخدام PUT في إنشاء موارد جديدة غير موجودة أصلًا كي لا تسمح الواجهة الخلفية لمستثمريها التحكم بتولد المعرّفات ID). طالما أن كائن DAO الجديد يستخدم المعاملين limit و page، سنمررهما إلى الدالة ()getUsers. تقدّم الهيكلية السابقة نموذجًا متماسكًا، إذ يرفض أية محاولة مثلًا لاستبدال MongoDB Mongoose بمكتبات أخرى مثل TypefORM و PostgreSL. ولتنفيذ مثل هذه التغييرات لا بد من إعادة بناء كل دالة من دوال DAO مع الحفاظ على بصمة هذه الدوال signature لتتفق مع بقية الشيفرة. اختبار الواجهة البرمجية REST المبنية على Mongoose لنشغّل الواجهة البرمجية باستخدام سطر اﻷوامر npm start، ونحاول إنشاء مستخدم: curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"marcos.henrique@toptal.com" }' يتضمن كائن الاستجابة معرّف المستخدم الجديد: { "id": "7WYQoVZ3E" } وسيكون إجراء بقية الاختبارات يديويًا أسهل باستخدام متغيرات البيئة: REST_API_EXAMPLE_ID="put_your_id_here" يبدو تحديث مستخدم كالتالي: curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' من المفترض ان تبدأ الاستجابة بالترويسة HTTP/1.1 204 No Content. (لاحظ أنه بدون include-- لن تُطبع أية استجابة). ولكي تتحقق من نجاح الطلبين السابقين نفّذ ما يلي: curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' تعرض الاستجابة الحقول المتوقعة ومن بينها id_ الذي ناقشناه سابقًا: { "_id": "7WYQoVZ3E", "email": "marcos.henrique@toptal.com", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" } لاحظ وجود حقل خاص هو v__ يُستخدم من قبل Mongoose لتحديد الإصدار وسيتغير في كل مرة يُحدَّث فيها نفس السجّل. دعونا اﻵن نطلب قائمة بأسماء المستخدمين: curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' وستكون الاستجابة نفسها، لكنها مغلفة بالقوسين []. بعد أن تأكدنا أن كلمة المرور قد حُفظت بأمان، سنتحقق من إمكانية حذف المستخدم: curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' نتوقع أن نحصل هنا على الاستجابة 204 مجددًا. قد تتسائل إن كان حقل كلمة المرور يعمل كما نتوقع، طالما أننا حددنا الخيار select: false في تعريف تخطيط Mongoose وبالتالي لن يظهر الحقل عند الاستجابة للطلب GET. لهذا سنكرر إنشاء مستخدم جديد ونتحرى اﻷمر (لا تنسى تخزين المعرّف الجديد لاستخدامات لاحقة). كلمات المرور المخفية والتنقيح المباشر للبيانات في حاوية MongoDB للتحقق من التخزين اﻵمن لكلمات المرور (مشفرّة بدلًا من أن تظهر كما هي)، يمكن للمطورين تفحّص MongoDB مباشرة. وأحد الطرق الممكنة استخدام واجهة سطر اﻷوامر المعيارية mongo من داخل حاوية Docker قيد التشغيل: sudo docker exec -it toptal-rest-series_mongo_1 mongo من هناك نفّذ اﻷمر use api-db يتبعه ()db.users.find().pretty لتحصل على بيانات جميع المستخدمين بما فيها كلمات المرور. أما لمن يفضل العمل على واجهات رسومية، بإمكانهم تثبيت عميل MongoDB مثل Robo 3T: تُعد البادئة $...argon2 جزءًا من التنسيق النصي PHC وقد خّزنت دون تعديل عن قصد. فإظهار عبارة Argon2 ومعاملاتها العامة لن يساعد المخترقين على تحديد كلمة النص السرياﻷصلية إن استطاعوا سرقة قاعدة البيانات. ويمكن تعزيز كلمة المرور أكثر باستخدام التغفيل salting، وهي تقنية سنستخدمها لاحقًا مع مفتاح JWT. تأكدنا اﻵن أن Mongoose ترسل البيانات بنجاح إلى MongoDB، لكن كيف نتأكد أن مستثمري الواجهة البرمجية سيرسلون بيانات مناسبة ضمن طلباتهم إلى الوجهات الخاصة المستخدم؟ إضافة المكتبة express-validator هنالك طرق عدة للتحقق من الطلبات، سنستخدم منها في تطبيقنا المكتبة express-validator وهي مكتبة مستقرة وسهلة الاستخدام وتتمتع بتوثيق جيد. وعلى الرغم من إمكانية استخدام وظيفة التحقق المضمنة مع Mongoose، لكن الميزات التي Express.js يقدمها express-validator أكثر. إذ تقدم لك المكتبة متحققًا جاهزًا من صحة البريد اﻹلكتروني، بينما عليك كتابة شيفرته بنفسك في Mongoose. سنثبت اﻵن المكتبة express-validator: npm i express-validator ولكي نحدد الحقول التي نتحقق من صحتها، نستخدم التابع ()body الذي سندرجه في الملف users.routes.config.ts. يتحقق بعدها التابع ()bodyمن الحقول ويولّد قائمة باﻷخطاء مخزّنة في الكائن express.Request في حال أخفق الطلب، ونحتاج عندها إلى أداة وسيطة كي تستفيد من تلك القائمة وتتحقق من اﻷخطاء. وطالما أننا نستخدم نفس المنطق على بقية المسارات، سننشئ الملف common/middleware/body.validation.middleware.ts الذي يضم الشيفرة التالية: import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware(); وهكذا سنكون قادرين على التعامل مع أية أخطاء تولّدها الدالة ()body. لنضف اﻵن ما يلي إلى الملف users.routes.config.ts: import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; نستطيع اﻵن تحديث وجهاتنا كالتالي: @@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail, لا تنس إضافة BodyValidationMiddleware.verifyBodyFieldsErrors في كل وجهة بعد أي سطر يضم الدالة ()body وإلا لن يكون لها فائدة. لاحظ أيضًا كيف حدّثنا الوجهتين PUT و POST لاستخدام express-validater بدلًا من الدالة validateRequiredUserBodyFields التي بنيناها بأنفسنا. لأنهما الوجهتان الوحيدتان اللتان تستخدمان تلك الدالة. ويمكن حذف شيفرتها من الملف users.middleware.ts. هذا كل ما في اﻷمر، شغّل Node.js وجرّب باستخدام أي عميل REST تفضلّه كيف تتعامل الواجهة البرمجية مع المدخلات المختلفة. ولا تنس اﻹطلاع على توثيق express-validator لترى اﻹمكانيات اﻷخرى التي تقدّمها. فما فعلنا هو مجرد نقطة انطلاق. الاستيثاق والسماحيات (منح التصريحات) تتيح واجهتنا البرمجية مجموعة من نقاط الوصول endpoints لمستثمري الواجهة كي ينشئوا ويعدّلوا المستخدمين ويطلبو قوائم بهم، وتمنح كل نقطة منها وصولًا عامًا غير محدود. وتمنع أنماط الاستخدام أي مستخدم من تغيير بيانات آخر، كما تمنع أي دخلاء من الوصول إلى أية نقطة لا نريد وصولًا عامًا إليها. تتضمن القيود السابقة ناحيتين أساسيتين نختصرهما بالعبارة "auth" وتعني الإذن: اﻷولى هي الاستيثاق authentication وتعني معرفة صاحب الطلب، والثانية هي الترخيص أو التصريح authorization أي السماح لصاحب الطلب بتنفيذ طلبه أو لا. لهذا انتبه جيدًا إلى الناحية التي تُناقش، وخاصة في طلبات HTTP المعيارية، فالحالة 401 Unauthorized تتعلق بالاستيثاق، أما 403 Forbidden فتتعلق بالتصريح ولهذا السبب سنستخدم الاصطلاح "auth" ليعني الاستيثاق، ونستخدم المصطلح سماحيات permissions لمواضيع التصاريح. هنالك العديد من نُهج الاستيثاق التي يمكنك الاطلاع عليها، بما في ذلك الخدمات التي يقدمها طرف ثالث مثل Ath0، لكننا سنستخدم في تطبيقنا نهجًا أبسط مبني على مفاتيح JWT لكنه قابل للتوسع. يتكون مفتاح JWT من نص JSON مشفّر مع بعض البيانات الوصفية التي لا تتعلق بالاستيثاق، وتضم في حالتنا البريد اﻹلكتروني للمستخدم ورايات السماحيات permissions flags. كما يضم نص JSON نصًا سرًيا Secret للتحقق من سلامة البيانات الوصفية. إن الفكرة التي نتبعها هي الطلب من العميل إرسال JWT ضمن كل طلب غير عام، مما يسمح لنا بالتحقق من امتلاك العميل الثبوتيات الصحيحة لاستخدام نقطة الوصول المطلوبة دون الحاجة إلى إرسال الثبوتيات بأنفسهم عبر قناة الاتصال عند كل طلب. لكن في أي مكان سنكتب الشيفرة المناسبة؟ يمكن من خلال الدوال الوسيطة استخدامها عندما تكون ضمن إعدادات الوجهات (المسارات). إضافة وحدة الاستيثاق لنحضّر أولًا ما يضمه مفتاح JWT، إذ سنبدأ استعمال الحقل permissionFlags العائد إلى مورد المستخدم لأنه فقط يقدم بيانات وصفية ملائمة للتشفير ضمن مفاتيح JWT، وليس لوجود أي علاقة بين مفاتيح JWT ومنطق تحديد السماحيات. سنضيف قبل إنشاء أداة وسيطة middleware لتوليد مفاتيح JWT دالة خاصة إلى الملف users.dao.ts تستخلص حقل كلمة المرور، لأننا أعددنا Mongoose لتمنع عرضه: async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); } أضف ما يلي في الملف users.dao.ts: async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); } ننشئ اﻵن المجلد auth في جذر المشروع، ونضيف نقطة وصول لنسمح لمستثمري الواجهة البرمجية بتوليد مفاتيح JWT. لهذا ننشئ أولًا الوحدة الوسيطة auth/middleware/auth.middleware.ts على شكل صنف متفرّد singleton يُدعى AuthMiddleware، كما سنحتاج إلى استيراد بعض المكتبات: import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2'; ننشئ ضمن الصنف AuthMiddleware دالة تتحقق من وجود ثبوتيات صالحة ضمن طلبات مستخدمي الواجهة البرمجية: async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // إعطاء نفس الرسالة في كلتا الحالتين // يساعد في الحماية من محاولات الاختراق res.status(400).send({ errors: ['Invalid email and/or password'] }); } ولكي تتأكد الدالة الوسيطة من وجود الحقلين email و password في جسم الطلب req.body، نستخدم express-validator، عندما نهيئ لاحقًا المسار لاستخدام الدالة ()verifyUserPassword. تخزين النص السري ضمن مفتاح JWT نحتاج إلى سر كي نوّلد مفتاح JWT، الذي نستخدمه في تعليم المفتاح وللتحقق من صحة المفاتيح القادمة مع طلبات العملاء. سنخزّن هذا النص السريضمن ملف متغيّر بيئة env. منفصل بدلًا من كتابته ضمن ملف TypeScript، ولن يُدفع هذا الملف إلى مستودع الشيفرة. وكما جرت العادة، أضفنا الملف env.example. إلى المستودع لمساعدة المطورين على فهم أي المتغيرات مطلوبة عند إنشاء الملف env. الحقيقي. ونحتاج في حالتنا إلى متغير يُدعى JWT_SECRET يُخزّن سر مفتاح JWT على هيئة نص. وتذكّر أن تغيّر هذا النص السريفي نسختك إن أردت العمل على التطبيق انطلاقًا من المستودع الذي يضم المشروع المكتمل في نهاية هذه السلسلة من المقالات. كما تجدر اﻹشارة هنا إلى ضرورة اتباع الممارسات اﻷفضل عند استخدام مفاتيح JWT بالتمييز بين المفاتيح وفق البيئة (تطوير، إنتاج،…). ينبغي على الملف env. الموجود في جذر المشروع اتباع التنسيق التالي، لكن ليس بالضرورة استخدام نفس السر: JWT_SECRET=My!@!Se3cr8tH4sh3 ومن الطرق السهلة لتحميل هذه المتغيرات ضمن التطبيق استخدام مكتبة تُدعى dotenv: npm i dotenv وكل ما تحتاجه لتهيئتها هو استدعاء الدالة ()dotenv.config حالما تشغّل التطبيق. لهذا أضف الشيفرة التالية أعلى الملف app.ts: import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; } متحكم الاستيثاق آخر المتطلبات التي تلزمنا قبل توليد مفتاح JWT هو تثبيت المكتبة jsonwebtoken وأنواع TypeScript الخاصة بها: npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken لننشئ اﻵن المتحكم auth/controllers/auth.controller.ts، ولا حاجة ﻹدراج المكتبة dotenv لأن إدراجها في ملف التطبيق app.ts جعل محتوى ملف متغيرات البيئة متاحًا ضمن كامل التطبيق من خلال كائن Node.js العام process: import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * تُنشر هذه القيمة تلقائيًا من ملف متغيرات البيئة الذي عليك إنشاءه بنفسك في جذر المشروع *في المستودع لمعرفة التنسيق المطلوب .env.example اطلع على الملف */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController(); تربط المكتبة المفتاح الجديد بالنص السري الذي حددناه jwtSecret. كما سنوّلد مُوهِم (أو مُغفِّل salt) ومعمّي hash باستخدام الوحدة البرمجية اﻷصلية crypto في Node.js، وبعدها ننشئ المفتاح الذي يتمكن مستثمرو الواجهة من تحديث مفتاح JWT الحالي، وهذا اﻷمر مفيد بشكل خاص عند توسيع التطبيق. لكن ما الفرق بين refreshKey و refreshToken و accessToken؟ تُرسل هذه المفاتيح إلى مستثمر الواجهة البرمجية والغاية من ذلك هو: استخدام accessToken للطلبات التي تقع خارج إطار الوصول العام، و refreshToken لطلب استبدال مفتاح accessToken منتهي الصلاحية. ويستخدم المفتاح refreshKey لتمرير المتغير salt مشفّرًا ضمن المفتاح refreshToken إلى دالة التحديث الوسيطة التي سنشرحها لاحقًا. تجدر الملاحظة إلى أننا اعتمدنا على المكتبة jsonwebtoken في تحديد مدة صلاحية المفتاح، فإن انتهت صلاحية مفتاح لا بد من إعادة الاستيثاق من جديد: مسار التحقق اﻷساسية في الواجهة البرمجية REST المبنية على Node.js لنهيئ نقطة الوصول ضمن الملف auth/auth.routes.config.ts: import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } } لا تنس إضافة التالي إلى الملف app.ts: // ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); //Userroutes قد يكون قبل أو بعد // ... أصبحنا اﻵن مستعدين ﻹعادة تشغيل Node.js واختبار الشيفرة، ولا بد من استخدام نفس الثبوتيات التي استخدمناها عند إنشاء المستخدم سابقًا: curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"marcos.henrique@toptal.com" }' ستكون الاستجابة شبيهة بالتالي: { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" } وكما فعلنا سابقًا، سنستخدم متغيرات البيئة لاستخدام القيم السابقة بشكل ملائم: REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here" وهكذا نكون قد حصلنا على مفتاح الوصول ومفتاح التحديث لكننا نحتاج إلى أداة وسيطة للاستفادة منهما. وحدة وسيطة للتعامل مع JWT نحتاج بداية إلى نوع TypeScript جديد للتعامل مع بنية المفتاح JWT بشكلها غير المرمّز، لهذا أنشئ الملف الذي يضم الشيفرة التالية common/types/jwt.ts: export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; }; لنكتب اﻵن شيفرة الدوال الوسيطة التي تتحقق من وجود مفتاح تحديث، والتحقق منه، والتحقق من مفتاح JWT. وسنضع الدوال الثلاث في الملف الجديد common/types/jwt.ts: import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware(); تتحقق الدالة ()validRefreshNeeded أيضًا إذا ما كان مفتاح التحديث صحيحًا من أجل معرّف مستخدم محدد. فإن كان كذلك، سنستخدم الدالة authController.createJWT لتوليد مفتاح JWT جديد للمستخدم. وتتحقق الدالة ()validJWTNeeded, إن أرسل مستثمر الواجهة البرمجية مفتاح JWT صالح ضمن ترويسة طلب HTTP وفق الصيغة Authorization: Bearer <token> (وهذا أيضًا للأسف تضارب آخر بين الاستيثاق والتصريح). علينا اﻵن تهيئة مسار جديد لتحديث المفتاح، وقد شُفِّرت ضمنه رايات السماحية. مسار لتحديث مفاتيح JWT سندرج اﻷداة الوسطية الجديدة ضمن الملف auth.routes.config.ts: import jwtMiddleware from './middleware/jwt.middleware'; ثم سنضيف المسارات التالية: this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]); سنختبر الآن إن كانت تعمل كما هو مطلوب باستخدام المفتاحين accessToken و refreshToken اللذان حصلنا عليهما سابقًا: curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }" علينا أن نتوقع الحصول على مفتاحين جديدين accessToken و refreshToken كي نستخدمهما لاحقًا. بإمكانك اﻵن تجريب كيف تتحقق الواجهة الخلفية من المفتاحين السابقين، وكيف تحدد عدد المرات التي يمكنك فيها الحصول على مفاتيح جديدة. واﻵن سيتمكن مستثمرو الواجهة الخلفية من إنشاء مفاتيح JWT والتحقق منها وتحديثها. الخلاصة أكملنا في هذا المقال العمل على تطبيقنا الذي يُنشئ واجهة برمجة REST باستخدام TypeScript و Express.js في بيئة Node.js وقد انتقلنا في هذا القسم من قاعدة بيانات مؤقتة مقيمة في الذاكرة إلى استخدام قاعدة بيانات MongoDB بالاستعانة بالمكتبة Mongoose كما عملنا على تنفيذ آلية للتحقق من صحة المدخلات باستخدام مفاتيح ويب JWT والمكتبة jsonwebtoken. أما عن مفاهيم السماحيات وكيفية تنفيذها لتتكامل مع مفاتيح JWT فهذا ما سنراه في المقال التالي. ترجمة -وبتصرف- للقسم اﻷول من المقال Building a Node.js TypeScript REST API, Part 3 MongoDB, Authentication, and Automated Tests اقرأ أيضًا المقال السابق: بناء واجهة برمجية متوافقة مع REST باستخدام Express.js القسم الثاني: نماذج البيانات والبرمجيات الوسيطة والخدمات دمج قاعدة البيانات MongoDB في تطبيقك Node تطبيق عملي لتعلم Express - الجزء الأول: إنشاء موقع ويب هيكلي لمكتبة محلية مقارنة بين MySQL و MongoDB
-
نكمل في هذا المقال ما بدأناه في مقالنا السابق الذي يتحدث عن بناء واجهة برمجية REST، وكنا قد خصصناه لشرح النقاط التالية: استخدام npm في إنشاء واجهة خلفية من الصفر. تحضير الاعتماديات اللازمة مثل TypeScript. استخدام الوحدة البرمجية debug المضمنة في بيئة Node.js. بناء هيكلية مشروع Express.js وتسجيل اﻷحداث التي تقع أثناء تشغيل التطبيق باستخدام Winston. إن كنت تشعر أن المفاهيم التي تحدثنا عنها واضحة بالنسبة إليك، انسخ رابط المشروع، وانتقل إلى الفرع toptal-article-01 ثم تابع القراءة خدمات الواجهة البرمجية ريست والبرمجيات الوسيطة والمتحكمات ونماذج البيانات سنفصّل في مقالنا هذا النقاط التالية: الخدمات Services: التي تجعل الشيفرة أكثر وضوحًا بترتيب العمليات المنطقية ضمن دوال يمكن للبرمجيات الوسيطة والمتحكمات استدعاءها. البرمجيات الوسيطة Middleware: التي تقيِّم حالة المتطلبات prerequisites قبل أن تستدعي Express.js دالة المتحكم المناسب. المتحكمات Controllers: تستخدم الخدمات لمعالجة الطلبات قبل إرسال نتيجة الطلب إلى العميل. نماذج البيانات Models: تصف بياناتنا وتساعد في عمليات التحقق التي نجريها أثناء تصريف التطبيق. سنضيف أيضًا قاعدة بيانات بسيطة ولا تصلح بالطبع لنسخة اﻹنتاج، فغايتها الوحيدة تسهيل فهم تطبيقنا ومتابعته، وتمهّد للمقال التالي الذي يتحدث عن الاتصال بقاعدة البيانات، والتكامل مع القاعدة MongoDB والمكتبة Mongoose. الخطوات اﻷولى للعمل مع DAOs و DTOs وقاعدة بياناتنا المؤقتة لن تستخدم قاعدة البيانات في هذه المرحلة ملفات لتخزين البيانات، بل ستحتفظ ببيانات المستخدمين ضمن مصفوفة، أي أن البيانات ستزول بمجرد إغلاق Node.js. ستدعم قاعدة البيانات العمليات اﻷساسية CRUD ( إنشاء Create، قراءة Read، تحديث Update، حذف Delete). ونشير إلى مفهومين نستخدمهما هنا وهما: كائنات الوصول إلى البيانات Data access Objects واختصارًا DAOs. كائنات نقل البيانات Data transfer Objects واختصارًا DTOs. يُستخدم DAO للاتصال بقاعدة بيانات محددة وتنفيذ عمليات CRUD عليها، بينما يحتوي DTO البيانات الخام التي يرسلها DAO أو التي يستقبلها من قاعدة البيانات. وبعبارة أخرى، تتوافق كائنات DTO مع أنواع نماذج البيانات، بينما تُعد كائنات DAO خدمات تستخدم كائنات DTO. وهكذا قد تكون كائنات DTO أكثر تعقيدًا وفقًا لمستوى تداخل البيانات في بنية قاعدة البيانات، بينما تكون نسخة واحدة من كائن DAO مسؤولة عن فعل محدد على سطر واحد فقط من قاعدة البيانات. لماذا تُستخدم كائنات DTO يساعد استخدام كائنات DTO كي تتوافق كائنات TypeScript مع نماذج البيانات الخاصة بنا في الحفاظ على تناسق قاعدة البيانات كما سنرى لاحقًا. لكن هنالك نقص واضح، فلا يمكن أن تقدم كائنات DTO ولا حتى TypeScript نفسها أي نوع من التحقق التلقائي مما يُدخله المستخدم، لأن ذلك يحدث أثناء تنفيذ التطبيق. فعندما يصل أحد المدخلات إلى نقطة وصول الواجهة البرمجية، فقد يكون لهذا المُدخل: حقول زائدة. حقول مطلوبة مفقودة (كتلك التي لا تبدأ بالمحرف ?). نوع بيانات الحقل لا تطابق نوع البيانات المحدد في نموذج البيانات الذي يعتمد على TypeScript. فلن تتحقق TypeScript (أو جافا سكريبت التي ستُنقل الشيفرة إليها) من تلك المدخلات، لهذا من المهم ألا ننسى عمليات التحقق، وخاصة عندما تكون الواجهة البرمجية متاحة للعموم. قد تساعدك في ذلك حزم مثل ajv، لكنها تعمل عادة بتعريف كائنات لها تخطيط مخصص لمكتبة محددة بدلًا من كائنات TypeScript الأصلية (ستلعب مكتبة Mongoose هذا الدور كما سنلاحظ في مقال تالٍ). وقد يخطر في بالك السؤال التالي: "هل علي استخدام كلًا من كائنات DAO و DTO إن توفّر ما هو أبسط؟"من اﻷفضل تفادي استخدام كائنات DTO في مشاريع Express.js/TypeScript حقيقة صغيرة إلا في الحالة التي تخطط فيها توسيع هذه المشاريع لتصبح متوسطة الحجم. لكن حتى لو لم تكن بصدد استخدامها في نسخ اﻹنتاج، يبقى هذا التطبيق فرصة مفيدة على طريق احتراف إنشاء واجهات برمجية باستخدام TypeScript. فمن الجيد التمرّن على توسيع استخدام أنواع TypeScript لتشمل أساليب أخرى، والعمل مع كائنات DTO لتقارنها مع أساليب أكثر بساطة عند إضافة مكوّنات ونماذج بيانات. نموذج المستخدم في الواجهة البرمجية REST على مستوى TypeScript نعرّف بداية ثلاث كائنات DTO للمستخدم، لهذا ننشئ مجلدًا يُدعى dto ضمن المجلد user، ثم ننشئ ملفًا يُدعى create.user.dto.ts يضم الشيفرة التالية: export interface CreateUserDto { id: string; email: string; password: string; firstName?: string; lastName?: string; permissionLevel?: number; } يعني ذلك أنه كلما انشأنا مستخدمًا جديدًا، وبصرف النظر عن قاعدة البيانات، لا بد أن يمتلك معرّفًا id وكلمة مرور password وبريد إلكتروني email وحقلين اختياريين هما الاسم اﻷول والثاني. يمكن لهذه المتطلبات أن تتغير وفقًا لمتطلبات العمل على مشروع محدد. ولا بد من تحديث الكائن بأكمله عند استخدام الاستعلام PUT، وسيكون الحقلان الاختياريين اﻵن ضروريان. لهذا أنشئ الملف put.user.dto.ts في نفس المجلد السابق ليضم الشيفرة التالية: export interface PutUserDto { id: string; email: string; password: string; firstName: string; lastName: string; permissionLevel: number; } وبالنسبة إلى طلبات PATCH، باﻹمكان استخدام الميزة partial من TypeScript والتي تنشئ نوعًا جديدًا بنسخ نوع آخر وجعل كل حقوله اختيارية. وهكذا ستكون شيفرة الملف patch.user.dto.ts هي فقط الشيفرة التالية: import { PutUserDto } from './put.user.dto'; export interface PatchUserDto extends Partial<PutUserDto> {} لننشئ اﻵن قاعدة البيانات المؤقتة في الذاكرة، لهذا ننشئ أولًا المجلد daos داخل المجلد user ومن ثم نضيف الملف users.dao.ts. ندرج أولًا كائنات DTO التي أنشأناها: import { CreateUserDto } from '../dto/create.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; import { PutUserDto } from '../dto/put.user.dto'; وللتعامل مع معرّفات المستخدمين IDs، سنضيف المكتبة shortid باستخدام الطرفية: npm i shortid npm i --save-dev @types/shortid بالعودة إلى الملف users.dao.ts، سندرج المكتبة shortid: import shortid from 'shortid'; import debug from 'debug'; const log: debug.IDebugger = debug('app:in-memory-dao'); بإمكاننا اﻵن إنشاء صنف يُدعى UserDao يبدو كالتالي: class UsersDao { users: Array<CreateUserDto> = []; constructor() { log('Created new instance of UsersDao'); } } export default new UsersDao(); سنستخدم في هذا الصنف نمط التصميم المتفرد singleton وبالتالي سيقدم هذا الصنف نفس النسخة، ونفس مصفوفة المستخدمين users عندما ندرجه ضمن ملفات أخرى. والسبب أن Node.js تخزّن هذا الملف مؤقتًا كلما أُدرج، وتجري كل عمليات الإدراج عند إقلاع التطبيق. أي سيُسلم كل ملف يشير إلى الملف users.dao.ts مرجعًا إلى النسخة ()new UsersDao التي صُدِّرت في أول مرة يعالج فيها Node.js هذا الملف. سنرى طريقة العمل هذه عندما نستخدم الصنف لاحقًا في المقال، ونستخدم هذا النمط من اﻷصناف الشائعة في TypeScript/Express.js مع تقدمنا في تطوير التطبيق. ملاحظة: من سلبيات استخدام نمط التصميم singleton صعوبة كتابة اختبارات وحدة لها، لكننا لن نلاحظ هذه السلبية في الكثير من حالات استخدامنا لهذه الأصناف، لأنها لا تضم متغيرات أعضاء تحتاج إلى إعادة ضبط قيمها. أما بالنسبة للحالات التي يجب فيها إعادة ضبط المتغيرات اﻷعضاء في singleton سنترك اﻷمر كتمرين للقارئ كي يفكّر في انتهاج طريقة للحل تعتمد على فكرة حقن الاعتمادية dependency injection. أما اﻵن، سنضيف العمليات اﻷساسية للتعامل مع قواعد البيانات CRUD إلى الصنف كدوال، وستكون بداية دالة إنشاء مستخدم كالتالي: async addUser(user: CreateUserDto) { user.id = shortid.generate(); this.users.push(user); return user.id; } وستأتي دالة استرجاع أسماء المستخدمين بأسلوبين: اﻷول هو "قراءة كل الموارد (جميع المستخدمين المسجلين)" واﻵخر "استرداد مستخدم من خلال المعرّف ID فقط": async getUsers() { return this.users; } async getUserById(userId: string) { return this.users.find((user: { id: string }) => user.id === userId); } أما الدالة التي تحدّث سجلات المستخدمين فقد تعيد كتابة الكائن بالكامل (الاستعلام PUT) أو جزء منه (PATCH? async putUserById(userId: string, user: PutUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1, user); return `${user.id} updated via put`; } async patchUserById(userId: string, user: PatchUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); let currentUser = this.users[objIndex]; const allowedPatchFields = [ 'password', 'firstName', 'lastName', 'permissionLevel', ]; for (let field of allowedPatchFields) { if (field in user) { // @ts-ignore currentUser[field] = user[field]; } } this.users.splice(objIndex, 1, currentUser); return `${user.id} patched`; } وكما ذكرنا سابقًا، فعلى الرغم من التصريح عن UserDto في طريقة تعريف الدوال السابقة، لا يقدم TypeScript أي طريقة للتحقق من اﻷنواع في زمن التنفيذ، ويعني ذلك أن: الدالة ()putUserById ستحتوي ثغرة تسمح لمستخدمي الواجهة البرمجية بتخزين قيم لحقول ليست جزءًا من النموذج الذي يعرّفه كائن DTO. ()patchUserById تعتمد هذه الدالة على على قائمة مكررة من أسماء الحقول والتي يجب أن تبقى متزامنة مع النموذج. وبدون وجود تزامن بين هذه القائمة والنموذج قد يستخدم النموذج القائمة التي يمثلها الكائن الذي حُدِّث، وبالتالي سيتجاهل قيم الحقول التي هي في الواقع جزء من النموذج الذي عرّفه كائن DTO لكنها لم تخزّن ضمنه سابقًا. سنعالج هاتين الحالتين بالشكل الصحيح لاحقًا عندما نتعامل مع تطبيقنا على مستوى قاعدة البيانات. أما العملية اﻷخيرة فهي عملية الحذف، وستكون دالتها كالتالي: async removeUserById(userId: string) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1); return `${userId} removed`; } وكنقطة إضافية، نعلم أن من شروط التسجيل الصحيح لمستخدم جديد هو عدم تكرار البريد اﻹلكتروني، لهذا سنضيف الدالة getUserByEmail: async getUserByEmail(email: string) { const objIndex = this.users.findIndex( (obj: { email: string }) => obj.email === email ); let currentUser = this.users[objIndex]; if (currentUser) { return currentUser; } else { return null; } } ملاحظة: في الحالات الحقيقة، قد تتصل بقاعدة البيانات من خلال مكتبات موجودة مسبقًا مثل Mongoose و Sequelize والتي تقدّم آلية لتنفيذ كل العمليات اﻷساسية التي تحتاجها. لهذا لن نتوسّع في شرح طريقة إنجاز الدوال السابقة. طبقة الخدمات في الواجهة البرمجية REST لتطبيقنا بعد أن أنشأنا كائن DAO أساسي مقيم في الذاكرة، بإمكاننا إنشاء خدمة تستدعي دوال CRUD. وطالما أن هذه الدوال مطلوبة لكل خدمة تتصل بقاعدة بيانات، سننشئ الواجهة CRUD التي تضم التوابع التي نحتاجها في كل مرة ننفذ فيها خدمة جديدة. تتمتع معظم بيئات التطوير المتكاملة التي نعمل عليها حاليًا ميزة توليد الشيفرة التي تمكننا من إضافة الدوال التي ننجزها في كل مرة نحتاجها مما يقلل كمية الشيفرة المكررة التي علينا كتابتها. أنشئ ضمن المجلد common مجلدّا بالاسم interfaces ثم أنشئ الملف crud.interface.ts وأضف إليه الشيفرة التالية: export interface CRUD { list: (limit: number, page: number) => Promise<any>; create: (resource: any) => Promise<any>; putById: (id: string, resource: any) => Promise<string>; readById: (id: string) => Promise<any>; deleteById: (id: string) => Promise<string>; patchById: (id: string, resource: any) => Promise<string>; } لننشئ اﻵن المجلد services ضمن المجلد users وضمنه الملف users.service.ts ونزوّده بالشيفرة التالية: import UsersDao from '../daos/users.dao'; import { CRUD } from '../../common/interfaces/crud.interface'; import { CreateUserDto } from '../dto/create.user.dto'; import { PutUserDto } from '../dto/put.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; class UsersService implements CRUD { async create(resource: CreateUserDto) { return UsersDao.addUser(resource); } async deleteById(id: string) { return UsersDao.removeUserById(id); } async list(limit: number, page: number) { return UsersDao.getUsers(); } async patchById(id: string, resource: PatchUserDto) { return UsersDao.patchUserById(id, resource); } async readById(id: string) { return UsersDao.getUserById(id); } async putById(id: string, resource: PutUserDto) { return UsersDao.putUserById(id, resource); } async getUserByEmail(email: string) { return UsersDao.getUserByEmail(email); } } export default new UsersService(); كانت خطوتنا الأولى إدراج الكائن DAO ثم اعتماديات الواجهة ثم نوع TypeScript الخاص بكل كائن DTO. سنعمل اﻵن على إنجاز الخدمة UserService كصنف متفرّد كما فعلنا مع الكائن DAO. تستدعي جميع دوال الواجهة CRUD الدوال التي تقبلها من UsersDao، وبالتالي عندما يحين الوقت لاستبدال الكائن DAO، لن نغيّر أي شيء في المشروع، ما عدا بعض التعديلات في هذا الملف حيث تُستدعى دوال DAO. أي لن نضطر إلى تتبع كل استدعاء للدالة ()list مثلًا والتجقق من محتواها قبل استبدالها، وهذه هي فائدة هذه الطبقة مقابل بعض الشيفرة اﻷساسية البسيطة التي رأيتها سابقًا. التعليمتان Async/Await في Node.js قد ترى أن استخدام async مع دوال الخدمة بلا معنى، وهذا صحيح حاليًا. فجميع هذه الدوال تعيد قيمها مباشرة، دون استخدام الوعود promise أو await داخليًا. لكننا فقط أردنا تحضير الشيفرة اﻷساسية للخدمات التي ستستخدم async. وبالمثل ستجد أن جميع الاستدعاءات لهذه الدوال تستخدم await. ستجد في نهاية المقال تطبيقًا قابلًا للتنفيذ والتجريب. وسيكون من الجيد توليد أخطاء مختلفة في أماكن مختلفة من الشيفرة، ومراقبة ما يحدث عند التصريف والاختبار، مع الانتباه إلى أن اﻷخطاء في استخدام async بالتحديد لن تظهر كما قد تتوقع! و اﻵن وقد انتهينا من إنجاز الكائن DAO والخدمات، سنعود إلى المتحكم بالمستخدم. بناء متحكم خاص بالواجهة البرمجية REST إن الفكرة من استخدام المتحكمات كما أشرنا سابقًا هي فصل إعدادات الوجهات عن الشيفرة التي تعالج في النهاية الطلب الذي يصل إلى الوجهة المطلوبة. وبالتالي لا بد أن تجري جميع عمليات التقييم قبل أن يصل الطلب إلى المتحكم. وكل ما يحتاجه المتحكم هو معرفة ما الذي سيفعله مع الطلب الفعلي، لأن الطلب الذي وصل إلى هذه المرحلة لابد وأن يكون صالحًا. يستدعي المتحكم بعد ذلك الخدمة التي تتوافق مع كل طلب تتعامل معه. علينا قبل أن نبدأ تثبيت مكتبة لتأمين تشفير كلمة المرور: npm i argon2 لنبدأ بإنشاء مجلد يُدعى controllers ضمن المجلد users وننشئ ضمنه الملف users.controller.ts: //ﻹضافة الأنواع إلى كائنات الطلب والاستجابة express ندرج //العائدة إلى دوال المتحكم import express from 'express'; //ندرج خدمة المستخدم التي أنشأناها مؤخرًا import usersService from '../services/users.service'; //لتشفير كلمة المرور argon2 ندرج المكتبة import argon2 from 'argon2'; //وفق سياق مخصص كما شرحنا في المقال السابق debug نستخدم المكتبة import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersController { async listUsers(req: express.Request, res: express.Response) { const users = await usersService.list(100, 0); res.status(200).send(users); } async getUserById(req: express.Request, res: express.Response) { const user = await usersService.readById(req.body.id); res.status(200).send(user); } async createUser(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); const userId = await usersService.create(req.body); res.status(201).send({ id: userId }); } async patch(req: express.Request, res: express.Response) { if (req.body.password) { req.body.password = await argon2.hash(req.body.password); } log(await usersService.patchById(req.body.id, req.body)); res.status(204).send(); } async put(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); log(await usersService.putById(req.body.id, req.body)); res.status(204).send(); } async removeUser(req: express.Request, res: express.Response) { log(await usersService.deleteById(req.body.id)); res.status(204).send(); } } export default new UsersController(); ملاحظة: تعيد اﻷسطر السابقة الاستجابة HTTP 204 No Content وتعني أن الطلب قد أنجز، لكن لا يوجد محتوى إضافي لإعادته مع جسم الاستجابة. بعد اﻹنتهاء من كتابة شيفرة المتحكم على شكل متفرّد، أصبحنا جاهزين لكتابة شيفرة الوحدة الوسيطة، وهي الوحدة البرمجية اﻷخرى التي تعتمد على نموذج كائن الواجهة البرمجية REST التجريبي وخدمته وهي الأداة الوسيطة. أداة وسيطة REST باستخدام Node.js و Express.js ما الذي يمكن أن تقدمه أداة وسيطة مبنية باستخدام Express.js ؟ بداية عمليات التحقق من صحة البيانات وهذا أمر شديد اﻷهمية، لنبدأ إذًا بإضافة آليات تحقق بسيطة من الطلبات قبل وصولها إلى متحكم المستخدم: التأكد من وجود حقول معينة لبيانات المستخدم مثل email و password وهي ضرورية ﻹنشاء مستخدم أو تحديث بياناته. التأكد من عدم استخدام البريد اﻹلكتروني المدخل من قبل. التحقق من عدم تغيير حقل البريد اﻹلكتروني بعد إنشاء المستخدم (لأننا سنستخدمه للسهولة كمعرّف أساسي للمستخدم). التحقق من وجود مستخدم محدد مسبقًا. ولتعمل آليات التحقق السابقة مع Express.js، لابد من كتابتها على شكل دوال تتوافق مع نمط Express.js وذلك ﻹدارة نقل التحكم باستخدام الدالة ()next كما شرحنا في المقال السابق. لهذا سنتحتاج إلى ملف جديد users/middleware/users.middleware.ts نضع فيه الشيفرة التالية: import express from 'express'; import userService from '../services/users.service'; import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersMiddleware { } export default new UsersMiddleware(); نضيف اﻵن بعض دوال اﻷداة الوسيطة إلى جسم الصنف: async validateRequiredUserBodyFields( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.email && req.body.password) { next(); } else { res.status(400).send({ error: Missing required fields email and password, }); } } async validateSameEmailDoesntExist( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user) { res.status(400).send({ error: User email already exists }); } else { next(); } } async validateSameEmailBelongToSameUser( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user && user.id === req.params.userId) { next(); } else { res.status(400).send({ error: Invalid email }); } } //بالشكل الصحيح this نستخدم هنا الدالة السهمية كي نربط التعليمة validatePatchEmail = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { if (req.body.email) { log('Validating email', req.body.email); this.validateSameEmailBelongToSameUser(req, res, next); [إضغط و إسحب للتحريك] } else { next(); } }; async validateUserExists( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.readById(req.params.userId); if (user) { next(); } else { res.status(404).send({ error: User ${req.params.userId} not found, }); } } ولكي نسهل على مستثمري الواجهة البرمجية تنفيذ طلبات إضافية على المستخدم الذي أنشئ حديثًا، سننشئ دالة مساعدة تستخلص الحقل userId من معاملات الطلب التي تصل من عنوان URL للطلب نفسه، ومن ثم نضيف الحقل إلى جسم الطلب، حيث تتواجد بقية بيانات المستخدم. والغاية من ذلك هي استخدام جسم الطلب كاملًا عندما نريد تحديث معلومات المستخدم، دون القلق من ضرورة الحصول على معرّف المستخدم في كل مرة، بل ستهتم اﻷداة الوسطية بهذا الموضوع. ستبدو الدالة بالشكل التالي: async extractUserId( req: express.Request, res: express.Response, next: express.NextFunction ) { req.body.id = req.params.userId; next(); } إضافة إلى منطق التنفيذ، ستجد أن الاختلاف الرئيسي بين اﻷداة الوسيطة المتحكم هو استخدام اﻷداة الوسيطة الدالة ()next لتمرير التحكم عبر سلسلة من الدوال المهيّأة مسبقًا حتى يصل إلى وجهته النهائية وهي المتحكم في حالتنا. تجميع كل الوحدات: إعادة تشكيل الوجهات بعد أن انتهينا من إنجاز مختلف نواحي معمارية التطبيق، سنعود إلى الملف users.routes.config.ts الذي عرّفناه في المقال السابق، والذي يستدعي اﻷداة الوسيطة والمتحكمات وكلاهما يعتمد على خدمة المستخدم والتي تتطلب بدورها نموذج المستخدم. سيكون الملف بشكله النهائي كالتالي: import { CommonRoutesConfig } from '../common/common.routes.config'; import UsersController from './controllers/users.controller'; import UsersMiddleware from './middleware/users.middleware'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes(): express.Application { this.app .route(`/users`) .get(UsersController.listUsers) .post( UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailDoesntExist, UsersController.createUser ); this.app.param(`userId`, UsersMiddleware.extractUserId); this.app .route(`/users/:userId`) .all(UsersMiddleware.validateUserExists) .get(UsersController.getUserById) .delete(UsersController.removeUser); this.app.put(`/users/:userId`, [ UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailBelongToSameUser, UsersController.put, ]); this.app.patch(`/users/:userId`, [ UsersMiddleware.validatePatchEmail, UsersController.patch, ]); return this.app; } } أعدنا هنا تعريف الوجهات بإضافة أداة وسيطة لتقييم منطق العمل واختيار دوال المتحكم المناسبة لمعالجة الطلب إن كان كل شيء صحيحًا. كما استخدمنا الدالة ()param التي تقدمها Express.js لاستخلاص قيمة الحقل userId. كما مررنا الدالة validateUserExists العائدة للأداة الوسيطة UserMiddleware في جميع الدوال ()all. كي تُستدعى قبل وصول أي طلب GET أو PUT أو PATCH أو DELETE إلى نقطة الوصول user/:usersId/. أي لا حاجة أن تكون validateUserExists ضمن مصفوفة الدوال اﻹضافية التي نمررها إلى ()put. أو ()patch.، إذ تُستدعى قبل هذه الدوال. كما عززنا قابلية الاستخدام المتكرر للأداة الوسيطة بطريقة أخرى أيضًا، وذلك بتمرير الدالة UsersMiddleware.validateRequiredUserBodyFields كي تُستخدم ضمن سياق استخدام POST و PUT، إذ نعيد دمجها في دوال وسيطة أخرى. تنبيه ﻹخلاء المسؤولية: ما فعلناه اﻵن هو آلية بسيطة للتحقق من صحة المدخلات، لكن عليك التفكير بكل القيود التي يجب وضعها في الشيفرة عندما تتعامل مع مشاريع حقيقية. ولكي نتوخى البساطة، افترضنا أن المستخدم ليس قادرا على تغيير بريده اﻹلكتروني. اختبار الواجهة البرمجية REST المبنية باستخدام Express/TypeScript نستطيع اﻵن تصريف وتشغيل تطبيق Node.js، وبمجرد أن يعمل سنكون قادرين على اختبار وجهات الواجهة البرمجية باستخدام عميل REST مثل Postman أو cURL. سنجرّب أولاً الحصول على قائمة المستخدمين: curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' إن الاستجابة التي سنحصل عليها حاليًا هي مصفوفة فارغة، وهذا صحيح، لذك علينا إنشاء المستخدم الأول كالتالي: curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' لاحظ كيف ستكون النتيجة هي خطأ يرسله التطبيق من خلال اﻷداة الوسيطة: { "error": "Missing required fields email and password" } وﻹصلاح اﻷمر، سنرسل طلبًا صحيحًا إلى المورد users/: curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "marcos.henrique@toptal.com", "password": "sup3rS3cr3tPassw0rd!23" }' سنرى هذه المرة استجابة شبيهة بالتالي: { "id": "ksVnfnPVW" } إن هذا المعرف id هو المعرّف الخاص بالمستخدم الجديد وقد يكون مختلفًا على جهازك. ولتسهيل بقية الاختبارات، يمكنك تنفيذ بقية اﻷوامر باستخدام المعرّف الذي حصلت عليه (على افتراض أنك تستخدم بيئة تشغيل شبيه بنظام لينكس): REST_API_EXAMPLE_ID="put_your_id_here" بإمكانك أن ترى اﻵن الاستجابة التي تحصل عليها عند تنفيذ الطلب GET باستخدام المتغّير السابق: curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' وتستطيع أيضًا تعديل المورد (المستخدم) بأكمله من خلال تنفيذ الطلب PUT: curl --request PUT "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "marcos.henrique@toptal.com", "password": "sup3rS3cr3tPassw0rd!23", "firstName": "Marcos", "lastName": "Silva", "permissionLevel": 8 }' كما تستطيع اختبار آلية التحقق بتغيير عنوان البريد اﻹلكتروني، ومن المفترض عندها ظهور رسالة خطأ. لاحظ أيضًا أن استخدام الطلب PUT لتحديث مورد ذو معرّف محدد، لا بد لنا -كمستخدمين للواجهة البرمجية- أن نرسل كائن الطلب بأكمله كي يتوافق مع معايير نموذج REST. فلو أردنا مثلًا تعديل الاسم اﻷخير فقط lastName، باستخدام PUT، لا بد من إرسال الكائن بأكمله لتنجح عملية التحديث. لكن من السهل في حالة كهذه استخدام الطلب PATCH لأنه يعمل ضمن قيود REST، وبإمكانك عندها إرسال قيمة lastName فقط: curl --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "lastName": "Faraco" }' وتذكر أن التمييز بين PUT و PATCH في شيفرتنا اﻷساسية عائد إلى أسلوب إعداد الوجهات عن طريق استخدام دوال الأداة الوسيطة التي أضفناها. هل نستخدم PUT أو PATCH أو كلاهما؟ قد ترى أنه لا ضرورة ملحة لاستخدام PUT نظرًا لمرونة PATCH، وبالفعل تتبنى بعض الواجهات البرمجية الفكرة. وقد يصّر البعض على دعم PUT كي تتوافق الواجهة البرمجية تمامًا مع REST. مع ذلك، قد يكون إنشاء وجهات تدعم PUT أمرًا مناسبًا لبعض حالات الاستخدام الشائعة. وفي واقع اﻷمر هذه النقاط هي موضع نقاشات أعمق، لهذا دعمنا في تطبيقنا استخدام PUT وكذلك PATCH، لكننا نشجعك على الاطلاع والبحث أكثر عندما تكون مستعدًا. إن حاولت الحصول على قائمة المستخدمين مجددًا، سترى المستخدم الجديد وقد حّدثت بياناته: [ { "id": "ksVnfnPVW", "email": "marcos.henrique@toptal.com", "password": "$argon2i$v=19$m=4096,t=3,p=1$ZWXdiTgb922OvkNAdh9acA$XUXsOHaRN4uVg5ltIwwO+SPLxvb9uhOKcxoLER1e/mM", "firstName": "Marcos", "lastName": "Faraco", "permissionLevel": 8 } ] بإمكاننا أخيرًا اختبار حذف مستخدم كالتالي: curl --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' إن حاولت اﻵن الحصول على قائمة المستخدمين مجددًا، فلن ترى المستخدم الذي أنشأته سابقًا. وهكذا نكون قد أنجزنا جميع العمليات اﻷساسية CRUD. الخلاصة استكشفنا في هذا المقال المكمّل للمقال السابق مفاهيم أساسية في بناء واجهة برمجية REST باستخدام Express.js. إذ جزءنا شيفرتنا إلى خدمات وأداة وسيطة ومتحكمات ونماذج بيانات، ولكل منها دوال تنفذ مهامًا محددة كالتحقق من صحة المدخلات وتنفيذ عمليات منطقية أو معالجة الطلبات الصحيحة والاستجابة لها. كما أنشأنا بنية شديدة البساطة لتخزين البيانات هدفها الوحيد تنفيذ بعض الاختبارات في هذه المرحلة، ومن ثم ستُستبدل بشيء عملي أكثر في مقالات قادمة. وما سنتعرف عليه في المقال القادم، بعد توخي البساطة الشديدة في إنشاء واجهتنا البرمجية، هو خطوات إضافية لجعل التطبيق أسهل صيانة وأكثر قابلية للتوسع وكذلك أكثر أمانًا مثل: استبدال قاعدة البيانات المؤقتة بقاعدة بيانات MongoDB، واستخدام المكتبة Mongoose لتسهيل كتابة الشيفرة. إضافة طبقة أمان والتحكم بالوصول من خلال مقاربة لا تعتمد على حالة التطبيق باستخدام JWT. إعداد اختبارات مؤتمتة تسمح لنا بتوسيع تطبيقنا. بإمكانك اﻵن الاطلاع على الشيفرة النهائية حتى هذه المرحلة من هنا. ترجمة -وبتصرف- للمقال Building a Node.js TypeScript REST API,Part2: Models, Middleware and Services اقرأ أيضًا المقال السابق: بناء واجهة برمجية متوافقة مع REST باستخدام Express.js -الجزء الأول دليلك لربط واجهة OpenAI API مع Node.js مدخل إلى إطار عمل الويب Express وبيئة Node
-
نتحدث في هذه السلسلة من المقالات عن خطوات بناء تطبيق بسيط يمثل واجهة خلفية على هيئة واجهة برمجية API باستخدام إطار عمل Express.js ولغة البرمجة TypeScript. كيف أكتب واجهة برمجية REST في بيئة Node.js غالبًا ما تكون المكتبة Express.js هي الخيار اﻷول من بين إطارات عمل Node.js عند كتابة واجهة خلفية لتكون واجهة برمجية REST. وعلى الرغم من أنها تدعم أيضًا بناء صفحات وقوالب HTML، لكننا سنركز في هذه السلسلة من المقالات على بناء واجهة خلفية باستخدام لغة TypeScript كي نسمح لأي واجهة أمامية أو واجهة خلفية خارجية (خادم آخر) من الاستعلام منها، لهذا عليك أن: تمتلك معرفة أساسية بلغة جافا سكريبت، وكذلك معرفة ببيئة عمل Node.js ومطلعًا على معمارية REST. تمتلك نسخة مثبّتة وجاهزة من بيئة Node.js (يفضل النسخة 14 وما بعد). سنبدأ من الطرفية أو (محرر سطر اﻷوامر) لإنشاء مجلّد خاص بالمشروع، ثم ننفّذ اﻷمر run npm init يُنشئ هذا الأمر بعض الملفات الأساسية التي نحتاجها لمشروع Node.js. ثم نضيف بعد ذلك إطار العمل Express.js وبعض المكتبات المفيدة اﻷخرى لمشروعنا من خلال الأمر التالي: npm i express debug winston express-winston cors وبالطبع هناك أسباب وجيهة كي يفضّل مطوّرو Node.js المكتبات السابقة: debug: هي وحدة برمجية تُستخدم لتفادي استخدام اﻷمر ()console.log أثناء تطوير التطبيقات. إذ تستخدم لترشيح العبارات التي نريد تنقيحها عند محاولة حل المشاكل التي تواجهنا. وباﻹمكان إيقافها كليًا في نسخة اﻹنتاج بدلًا من إزالتها يدويًا. winston: الوحدة المسؤولة عن تسجيل الطلبات القادمة إلى الواجهة البرمجية والاستجابات (واﻷخطاء) التي تعيدها الواجهة. وتتكامل express-winston مباشرة مع Express.js لهذا ستكون شيفرة واجهة برمجية المتعلقة بعملية إدارة السجلات التي تؤديها winston جاهزة. cors: هي جزء من أداة Express.js الوسيطة والتي تسمح لنا بمشاركة الموارد ذات اﻷصول المختلطة cross-origin resource sharing. وبدون هذه المكتبة لن تتمكن الواجهة البرمجية من تخديم سوى الواجهة الأمامية الموجودة في نفس النطاق الفرعي الذي يحوي الواجهة الخلفية. تستخدم الواجهة الخلفية في تطبيقنا تلك الحزم عند يعمل التطبيق، لهذا علينا تثبيت بعض اعتماديات مرحلة التطوير لتهيئة TypeScript. لهذا ننفذ الأمر التالي: npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript نحتاج الاعتماديات السابقة لتفعيل TypeScript في شيفرة تطبيقنا، واﻷنواع التي تستخدمها Express.js والاعتماديات اﻷخرى. وسيوّفر هذا اﻷمر وقتك عند استخدام بيئات تطوير متكاملة مثل WebStorm أو VSCode التي تتيح لك ميزات اﻹكمال التلقائي لبعض التوابع أثناء كتابة الشيفرة. ينبغي أن تكون الاعتمادات بشكلها النهائي ضمن الملف package.json كالتالي: "dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" } وهكذا تكون جميع الاعتماديات اللازمة لعمل تطبيقنا جاهزة! هيكيلية مشروع واجهة برمجية REST باستخدام TypeScript سنستخدم في مشروعنا ثلاث ملفات وهي: app.ts/. common/common.routes.config.ts/. users/users.routes.config.ts/. إن الفكرة من استخدام مجلدين (common و users) في مشروعنا هو تكوين وحدتين لكل منهما مسؤولياتها الخاصة. وبالتالي قد نعطي الوحدتين بعض أو كل الميزات التالية: تهيئة الوجهة Rourte: لتعريف الطلبات التي يمكن أن تتعامل معها الواجهة البرمجية. خدمات Services: لتنفيذ مهام مثل الاتصال بقاعدة البيانات وتنفيذ استعلامات أو الاتصال بخدمات خارجية ضرورية للاستعلام. وسيط أو برمجية وسيطة middleware: للتحقق من صلاحية طلب معيّن قبل أن يتعامل المتحكم النهائي بالمسار مع تفاصيل الاستعلام. وحدات Models: لتعريف وحدات البيانات التي تطابق تخطيط قاعدة بيانات محددة، لتسهيل تخزين البيانات واستعادتها. متحكّمات controllers: لفصل معلومات تهيئة المسار أو الوجهة التي سننتقل إليها route configuration عن الشيفرة التي تعالج في النهاية (بعد المرور على أية برامج وسيطة) طلب هذا المسار أو تستدعي دوال خدمة من مستوى أعلى عند الحاجة، وتعيد الاستجابة على هذا الطلب إلى العميل. للمجلد هيكلية ذات تصميم بسيط متوافق مع الواجهة البرمجية. ملفات مسارات Routes شائعة الاستخدام في TypeScript سنعمل على تنظيم ملفات Routes في تطبيق Express.js ،لذا سننشئ الملف common.routes.config.ts في المجلّد common ونضع فيه الشيفرة التالية: import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } } إن الطريقة التي ننشئ فيها المسارات routes هنا اختيارية، لكن، وطالما أننا نعمل مع TypeScript، فمن الجيد أن نتدرب على بناء المسارات باستخدام الوراثة من خلال التعليمة extends كما سنرى بعد قليل. إذ ننشئ صنفًا رئيسيًا لتحديد سلوك وملامح مشتركة بين جميع مسارات التطبيق وسيكون لجميع الملفات في هذا المشروع السلوك ذاته، كما سيكون لها اسم وإمكانية الوصول إلى كائن Express.js اﻷساسي في تطبيقنا Application ثم ننشئ صنفًا فرعيًا منه لتحديد سلوك مسارات معينة. الآن، يمكننا أن نبدأ في إنشاء ملف مسارات المستخدمين في مجلد المستخدمين users، دعونا ننشئ ملف باسم users.routes.config.ts ونكتب بداخله الشيفرة البرمجية التالية import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } } هنا، نقوم باستيراد الصنف CommonRoutesConfig ونوسعه إلى صنف جديد UsersRoutes. ونرسل من خلال الدالة البنائية constructor التطبيق (أي كائن express.Application الرئيسي) واسم UsersRoutes إلى دالة البناء الخاصة بـ CommonRoutesConfig. قد يبدو المثال بسيطًا لكن عند توسيع الأمر ليشمل عدة ملفات routes سيساعدنا ذلك في تفادي تكرار الشيفرة. لنفترض أننا سنحتاج إلى إضافة ميزات جديدة في هذا الملف، مثل إدارة السجلات وتسجيل الأحداث، عندها بإمكاننا إضافة الحقول الضرورية إلى الصنف CommonRoutesConfig وعندها ستتمكن جميع اﻷصناف المشتقة منه من الوصول إلى هذه الميزة. استخدام دوال TypeScript المجرّدة لتقديم وظائف متشابه بين اﻷصناف ماذا لو أردنا الحصول على وظائف متشابهة ضمن اﻷصناف المختلفة (مثل تهيئة نقاط الاتصال بالواجهات البرمجية)، على الرغم من اختلاف طرق تنفيذ هذه الوظائف من صنف ﻵخر؟ أحد الخيارات المتاحة هو استخدام ميزة تُدعى التجريد abstraction في TypeScript. لنحاول إنشاء دالة مجرّدة بسيطة جدًا يرثها الصنف UsersRoutes (وبقية أصناف التوجيه التي قد ننشئها لاحقًا) من الصنف CommonRoutesConfig. ولنفترض أننا سنجبر كل الوجهات على امتلاك دالة (حتى نتمكن من استدعائها من الدالة البانية المشتركة) تُدعى()configureRoutes، وفيها نصرّح عن نقاط الوصول الخاصة بكل مورد من موارد الصنف. ولتنفيذ اﻷمر، سنضيف هذه اﻷشياء إلى الملف common.routes.config.ts: الكلمة المحجوزة abstract إلى السطر الذي يضم الكلمة class كي نفعّل خاصية التجريد لهذا الصنف. تصريح عن دالة جديدة في نهاية الصنف abstract configureRoutes(): express.Application، تجبر أي صنف مشتق من الصنف CommonRoutesConfig على تقديم آلية تطابق توقيع الدالة function signature، وسيرمي مصرّف TypeScript خطأ إن لم يجد آلية كهذه. استدعاء الدالة ()this.configureRoutes في نهاية الدالة البانية طالما أننا متأكدين من وجود هذه الدالة. ستبدو الشيفرة اﻵن كالتالي: import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; } وهكذا ينبغي على كل صنف مشتق من الصنف CommonRoutesConfig أن يمتلك دالة ()configureRoutes تُدعى تُعيد كائنًا من النوع express.Application، وبالتالي لا بد من تحديث الملف users.routes.config.ts: import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } } دعنا نراجع ما فعلناه حتى الآن: أدرجنا بداية الملف common.routes.config ومن ثم الوحدة البرمجية express، وعرفنا بعد ذلك الصنف UserRoutes الذي أردناه أن يرث الصنف اﻷساسي CommonRoutesConfig وبالتالي سيضمّ الدالة ()configureRoutes ويقدّم آلية لتنفيذها. وﻹرسال المعلومات عبر الصنف CommonRoutesConfig، نستخدم الدالة البانية للصنف التي تتوقع تمرير كائن express.Application إليها، وهذا ما سنشرحه بتفاصيل أكثر لاحقًا. نمرر من خلال الدالة ()super التطبيق إلى الدالة البانية للصنف CommonRoutesConfig واسم الوجهة (وهي في هذه الحالة UsersRoutes). وتستدعي الدالة ()super بدورها الدالة ()configureRoutes. تهيئة وجهات Express.js الخاصة بنقاط الوصول إلى المستخدمين ستكون الدالة المكان الذي ننشئ فيه نقاط الوصول بين المستخدم والواجهة البرمجية REST. وفيها نستخدم التطبيق مع وظائف التوجيه من خلال Express.js. تكمن الفكرة في استخدام الدالة ()app.route لتفادي تكرار الشيفرة، وهذا اﻷمر سهل نسبيًا طالما أننا ننشئ واجهة برمجية REST ذات موارد محددة تمامًا. إن المورد اﻷساسي في تطبيقنا هو users، ولدينا حالتان: عندما يريد مستدعي الواجهة البرمجية إنشاء مستخدم جديد أو الحصول على قائمة بالمستخدمين الموجودين، لا بد أن يكون اسم المورد فقط users في نهاية المسار إلى المورد (لا نريد الخوض في هذه الحالة في فلترة أو تنظيم نتائج الاستعلام أو غيرها من العمليات في هذا التطبيق). عندما يريد المستدعي أن ينفّذ عملية ما على سجل مستخدم، وعندها لابد أن يكون نمط المسار إلى المورد كالتالي: users/:userId. تتيح آلية عمل الدالة ()route. في Express.js التعامل مع طلبات HTTP بأسلوب متسلسل أنيق، لأن جميع التوابع ()get. و ()post. وغيرها، ستعيد نفس النسخة من الكائن التي يعيدها التابع ()route.. لهذا سننهي عملية التهيئة كالتالي: configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // /users/:userId يُنفّذ البرنامج الوسيط هذه الدالة قبل أي استعلام // لكنه لا ينفذ شيئًا اﻵن // next() بل يمرر ببساطة التحكم إلى الدالة التالية في التطبيق تحت next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; } تتيح الشيفرة السابقة لعميل الواجهة البرمجية المتوافقة مع REST استدعاء نقطة الوصول users باستخدام أحد الاستعلامين POST أو GET، وتتيح له بنفس اﻷسلوب استدعاء نقطة الوصول users/:userId من خلال استعلامات GET أو PUT أو PATCH أو DELETE. كما أضفنا إلى نقطة الوصول users/:userId برنامج وسيط يستخدم الدالة ()all التي تُنفَّذ قبل أي استدعاء للدوال ()get أو ()put أو ()patch أو ()delete. وسيكون لهذه الدالة أهميتها عندما ننشئ لاحقًا مسارات يصل إليها فقط المستخدمين المستوثقين. وقد تلاحظ أن جميع الدوال ()all -وأية أجزاء من البرنامج الوسيط- تمتلك ثلاثة أنواع من الحقول Request و Response و NextFunction: النوع Request هو طريقة Express.js لتقديم طلبات HTTP التي يعالجها. ويُحدّث هذا النوع ويوسّع نوع Node.js اﻷصلي الذي يتعامل مع الطلبات. النوع Response هو طريقة Express.js لتقديم استجابات HTTP التي يعالجها. ويُحدّث هذا النوع ويوسّع نوع Node.js اﻷصلي الذي يتعامل مع الطلبات. كما يستخدم الحقل NextFunction الذي لا يقل أهمية عن الاثنين السابقين كدالة استدعاء تسمح بتمرير التحكم إلى أية دوال أخرى يضمها الوسيط. وتتشارك جميع البرامج الوسيطة نفس كائنات الطلب والاستجابة قبل أن يُرسل المتحكم الاستجابة إلى صاحب الطلب في النهاية. الملف app.ts المدخل إلى Node.js بعد أن وضعنا هيكلية بسيطة للتوجّه في التطبيق، ننتقل إلى تهيئة مدخل entry point إليه، لهذا سننشئ الملف app.ts في المجلد الجذري للمشروع ونبدؤه بالشيفرة التالية: import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug'; هناك إدراجان فقط جديدان في هذا الملف هما: http: وهو وحدة برمجية أصلية في Node.js، نحتاجها في تشغيل تطبيق Express.js. body-parser: وهو وسيط يأتي مع Express.js، ويفسّر الطلب (صيغة JSON في حالتنا) قبل وصول التحكم إلى معالج الطلب الذي حددناه. ننتقل بعد إدراج الملفات إلى التصريح عن المتغيرات التي نريد استخدامها: const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app'); تعيد الدالة ()express كائن تطبيق Express.js اﻷساسي الذي نمرره عبر تطبيقنا، من خلال إضافته بدايةً إلى الكائن http.Server (نحتاج إلى تشغيله بعد تهيئة الكائن express.Application الخاص بتطبيقنا) نترقب اﻵن الطلبات إلى المنفذ 3000 الذي تفهمه TypeScript على أنه من النوع Number بدلًا من المنافذ المعيارية مثل 80 لطلبات HTTP و 443 لطلبات HTTPS التي تُستخدم نمطيًا للاتصال مع الواجهة الأمامية للتطبيق. لماذا المنفذ 3000؟ لا توجد قاعدة تنص على أن المنفذ يجب أن يكون 3000، وسيُختار رقم المنفذ اعتباطيًا إن لم نخصص واحدًا. لكن الرقم 3000 يستخدم بكثرة في أمثلة توثيق Node.js و Express.js لهذا أكملنا على هذا النحو! هل يمكن أن تتشارك Node.js المنفذ مع الواجهه اﻷمامية؟ يمكننا أن نشغّل التطبيق محليًا على منفذ مخصص حتى لو أردنا من الواجهة الخلفية أن تستجيب للطلبات على المنافذ المعيارية. يتطلب اﻷمر خادم وكيل عكسي reverse proxy له نطاق رئيسي أو فرعي يستقبل الطلبات على أحد المنفذين 80 أو 443 ثم يعيد توجيهها إلى المنفذ الداخلي 3000. تتبع المصفوفة routes ملفات التوجيه الخاصة بنا لأغراض التنقيح كما سنرى، ونرى أخيرا كيف ينتهي debugLog بدالة مشابهة للدالة console.log، لكنها أفضل من ناحية إمكانية الضبط الدقيق، إذ تغطي تلقائيًا ما نريد أن ندعو به ملفاتنا أو وحداتنا البرمجية (دعوناه في حالتنا "app" عندما مررناه كنص إلى الدالة البانية ()debug). أصبحنا اﻵن جاهزين لتهيئة جميع وحدات Express.js الوسيطة والوجهات إلى الواجهة البرمجية: // JSON نضيف هنا وسيط لتفسير كل الطلبات القادمة بصيغة app.use(express.json()); // CORS نضيف هنا وسيطًا للسماح بالطلبات مختلطة الأصول app.use(cors()); //expressWinston نحضّر هنا إعدادات الوحدة الوسيطة المخصصة ﻹدارة التسجيل //Exprress.js التي تعالجها HTTP والتي تسجّل جميع طلبات const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // سجل الطلب على سطر واحد إن لم يكن التنقيح مفعّلًا } // هيئ المسجل بالإعدادات السابقة app.use(expressWinston.logger(loggerOptions)); //Express.js إلى مصفوفتنا بعد إرسال كائن UserRoutes نضيف //كي تضاف الوجهات إلى التطبيق routes.push(new UsersRoutes(app)); // هذه وجهة بسيطة للتأكد أن كل شيء يعمل كما هو مطلوب const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) }); يرتبط expressWinston.logger تلقائيًا بالمكتبة Express.js ويسجل التفاصيل من خلال نفس البنية التحتية التي يستخدمها debug، وذلك لكل طلب مكتمل. وستنسق الخيارات التي مررناها إليه وتلوّن خرج الطرفية التي تعرض السجلات، إضافة إلى عرض سجلات أكثر تفصيلًا (وهو الأمر الافتراضي) عندما نفعّل نمط التنقيح. وتجدر الملاحظة إلى ضرورة تعريف وجهاتنا بعد إعداد expressWinston.logger. وأخيرًا نأتي إلى اﻷمر اﻷكثر أهمية: server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); //console.log الحالة الوحيدة التي لن تحاشى فيها استخدام // هو معرّفة متى ينتهي الخادم من عملية اﻹقلاع console.log(runningMessage); }); تشغّل الشيفرة السابقة الخادم فعليًا، وعندما يبدأ تنفيذها يشغّل Node.js توابع الاستدعاء الخاصة بنا والتي تسجّل في وضع التنقيح أسماء كل الوجهات routes التي أعددناها وهي UsersRoutes حتى اللحظة. تنبهنا دوال الاستدعاء بعد ذلك إلى أن الواجه الخلفية جاهزة لاستقبال الطلبات، حتى لو كانت تعمل في وضع الإنتاج. تحديث الملف package.json لنقل شيفرة TypeScript إلى جافا سكريبت وتشغيل التطبيق بعد إنجاز البنية اﻷساسية للتطبيق وتحضيره للتشغيل، نحتاج أولًا إلى بعض اﻹعدادات لتمكين نقل transpilation شيفرة TypeScript: { "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } } نضيف أخيرًا بعض اللمسات النهائية على الملف package.json على هيئة سكربتات: "scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" }, يعمل السكربت test كملف مؤقت سنستبدله لاحقًا. تنتمي الوحدة tsc في السكربت start إلى TypeScript، وهو المسؤول عن نقل شيفرة TypeScript إلى جافا سكريبت التي ستظهر في المجلد dist. ثم نشغّل النسخة المبنية من التطبيق باستخدام التعليمة node ./dist/app.js. نمرر الوسيط unhandled-rejections=strict-- إلى Node.js (حتى في النسخ 16 وأعلى) لإيقاف التنفيذ عند ظهور خطأ غير محسوب في الشيفرة، ويسهّل ذلك معرفة سبب الخطأ وتصحيحه وهذا اﻷسلوب أوضح من الخيار اﻵخر وهو الاعتماد على كائن السجلات expressWinston.errorLogger الذي يزوّدك بقائمة اﻷخطاء بعد توقف المصرّف. ومعنى ذلك أننا سنترك Node.js يعمل على الرغم من وجود خطأ غير محسوب في الشيفرة وقد يسبب ذلك سلوكًا غير متوقع للخادم وظهور أخطاء أخرى قد تكون أكثر تعقيدًا. يستدعي السكربت debug السكربت start لكنه يعرّف أولًا متغير البيئة DEBUG. ولهذا المتغير تأثير في تمكين جميع عبارات ()debugLog (إضافة إلى تلك التي تقدمها Express.js، والتي تستخدم نفس وحدة التنقيح debug التي نستخدمها) لعرض تفاصيل مفيدة على الطرفية، وإلا ستختفي هذه التفاصيل عند تشغيل الخادم في وضع اﻹنتاج باستخدام التعليمة npm start. جرّب تنفيذ اﻷمر npm run debug. بنفسك، وقارن نتائج الخرج على الطرفية مع تلك التي تنتج عن تنفيذ npm start. تلميح: بإمكانك تحديد خرج التنقيح ليعطي فقط عبارات ()debugLog الموجودة في الملف app.ts، وذلك باستخدام DEBUG=app بدلًا من *\=DEBUG. فالوحدة debug مرنة عمومًا، وهذه الميزة ليست استثناءً. قد يحتاج مستخدمي ويندوز استبدال export بالتعليمة SET لأن export هي الطريقة التي تعمل على لينكس و ماك أو إس. أما إن أردت دعم عدة بيئات تطوير في تطبيقك، جرّب الحزمةcross-env package التي تزوّدك بحلول واضحة لهذه المسألة. اختبار الواجهة الخلفية مع تنفيذ أحد اﻷمرين npm run debug أو npm start ستكون الواجهة الخلفية جاهزة لتلقي الطلبات على المنفذ 3000. يمكننا عندها استخدام أحد المكتبات cURL أو Postman أو Insomnia لاختبار الواجهة الخلفية. وطالما أننا لم ننشئ سوى هيكلية للمورد users، بإمكاننا ببساطة إرسال طلبات دون جسم للطلب لنتأكد أن كل شيئ يجري كما هو متوقع، فمثلًا: curl --request GET 'localhost:3000/users/12345' ستعيد عندها الواجهة الخلفية الاستجابة: GET requested for id 12345. وعند استخدام POST: curl --request POST 'localhost:3000/users' \ --data-raw '' وغيرها من أنواع الطلبات، ستعيد الواجهة الخلفية نفس الاستجابة. الخلاصة بدأنا في هذا المقال في إنشاء واجهة برمجية REST بتهيئة المشروع من الصفر ومن ثم دخلنا في أساسيات إطار العمل Express.js. خطونا بعد ذلك أولى خطواتنا في احتراف TypeScript عن طريق بناء نموذج UsersRoutesConfig يرث CommonRoutesConfig وسنعيد استخدام هذا النموذج في الجزء الثاني من هذه السلسلة. أنهينا العمل بعد ذلك بتهيئة ملف المدخل app.ts لاستخدام الوجهات، ومن ثم تهيئة ملف package.json بالسكربتات اللازمة لبناء وتشغيل التطبيق. وعلى الرغم من استخدام أساسيات الواجهة البرمجية REST مع Express.js و TypeScript في مقالنا، فسوف نركز في المقال التالي على بناء متحكمات مناسبة للموارد والتعرف على نماذج أخرى مثل الخدمات والوحدات الوسيطة والمتحكمات وغيرها من الوحدات البرمجية. ترجمة -وبتصرف- للمقال Building a Node.js TypeScript REST API Part1 Express.js اقرأ أيضًا مدخل إلى Node.js وExpress دليلك لربط واجهة OpenAI API مع Node.js شرح فلسفة RESTful - تعلم كيف تبني واجهات REST البرمجية إنشاء مدوّنة باستخدام Node.js و Express (الجزء الأول)
-
التعريف بالمشروع هذه سلسلة من الدّروس مُوجّهة للمبتدئين بتطوير الويب، تهدف إلى تعليم استخدام بيئة Node.js وإطار العمل Express من خلال بناء مدوّنة متكاملة تسمح للكُتَّاب بإضافة التّدوينات وتسمح للزوّار بإنشاء حسابات والتّعليق على التُدوينات. لن تقتصر هذه السّلسلة على شرح Express، بل ستقدّم شرحًا (نأمل أن يكون وافيًا) للعديد من المفاهيم المتعلّقة بتطوير الويب من جهة الخوادم (Server-side web development)، حيث سنتطرّق إلى تثبيت خادوم MySQL مع شرح استخدامه في Node.js بالإضافة إلى آليّة عمل بروتوكول HTTP ونظام إدارة المستخدمين وإنشاء الجلسات، في نهاية السّلسلة سنُلقي نظرة على مواضيع تحسين الأداء والأمان قبل نشر المشروع على الويب. في نهاية هذه السّلسلة من المفترض أن يكون المتعلّم قادرًا على التّعامل مع بيئة Node.js بسهولة ويمكنه إنشاء الخوادم وقواعد البيانات وإنشاء برامج تصل بين هذه الأجزاء. إذا كنت قادمًا من عالم PHP، فستكون هذه السّلسلة مناسبة لك أيضًا على حدٍّ سواء. Node.js لعلّك سمعت من قبل بـNode.js، لكنّها ما تزال غامضةً بشكل أو بآخر خصوصًا بين المطوّرين العرب، والسبب ربّما يكون ضعف الرّغبة في التّغيير أو صعوبة تأمين استضافة مشاريعها مقارنةً باستضافة مشاريع PHP أو غير ذلك من الأسباب. Node.js هي بيئة تطوير تسمح لنا بكتابة البرامج وتنفيذها باستخدام JavaScript، اللّغة الّتي كانت حتى وقت قريب حبيسة المتصفّح؛ لكنّها لم تعد كذلك بل أصبحت تُستخدم في كتابة مشاريع الويب وتطبيقات سطح المكتب وحتى التّطبيقات الّتي تعمل في الطّرفيّة، وربّما يعود الفضل في ذلك إلى Node.js ذاتها. إن كنت استخدمت PHP مع Apache لإنشاء موقع من قبل، فستجد أن Node.js تستطيع القيام بالمهمّة ذاتها وأكثر، وبأسلوب أبسط وأكثر تنظيمًا وتوفيرًا للوقت. Express لعلّ Express أهمّ مكوّنات مشروعنا، وعليه سينصبّ معظم شرحنا. Express باختصار هو إطار عمل لمشاريع الويب يعمل في بيئة Node.js، فهو بالنّسبة لـNode.js يقابل Laravel بالنّسبة لـPHP، وإن كان يختلف عنه كثيرًا في الفكرة الّتي يقوم عليها، إذ أنّ Express يلتزم بفلسفة Node.js الّتي تهدف إلى تجزئة المشاريع الضّخمة إلى وحدات، لن تجد في Express ذاته وحدات تتعامل مع قواعد البيانات أو تتولّى إدارة الجلسات وحماية كلمات المرور وما إلى ذلك، بل عليك أن تعتاد أنّ لكلّ شيء في Node.js وحدة مستقلّة يمكن استيرادها واستخدامها مع الوحدات الأخرى لنحصل على مشروع يسهل تطويره وصيانته دون الاعتماد على مكوّن ضخم من جهة مُطوّرة واحدة قد يكون مصير تطويره إلى الإهمال في المستقبل. يتولّى Express إدارة الرّوابط وتوجيهها فقط، ويمكن توسيعه باستخدام ما يُسمّى "البرامج الوسيطة" (middleware) التي تُشبه إلى حدّ ما إضافات المتصفّح التي تستخدمها، فهي تضيف المزيد من المزايا إلى الوظيفة الرئيسيّة لـExpress، وسنجد مثلاً برنامجًا وسيطًا يحفظ الجلسات باستخدام الكعكات (cookies) وآخر يخزّن نتائج الاستعلامات بشكل مؤقّت لتسريع استجابة الخادوم... من الواجب أن نذكر أنّ Express ليس إطار العمل الوحيد المتوفّر في Node.js، ولكنّه قد يكون الأشهر لبساطته الشّديدة وهيكليّته الممتازة. يبدو المستقبل واعدًا لمشاريع مثل Koa الّذي يستفيد من المزايا القادمة إلى الإصدارات المستقبليّة من JavaScript لتحسين كتابة الشّيفرة أكثر. يُشرف على تطوير Koa الفريق المطوّر لـExpress ذاته. MySQL سنستخدم لغة قواعد البيانات الشّهيرة SQL (بنكهة MySQL إن جاز التّعبير) لتخزين التّدوينات والتّعليقات ومعلومات المستخدمين، إن كنت لا تعرف الكثير عن SQL فلا بأس، لأنّ صياغتها غاية في السّهولة وتكاد تُشبه جمل اللغة الإنكليزيّة الحقيقيّة! تثبيت Node.js للحصول على آخر إصدار من Node.js: إذا كنت تستخدم Windows أو Mac فتوجّه إلى الصّفحة الرئيسية لموقع Node.js سيقوم الموقع بالتّعرّف على نظامك وبنيته وانتقاء برنامج التّثبيت المناسب، ليس عليك سوى الضّغط على زر Install لتنزيل برنامج التّثبيت ثم فتحه ومتابعة الخطوات. إذا كنت تستخدم Arch Linux، فستجد أحدث إصدار من Node.js ضمن مستودعات مستخدمي Arch (AUR). ويمكن تثبيته بالأمر التالي إذا كنت تستخدم yaourt (الأمر مشابه بالنّسبة لكلّ البرامج التي توفّر وصولاً إلى AUR? yaourt -S nodejs --noconfirm سيُطلب منك إدخال كلمة المرور إلى مستخدمك أو كلمة المرور إلى المستخدم الجذر (إن وُجدت). كما يمكن التّثبيت بالطّريقة المشروحة في الخيار التّالي. إذا كنت تستخدم توزيعة Linux أخرى، فقد تجد إصدارًا قديمًا من Node.js ضمن مستودعات توزيعتك، لذا يُنصح بتثبيت Node.js عن طريق مُدير إصدارات Node.js المتوفّر على GitHub، ويتمّ التثبيت بالطّريقة التالية: قم بتنزيل آخر نسخة من مدير إصدارات Node.js عن طريق زر Download ZIP في صفحة المشروع على GitHub. فكّ ضغط الملفّ الذي قمت بتنزيله انتقل بالطّرفيّة إلى مسار المجلد الناتج عن العمليّة السابقة، مثلاً: cd ~/Downloads/n-master ثم قم بتنفيذ أمر بناء المشروع: sudo make install قم بتثبيت آخر إصدار مستقرّ من Node.js مستخدمًا الأمر n الذي يوفّره مدير إصدارات Node.js: sudo n stable سيُطلب منك إدخال كلمة المرور إلى مستخدمك أو كلمة المرور إلى المستخدم الجذر إن وُجدت. من فوائد مُدير إصدارات Node.js إمكانيّة التبديل بشكل سريع بين عدّة إصدارات من Node.js، فقد ترغب أحيانًا بتجربة بعض المزايا المتوفّرة في إصدار غير مستقرّ (مثل v0.11 الذي يتضمّن بعضًا من مُكوّنات ECMAScript 6) مُستخدمًا الأمر n latest، ولكنّك ترغب بالعودة للعمل على مشاريع جادّة ضمن إصدار مستقرّ. ولهذا يمكنك استخدام الأمر n stable. يمكن أيضًا تثبيت الإصدارات الحديثة من Node.js على Ubuntu والتّوزيعات الأخرى باتّباع التّعليمات الرّسميّة المتوفّرة على صفحة Node.js على GitHub. للتحقّق من تثبيت Node.js اكتب الأمر التالي في الطّرفيّة (أو سطر أوامر Windows): node -v لتحصل على نتيجة برقم إصدار Node.js الذي قمت بتثبيته، مثل v0.10.33. إن كانت النتيجة تُفيد بعدم وجود الأمر مثل bash: node: command not found (على Linux) أو node is not recognized as an internal or external command... (على Windows)، فتحقّق من اتّباع الخطوات السّابقة مجدًدًا. إنشاء المشروع لنبدأ العمل بشكل نظيف، أنشئ مُجلّدًا جديدًا في مكان ما في جهازك وانتقل إليه باستخدام الطّرفيّة، سأقوم بإنشاء مجلّد ضمن مسار مُستخدمي /home/f/ وأسمّيه my-blog، ثم سأنتقل إليه باستخدام الأمر cd (اختصارًا لـchange directory) (الذي يتطابق في Linux وMac وWindows): cd /home/f/my-blog في Windows، قد يكون الأمر مُشابهًا لهذا: cd C:\Users\f\my-blog سنستخدم الأمر init الذي يوفّره مُدير حزم Node (npm) لإنشاء مشروع جديد، افتح الطّرفيّة (سطر الأوامر في Windows) واكتب الأمر التالي: npm init سيطرح البرنامج عليك مجموعة من الحقول لتعبئتها: name: اسم المشروع، ويقترح npm اسم المجلد الحالي كاسم للمشروع، ويمكنك الأخذ بالاقتراح بترك الحقل فارغًا وضغط Enter. version: إصدار المشروع (يمكنك تركه كما هو). description: وصف للمشروع. entry point: الملفّ الرئيسيّ الذي ينطلق منه المشروع، يمكنك تركه كما هو وإنشاء الملف index.js لاحقًا. test command: الأمر الذي يجب أن ينفّذه npm عندما يطلب منه تنفيذ الاختبارات على المشروع، أي عندما ينفذ الأمر npm test ضمن مجلد المشروع الرئيسيّ. سنتركه فارغًا الآن. git repository: مسار مستودع git الذي ستستخدمه لإدارة المشروع، يمكن أن يكون رابط http:// أو git:// ويمكن تعديله لاحقًا. keywords: الكلمات المفتاحية للمشروع مفصولة بفاصلة لاتينية (,)، أمثلة: blog, mysql, expressjs, tutorial. author: كاتب المشروع. license: رخصة المشروع، يمكن استخدام أي رخصة مثل GPLv2 أو MIT. بعد الانتهاء سيعرض البرنامج عليك المعلومات التي أدخلتها وهي جاهزة للكتابة إلى ملفّ package.json، اكتب yes لكتابة الملفّ. الملفّ package.json هو نقطة الانطلاق في كلّ مشاريع Node.js، ويُستخدم للتعريف بالمشروع ووصفه عند نشره في سجلّ حزم npm، والأهم من ذلك أنّه يستخدم لحفظ ما يعتمد عليه المشروع من حزم مع أرقام الإصدارات المطلوبة لكلّ حزم؛ ومن خلال هذا الملفّ يمكن إستنساخ المشروع وإعادة تثبيت المتطلّبات للتعديل عليه في وقت آخر أو من قبل أشخاص آخرين. تثبيت متطلّبات المشروع سيعتمد مشروعنا على إطار العمل Express كما هو واضح، بالإضافة إلى قواعد بيانات MySQL لتخزين التدوينات والمستخدمين وتعليقاتهم، سنحتاج أيضًا إلى بعض المتطلّبات الأخرى التي سنثبّتها في وقت الحاجة إليها. لتثبيت أحدث إصدار من express وحفظه كمتطلّب ضمن ملفّ package.json، نفّذ الأمر التّالي: npm install express --save ملاحظة: سنعتمد الإصدار الرّابع في هذه السلسّلة لكونه أحدث إصدار في وقت كتابتها، للتأكّد من تثبيت الإصدار الرّابع حتّى بعد صدور إصدارات أحدث، يمكنك استخدام الأمر: npm install express@4.10.* --save يحتاج تثبيت MySQL إلى خطوتين: الأولى تثبيت الخادوم الذي يوفّر قاعدة البيانات، ويُنجز بطريقة مختلفة لكل نظام تشغيل: في Windows وMac OS X، يمكن تثبيته بتنزيل برنامج التّثبيت المناسب لإصدار النّظام وبنيته من الموقع الرّسمي ثمّ اتّباع خطوات التّثبيت كما في تثبيت أي برنامج آخر. في Arch Linux، أنصح باستخدام MariaDB، وهي بديل مطابق تمامًا لـMySQL ويحلّ محلّه بدون الاضطرار لتعديل أي جزء من الشيفرة، ويمكن تثبيته من خلال مستودعات مستخدمي Arch بالأمر التّالي: yaourt -S mariadb --noconfirm في توزيعات Linux الأخرى مثل Ubuntu وFedora، فقد تتوفّر MariaDB وMySQL في المستودعات الرّسميّة ويمكن تثبيتها باستخدام apt-get وyum. الخطوة الأخرى تتضمّن تثبيت عميل MySQL (أو ما يسمى MySQL client)، وهو الجزء الذي سيتواصل مع الخادوم ليجلب نتائج الاستعلام من قاعدة البيانات ويوفّرها لمشروعنا، وفي حالتنا هذه ليس سوى وحدة Node.js يمكن تثبيتها بسهولة عبر npm install واستخدامها ضمن مشروعنا؛ تتوفّر العديد من الوحدات التي تقدّم إمكانية التواصل مع خادوم قواعد بيانات MySQL، ومن أفضلها الوحدة mysql التي يمكن تثبيتها بتنفيذ الأمر: npm install mysql --save نصيحة: يمكن تثبيت الحزمتين بأمر واحد: npm install express mysql --save بعد تثبيت الحزمتين، سنلاحظ إضافة حقل جديد في ملفّ package.json يوضّح متطلّبات المشروع التي حفظناها إلى الآن (قد تختلف أرقام الإصدارات لديك): "dependencies": { "express": "^4.10.6", "mysql": "^2.5.4" } إنشاء قاعدة البيانات وإدخال بعض التّدوينات كعيّنة وجود بعض التدوينات سيساعدنا في بناء الواجهة ورؤية التغييرات بسهولة أكبر، لذلك سنقوم بإنشاء قاعدة البيانات وإدخال بعض التّدوينات إليها قبل كلّ شيء. لإنشاء قاعدة البيانات وإدخال التدوينات، سنقوم بالتواصل مع الخادوم عبر الطّرفيّة، مستخدمين البرنامج mysql الذي يتمّ تثبيته تلقائيًّا عند تثبيت خادوم MySQL أو MariaDB. افتح الطّرفية ونفّذ الأمر: mysql -u root ملاحظة: إن اخترت اسمًا للمستخدم وكلمة مرور مختلفين أثناء التثبيت، فيمكن إدخالها بالطريقة الآتية (سيطلب منك إدخال كلمة المرور إلى هذا المستخدم): mysql -u username -p ستظهر شاشة مشابهة لهذه: ملاحظة: إن واجهتك مُشكلات في بدء البرنامج mysql، جرّب إعادة تشغيل النّظام. ملاحظة (2): يمكنك الاستغناء عن استعمال صدفة MySQL إذا كنت قد ثبّتت PHPMyAdmin على جهازك، حيث بإمكانك إدخال الأوامر ذاتها في مربّع الاستعلامات. في صدفة MySQL هذه يمكننا إدخال أوامر MySQL ليقوم الخادوم بتنفيذها على الفور. سنقوم بإنشاء قاعدة بيانات المدوّنة، وسنسمّيها myblog: CREATE DATABASE myblog; النتيجة التّالية دليل على نجاح التنفيذ: Query OK, 1 row affected (0.00 sec) اتّصل بقاعدة البيانات الجديدة بالأمر: connect myblog لنقم بإنشاء جدول للمستخدمين وآخر للتدوينات ثم لندخل مستخدمًا مع 4 تدوينات كتبها في أيام مختلفة، يمكنك نسخها ولصقها في صدفة MySQL فحسب: CREATE TABLE `users` (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50), password VARCHAR(500) NOT NULL, full_name VARCHAR(50), is_author BOOLEAN DEFAULT , UNIQUE INDEX (username)); INSERT INTO `users` (username, password, full_name, is_author) VALUES ("admin", "$2a$08$Z3FpAQwRgj7W0i71TtizFO7QDjpsIRNJfHh6mLgRJRJBtheKJh1Tu", "admin", 1); CREATE TABLE `posts` (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100), body LONGTEXT, date TIMESTAMP, author_id INT, slug VARCHAR(50), UNIQUE INDEX (slug), FOREIGN KEY (author_id) REFERENCES `users` (id)); INSERT INTO `posts` (title, body, date, author_id, slug) VALUES ("مرحبًا بالعالم!", "مرحبًا بكم في مدوّنتي المتواضعة!", "2014-12-29", 1, "hello-world"); INSERT INTO `posts` (title, body, date, author_id, slug) VALUES ("اقتباسات (1)", "الغني لو سئل عن تحسين العمل والحياة فسوف يقول: نحن نعرف أن البؤس غيرمفرح والواقع أن البؤس مادام بعيداً عنا فإننا نتسلح بفكرة أنه غير مفرح. ولكن لا تتوقع منا أن نفعل أي شيء بصدده. نحن آسفون لطبقاتكم الدنيا مثل مانحن آسفون لقطة جرباء...غير أننا سنقاتل كالمردة ضد أي تحسين لظرفكم. نحن نشعر انكم مأمونون أكثر وأنتم في حالكم هذا. إن الواقع الراهن يناسبنا ولسنا مستعدين لمخاطرة تحريركم حتى بساعة إضافية في اليوم هكذا يا إخوتي الأعزاء إن كان عليكم ان تعرقوا لدفع رحلاتنا إلى إيطاليا فلتعرقوا ولتحل عليكم اللعنة ― جورج أورويل، متشردًا في باريس ولندن", "2014-12-30", 1, "quotes-1"); INSERT INTO `posts` (title, body, date, author_id, slug) VALUES ("اقتباسات (2)", "التليفزيون يُغرقك في بحر من الأصوات والألوان بحيث لا تجد الوقت الكافي لتفكر أو تنتقد... إنه يقدم لك الأفكار الجاهزة ولا يسمح لك بالانتقاد الذي يسمح به الكتاب. ― راي برادبري، فهرنهايت 451", "2014-12-31", 1, "quotes-2"); INSERT INTO `posts` (title, body, date, author_id, slug) VALUES ("اقتباسات (3)", "أستطيع أن أقول لك يا بنيّ إنّ السّعادة ينبوع يتفجّر من القلب، لا غيث يهطل من السّماء، وأنّ النّفس الكريمة الرّاضية البريئة من أدران الرّذائل وأقذارها، ومطامع الحياة وشهواتها، سعيدة حيثما حلّت. [...] فمن أراد السّعادة فلا يسأل عنها المال والنّسب، وبين الفضّة والذّهب، والقصور والبساتين، والأرواح والرّياحين، بل يسأل عنها نفسه الّتي بين جنبيه فهي ينبوع سعادته وهنائه إن شاء، ومصدر شقائه وبلائه إن أراد. ― مصطفى لطفي المنفلوطي، الفضيلة", "2015-01-01", 1, "quotes-3"); لكلّ سطر في جدول التّدوينات الحقول التالية: العنوان title ونص التدوينة body وتاريخها date ومُعرّف الكاتب author_id الذي يُشير إلى أحد الكُتّاب المُسجلّين في جدول المستخدمين users، ثمّ slug وهو العنوان بالإنكليزية الملائم لاستخدامه ضمن رابط التّدوينة مثل hello-world في http://myblog/posts/hello-world. نحن الآن جاهزون للعمل! في الدّرس القادم سنبدأ بإنشاء الصّفحة الرّئيسيّة لمدوّنتنا، والّتي ستعرض التّدوينات الّتي أضفناها لتوّنا.
-
في الدّرس السّابق قمنا بثتبيت Node.js وخادوم MySQL وبقيّة متطلّبات المشروع، حان الوقت لنبدأ العمل الحقيقيّ! إنشاء صفحة المدوّنة الرئيسيّةأهمّ ما تعرضه الصّفحة الرئيسيّة لكلّ مدوّنة عادةً آخر التّدوينات بتاريخ كتابتها من الأحدث للأقدم، وسنركّز الآن على تطبيق هذا الجزء على أنّ نتوسّع في إضافة الميّزات في وقتٍ لاحق. أنشئ الملفّ index.js الّذي يُمثّل نقطة انطلاق مشروعنا، ولنبدأ باستيراد Express ضمنه: var express = require("express");لنُنشئ الآن تطبيق Express جديد، وهو يمثّل الخادوم الذي يُدير مدوّنتنا بالكامل، يتمّ إنشاء تطبيق Express ببساطة باستدعاء دالّة express الّتي أنشأناها لتوّنا: var express = require("express"); var app = express();تكون الصّفحة الرئيسيّة للمدوّنة على الرّابط الجذر للموقع عادةً، وهو ما نُعبّر عنه بـ/، سنطلب من تطبيقنا الاستجابة للطّلبات التي تصل إلى هذا الرّابط بعرض صفحة HTML تحوي آخر 10 تدوينات مرتّبة وفق تاريخ كتابتها من الأحدث إلى الأقدم: var express = require("express"); var app = express(); app.get("/", function(request, response) { // أرسل HTML });لندع إرسال الصّفحة جانبًا ولنفهم أسلوب استخدام Express، لكلّ تطبيق Express وظائف أربعة تُستخدم في استقبال وتوجيه الطّلبات، وهي get وpost وput وdel، وهذه الوظائف توافق أفعال HTTP الشّائعة. ولكن ما هي أفعال HTTP؟ كيف يعمل HTTP؟في كلّ مرّة تزور صفحة على الويب فإنّ متصفّحك يرسل للخادوم الذي يستضيف الموقع طلبًا بالحصول (GET) على المحتوى في الرابط الذي كتبته، يكون طلب HTTP هذا مشابهًا للمثال التّالي (المُبسَّط عمدًا): GET www.myblog.com/hello-world HTTP/1.1 Accept: text/html Accept-Language: ar-sy,ar; User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:34.0) Gecko/20100101 Firefox/34.0تُسمّى الحقول Accept وAccept-Language... بترويسات الطّلب (Request Headings)، ولكلّ ترويسة معنىً بالنّسبة للخادوم الذي يستقبل الطّلب، فمثلاً يقوم المتصفّح في الحقل User-Agent بالتّعريف عن نفسه، وهو ما يسمح للخادوم بإرسال جواب مخصّص لكلّ متصفّح مثلاً (إن شاء)، وفي الحقل Accept-Language يُرسِل المتصفّح اللّغات الّتي يرغب المستخدم برؤية الجواب بها، فيقوم الخادوم بإرسالة الصّفحة بالعربيّة (سوريا) ar-sy في حالتنا إن توفّرت لديه، أو بالعربية ar كخيار ثانٍ... وهكذا. يردّ الخادوم على الطّلب بجواب HTTP (HTTP Response) الذي يُشبه مثالنا هذا: HTTP/1.1 200 OK Content-Type: text/xml; charset=utf-8 Content-Length: 3918 <!DOCTYPE html> <html lang="ar"> <head> <title>مُدوّنتي - مرحبًا بالعالم!</title> </head> <body> مرحبًا بكم في مدوّنتي المتواضعة! </body> </html>السّطر الأوّل في الجواب يُسمّى سطر الحالة، ويتضمّن حالة الطّلب (حيث الرّقم 200 يعني أن الخادوم تلقّى الطّلب وردّ عليه بما هو متوقّع)، بقيّة السّطور هي ترويسات الجواب (Response Headings) الّتي تعني كلّ واحدة منها شيئًا ما لمستقبل الجواب (المتصفّح). يلي الترويسات متن الجواب (Response Body) الذي يحوي في حالتنا صفحة HTML الّتي سيقوم المتصفّح بعرضها على المستخدم. فعل GET الذي استخدمناه ليس وحيدًا، فهناك أفعال أخرى مثل POST الذي يُستخدم في المتصفّح لإرسال الحقول التي يُعبّئها المستخدم (كتعبئة حقل تسجيل الدّخول)، والفعل DELETE الذّي يستخدم ليطلب من الخادوم حذف محتوى ما (مثل حذف تدوينة من قبل المستخدم). الجدير بالذّكر أن الخادوم حرّ التّصرّف بالطّلبات التي يتلقّاها، والطّريقة التي شرحناها بهذه الأفعال مبنيّة على التّقاليد الشّائعة لاستخدامها، فلا شيء في الحقيقة يمنع الخادوم من حذف تدوينة عندما يتلقّى طلب GET بدلاً من DELETE وإنّما هو عُرف متّفق عليه. لنعد الآن إلى مثالنا السّابق، تقبل الوظيفة get مُعاملين أولهما الرّابط المطلوب التّعامل معه، والأخرى دالّة تقرأ الطّلب وتعدّل جوابه قبل إرسال الجواب للمُتصفّح، يمكن إرسال متن الجواب للمتصفّح من خلال الوظيفة send() للكائن response: var express = require("express"); var app = express(); app.get("/", function(request, response) { var html = "<!DOCTYPE html><html lang='ar'>" + "<head><title>مُدوّنتي!</title></head>" + "<body>" + posts.map(function(post) { return "<li>" + post.title + "</li>"; }).join("") + "</body></html>" response.send(html); });في الحالة الافتراضية سيكون جواب هذا الطّلب بالرّمز 200 OK مع متن يطابق محتوى المُتغيّر html. سنتعرّف فيما بعد على كيفيّة تغيير رموز الحالة بحيث نُرسل الرّمز الشّهير 404 Not Found عندما لا نجد تدوينة على الرّابط المطلوب. سيتوقّف البرنامج في هذه الحالة مُعطيًا خطأ بسبب كون posts غير معرّف، كلّ ما علينا الآن هو جلب التّدوينات من خلال قاعدة البيانات وتخزينها ضمن المُتغيّر posts، نحتاج إذًا لتنفيذ استعلام MySQL لجلب أحدث التدوينات، ولهذا سنقوم باستيراد وحدة mysql التي قمنا بتثبيتها وتأمين الاتصال بقاعدة البيانات: var express = require("express"); var mysql = require("mysql"); var connection = mysql.createConnection({ host: "localhost", user: "root", password: "", database: "myblog" }); connection.connect(); var app = express(); app.get("/", function(request, response) { connection.query( "SELECT * from `posts` ORDER BY date DESC LIMIT 10;", function(err, posts) { if (err) throw err; var html = "<!DOCTYPE html><html lang='ar'>" + "<head><title>مُدوّنتي!</title></head>" + "<body>" + posts.map(function(post) { return "<li>" + post.title + "</li>"; }).join("") + "</body></html>"; response.send(html); }); }); ملاحظة: لا تنسَ تغيير اسم المستخدم وكلمة المرور ليتوافقا مع ما اخترته أثناء تثبيت MySQL. تمتلك وحدة mysql وظيفة createConnection() الّتي تُعيد لنا نسخة من اتّصال بقاعدة البيانات الّتي حدّدناها، والذي يمكن بدؤه باستدعاء الوظيفة connect() ثم تّنفيذ الاستعلامات query() الّتي تتمّ بأسلوب غير متزامن (asynchronous) لتُعيد لنا الصّفوف النّاتجة عن الاستعلام ضمن المعامل الثّاني للدّالة (function(err, posts) { ... }) الّتي تُستدعى بعد انتهاء الاستعلام. بهذه السّطور القليلة التي يمكن فهمها بالقليل من الجهد تمكنّنا من إنشاء مدوّنة بسيطة، وهنا يبرز جمال Node.js الذي يسمح للمبتدئين بتطبيق أفكار قد تبدو بعيدة المنال وجعلها واقعًا ملموسًا! الآن حان وقت تجربة المشروع، نحتاج لإخبار Express بالإنصات إلى الطّلبات الّتي ترد على منفذ معيّن على جهازنا (localhost): var express = require("express"); var mysql = require("mysql"); var connection = mysql.createConnection({ host: "localhost", user: "root", password: "", database: "myblog" }); connection.connect(); var app = express(); app.get("/", function(request, response) { connection.query( "SELECT * from `posts` ORDER BY date DESC LIMIT 10;", function(err, posts) { if (err) throw err; var html = "<!DOCTYPE html><html lang='ar'>" + "<head><title>مُدوّنتي!</title></head>" + "<body>" + posts.map(function(post) { return "<li>" + post.title + "</li>"; }).join("") + "</body></html>"; response.send(html); }); }); app.listen(3000);لبدء البرنامج، افتح الطّرفيّة وانتقل إلى مجلّد المشروع، ثم نفّذ الأمر التّالي: node index.js ستتوقّف المؤشّر في الطّرفيّة عن الاستجابة بسبب انشغال هذه الطّرفيّة بتنفيذ البرنامج، اذهب إلى المتصفّح وانتقل إلى الرابط http://localhost:3000/ وشاهد النّتيجة: قد تبدو الصّفحة غاية في البساطة وخالية من أي عُنصر جماليّ، لكنّ ما يهمّنا الآن هو أنّنا قمنا بإنشاء خادوم يتواصل مع قاعدة بيانات ويعرض النّتائج على المستخدم... كلّ هذا في 16 سطرًا من JavaScript! لإنهاء البرنامج عُد إلى الطّرفيّة ذاتها واضغط Ctrl+C. تعرّف على لغة القوالب Jadeبعد أن تأكدنا من تنفيذ المكوّن الرئيسيّ لمشروعنا، سنعمل على تحسين شيفرتنا لجعلها أكثر بساطة وقابلة للتّطوير بسهولة فيما بعد. إذا ألقينا نظرةً على آخر ما كتبناه، سرعان ما نكتشف التّعقيد الذي ستصل إليه شيفرتنا إن أردنا إضافة المزيد من المزايا ضمن HTML، لأنّ هذا يعني إضافة المزيد من النّصّ إلى المتغيّر html بحيث يصبح طويلاً جدًّا وصعب القراءة؛ لا بدّ أن توجد طريقة أفضل من هذه! تتوفّر في كلّ اللّغات طريقة لتوليد صفحات HTML ديناميكيّة على الخادوم، بمعنى أنّه يمكن تغيير بعض محتوياتها وإدخال محتوى مُتغيّر فيها قبل إرسالها إلى المستخدم، هل تساءلت يومًا كيف يعرض فيس بوك لكلّ مستخدم صفحةً خاصّة به؟ بحيث يكون هيكلها متماثلاً لكلّ المستخدمين ولكن محتواها من الأخبار مختلف من مستخدم لآخر، الجواب هو باستخدام القوالب؛ لن نقوم بإنشاء فيس بوك جديد الآن، لكنّنا سنستفيد من ميزات القوالب الدّيناميكيّة لتوليد HTML بدلًا من كتابتها يدويًّا ضمن شيفرتنا! في عالم Node.js ستجد الكثير من لغات القولبة، لكنّ الامتداد الطّبيعيّ لاستخدام Express يكون باعتماد Jade كلغة قولبة كونها بدأت من المُطوّر ذاته، لنُعد كتابة HTML الصّفحة الرّئيسيّة لمدوّنتنا باستخدام Jade: doctype html html(lang="ar") head title "مُدوّنتي!" body for post in posts li #{ post.title }قارن بين نصّ HTML ونصّ Jade الأخير، أوّل ما نلاحظه في Jade هو بساطة صياغتها، فهي تلغي الوسوم النّهائيّة (مثل </head> و</body>) وتستعيض عن ذلك بكونها حسّاسة للمحاذاة، فكون الوسم title مُزاحًا إلى يمين head يعني أنّه محتوىً ضمنه، وكذلك الأمر بالنّسبة لـbody، نلاحظ كذلك دعم Jade للحلقات والمُتغيّرات، وهي من أبرز مزايا لغات القوالب، لأنها تسمح بتوليد عناصر متكرّرة دون الحاجة لكتابتها يدويًّا. سنحتاج أوّلًا لتثبيت Jade وحفظه في متطلّبات المشروع: npm install jade --save احفظ شيفرة Jade السابقة في ملفّ home.jade ضمن مجلّد جديد سمّه views داخل مُجلّد المشروع، ثمّ عُد للملفّ index.js، ولنقم باستخدام Jade عوضًا عن الأسلوب السابق: var express = require("express"); var mysql = require("mysql"); var connection = mysql.createConnection({ host: "localhost", user: "root", password: "", database: "myblog" }); connection.connect(); var app = express(); app.set("view engine", "jade"); app.get("/", function(request, response) { connection.query( "SELECT * from `posts` ORDER BY date DESC LIMIT 10;", function(err, posts) { response.render("home", { posts: posts }); }); }); app.listen(3000);ضبطنا الإعداد view engine في Express إلى القيمة "jade"، يستخدم Express هذا الإعداد عندما يُطلب منه عرض ملفّ ما باستخدام الوظيفة render التّابعة لكائن الجواب response، بحيث يبحث عن مُفسّر لغة القوالب (jade في حالتنا) ويطلب منه تحويل الملفّ "home" إلى HTML، مُمرّرًا له الكائن الذي يحوي المتغيّرات الّتي يحتاجها ({ posts: posts }). يبحث Express عن ملفّات العرض في المجلّد views بشكل افتراضيّ، وهو ما قمنا بإنشاءه للتّوّ.قم بتشغيل البرنامج مرّة أخرى باستخدام الأمر node index.js ثمّ زر الرّابط http://localhost:3000/. لم يتغيّر شيء ظاهر، لكنّنا انتقلنا إلى استخدام لغة قوالب وراء الكواليس، وسنستفيد من هذا بكتابة شيفرة أبسط وأكثر تنظيمًا. لنقم الآن بتعديل القالب home.jade ليبدو بشكل أجمل: doctype html html(lang="ar", dir="rtl") head title "مُدوّنتي!" body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr for post in posts h2 #{ post.title } p #{ post.body } small بتاريخ #{ post.date }قمنا بتغيير اتّجاه النّص لجعله من اليمين إلى اليسار عبر الخاصة "dir"، ثمّ أدخلنا بعض التنسيق من خلال الوسم "<style>" في HTML، تسمح Jade بكتابة لغات أخرى ضمن القالب مثل كتابة CSS وCoffeeScript أو Markdown أو Sass عبر الصّياغة :language ليتم تحويلها إلى اللّغة المناسبة للمتصفّح إن تطلّب الأمر، وفي هذه الحالة أدخلنا CSS بسيط (الذي لا يحتاج للتّحويل) بكتابة :css قبل الشّيفرة. سنتعرّف على مزيد من مزايا Jade خلال عملنا. تبدو مدوّنتنا بشكل أجمل الآن، لكنّها بالتأكيد تحتاج المزيد من العمل! يمكننا تحسين عرض صيغة التّاريخ باستخدام مكتبة moment للتّعامل مع التّواريخ والوقت، سنحتاج أولاً إلى تثبيتها وحفظها في متطلّبات المشروع: npm install --save moment سنُدخل التّعديلات اللّازمة على الملفّين index.js وhome.jade: var express = require("express"); var mysql = require("mysql"); var moment = require("moment"); moment.locale("ar"); var formatDate = function(date) { return moment(new Date(date)).fromNow(); } var connection = mysql.createConnection({ host: "localhost", user: "root", password: "", database: "myblog" }); connection.connect(); var app = express(); app.set("view engine", "jade"); app.get("/", function(request, response) { connection.query( "SELECT * from `posts` ORDER BY date DESC LIMIT 10;", function(err, posts) { response.render("home", { posts: posts, formatDate: formatDate }); }); }); app.listen(3000);doctype html html(lang="ar", dir="rtl") head title "مُدوّنتي!" body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr for post in posts h2 #{ post.title } p #{ post.body } small كُتِبَت #{ formatDate(post.date) }يمكن تمرير الدّوال (functions) إلى Jade كما نُمرّر المتغيّرات، وفي حالتنا قمنا بتعريف دالّة تقوم بتنسيق التّاريخ الذي تتلقاه بصياغة نسبيّة (منذ كذا يومًا، منذ ساعتين...) وذلك بالاستفادة من مكتبة moment التي استوردناها وعيّنّا لغة التّاريخ فيها إلى العربيّة. أجرينا التغييرات اللازمة في Jade مستخدمين الدّالة التي فرضناها وأصبحت متوفّرة ضمن القالب: إنشاء صفحة التدوينةمن المعتاد لصفحات التّدوينات أن تكون بهذه الهيئة: http://myblog.com/posts/hello-world، ويمكن أن نشاهد في مدوّنات أخرى روابط تحوي تاريخ كتابة التّدوينة أو رقمًا خاصًّا بها... إلخ، لكنّنا سندع الأمور بسيطة. لدينا حاليًّا 4 تدوينات، ستكون روابطها: /posts/hello-world/posts/quotes-1/posts/quotes-2/posts/quotes-3الثّابت بين هذه الرّوابط هو اعتمادها على الحقل slug الّذي أدخلناه في كلّ سطر في جدول التّدوينات. من غير المنطقيّ أن نُسجّل رابطًا لكلّ تدوينة على حدة في Express، وسيصبح هذا مستحيلاً مع إنشاء تدوينات جديدة. يوفّر Express آليّة للإجابة على الطّلبات الواردة على الروابط التي تطابق نمطًا معيّنًا، وهو في حالتنا /posts/ متبوعًا بحقل متغيّر slug، أو /posts/:slug بصياغة Express، سنضيف الشيفرة التالية إلى برنامجنا (قبل آخر سطر): app.get("/posts/:slug", function(request, response) { var slug = request.params.slug; connection.query("SELECT * from `posts` WHERE slug = ?", [ slug ], function(err, rows) { var post = rows[]; response.render("post", { post: post, formatDate: formatDate }); }); }) نطلب من Express الاستجابة لأي رابط يطابق النمط "/posts/:slug" بالبحث عن التدوينة التي تملك القيمة slug ضمن العمود الموافق في جدول التّدوينات، نلاحظ أنّ Express يوفّر لنا هذه القيمة المتغيّرة من خلال الكائن params التابع لكائن الطّلب request (كائن الطّلب يحوي كذلك ترويسات الطّلب الّتي تحدّثنا عنها في الجزء السّابق). من المهمّ أنّ نحمي قاعدة بياناتنا من العبث وذلك بتجنب هجمات حقن SQL، ولهذا توفّر وحدة mysql دالّة query() ذاتها لكن مع 3 معاملات بدل اثنين فقط، حيث يكون الثاني مصفوفة تحوي القيم الّتي نريد التأكّد من سلامتها (escape) قبل إحلالها محلّ إشارات الاستفهام في استعلاماتنا. هذا أسلوب شائع جدًا في استعلامات SQL، وهو أقلّ ما يمكننا فعله لحماية قاعدة البيانات. لم نقم بعد بإنشاء قالب صفحة التّدوينة، لننشئ ملفًا جديدًا اسمه post.jade ضمن مجلّد views: doctype html html(lang="ar", dir="rtl") head title مُدوّنتي! body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr h2 #{ post.title } p #{ post.body } small كُتِبَت #{ formatDate(post.date) }لنبدأ برنامجنا، ونذهب إلى الصّفحة http://localhost:3000/posts/hello-world: لدينا الآن بعض المشكلات، ماذا يحدث لو أدخلنا رابطًا لتدوينة غير موجودة؟ جرّب مثلاً http://localhost:3000/posts/another-post: وقع خطأ في تفسير Jade سببه أن المتغيّر post الّذي وصله هو في الحقيقة غير معرّف undefined، لأنّه ما من تدوينة في قاعدة البيانات يطابق حقل slug فيها القيمة another-post، وعندما أجرينا الاستعلام أُعيدت لنا مصفوفة فارغة rows، وفي JavaScript فإنّ محاولة الوصول إلى خاصّة غير موجودة ("0") في عنصر مُعرّف (المصفوفة rows في حالتنا) تُرجع undefined. ما الذي كان علينا فعله لتجنب هذا الخطأ؟ أولاً يجب التأكّد قبل كلّ شيء أنّ الخطأ الذي يقع في مرحلة الاستعلام يتم التّعامل معه (handled) قبل الانتقال لما بعده، انتبه إلى أنّ الاستعلام الذي يتم بنجاح ويعيد مصفوفة فارغة لا يعتبر خطأ، لذا يجب التّعامل مع هذه الحالة أيضًا؛ مبدئيًا سنكتفي بإيقاف تنفيذ الدّالة: app.get("/posts/:slug", function(request, response) { var slug = request.params.slug; connection.query("SELECT * from `posts` WHERE slug = ?", [ slug ], function(err, rows) { if (err || rows.length == ) return; var post = rows[]; response.render("post", { post: post, formatDate: formatDate }); }); }) جرّب الآن إعادة تشغيل البرنامج وزيارة الصّفحة ذاتها... سيستمرّ المتصفّح بمحاولة تحميلها لوقت طويل قبل أن يفشل بسبب انتهاء مهلة الطّلب. لماذا يحدث هذا؟ علينا أن نفهم واحدًا من أهمّ المفاهيم في Express، وهو الكيفيّة التّي تسير بها عمليّة توجيه الرّوابط (routing)، في شيفرتنا الأخيرة سيتوقف Express عند return دون أن يعرف ما ينبغي فعله في الخطوة التّالية، وهذا يجعل البرنامج عالقًا في الفراغ، نحتاج لطريقة نخبر بها Express أن يتابع التّنفيذ ويفعل شيئًا ما عندما تنتهي إحدى وظائف التّعامل مع الرّوابط، ولهذا يعطينا Express دالّة next التي تتوفّر كمعامل ثالث للدالة التّي تتلقّى الرابط: app.get("/posts/:slug", function(request, response, next) { var slug = request.params.slug; connection.query("SELECT * from `posts` WHERE slug = ?", [ slug ], function(err, rows) { if (err || rows.length == ) return next(); var post = rows[]; response.render("post", { post: post, formatDate: formatDate }); }); }) أعد تشغيل البرنامج وزر الصّفحة مجدًّدا: هذا أفضل! لكن ما هي الدّالة التّالية التي استدعاها Express ليعرف أنّ صفحة على هذا الرّابط غير موجودة؟ الإجابة هي أنّ Express يحوي بشكل افتراضي دوالّ داخليّة يستدعيها عندما لا نزوّده بالدّالة التّالية، لكنّنا نستطيع فعل ذلك بسهولة: app.get("/posts/:slug", function(request, response, next) { var slug = request.params.slug; connection.query("SELECT * from `posts` WHERE slug = ?", [ slug ], function(err, rows) { if (err || rows.length == ) return next(); var post = rows[]; response.render("post", { post: post, formatDate: formatDate }); return; }); }) app.get("/posts/:slug", function(request, response) { response.send("التدوينة غير موجودة"); }) سجّلنا أكثر من دالّة تتعامل مع الرّابط ذاته، سينفّذها Express جميعًا بالتّرتيب ذاته، يمكن لكلّ دالّة أن تستدعي الدّالة التّالية أو أن توقف سلسلة الاستدعاءات بإرسال الطّلب للمتصفّح وإيقاف التّنفيذ. (إرسال الطّلب لا يعني بالضّرورة أنّ الدّوال التّالية لن تنفّذ، بل يجب إيقاف التّنفيذ صراحةً إن لم نرغب بهذا السّلوك). أعد تشغيل البرنامج ثم زُر الصّفحة ذاتها: حدث ما نتوقّعه بالضّبط، على سبيل التّأكد من كوننا لم نعبث بالوظيفة الرئيسيّة، جرّب زيارة تدوينة موجودة مثل http://localhost:3000/posts/quotes-1. كاختبار لك، قم بتعديل صفحة "التّدوينة غير موجودة" مستخدمًا قالبًا خاصًّا ولتجعله جميلاً! تصرّف براحتك! سأقوم بإدخال تعديل بسيط على الدّالة الثّانية، لجعلها ترسل الرّمز 404 (غير موجود) للمتصفّح بدل القيمة الافتراضيّة (200): app.get("/posts/:slug", function(request, response) { response.status(404); response.send("التدوينة غير موجودة"); })لن يغيّر هذا شيئًا في الظّاهر، لكنّه العرف المتّفق عليه، يمكن لبعض المتصفّحات أن تتعامل مع خطأ كهذا بعرض صفحة نتائج البحث على Google مثلاً (مع أنّه لا يوجد متصفّح يفعل ذلك)، لكنّها طريقة HTTP في التّفاهم بين الخادوم والمتصفّح. عظيم! لدينا الآن صفحة رئيسيّة منسّقة وصفحات مفردة للتّدوينات، في الدّرس القادم سنقوم بإنشاء نظام للمستخدمين تمهيدًا لإتاحة التّعليقات وكتابة تدوينات جديدة.
-
بعد أن أنشأنا نظام المستخدمين والجلسات، أصبحنا جاهزين الآن لبناء نظام التّعليقات، ثم إتاحة إمكانية إنشاء تدوينة جديدة وتعديل التّدوينات السّابقة. إتاحة التّعليقلحفظ التّعليقات نحتاج أولًا إلى جدول جديد يحفظ نصّ التعليق وتاريخه وكاتبه والتّدوينة التي أُضيف إليها، افتح صدفة MySQL واتصّل بقاعدة البيانات ثم نفّذ هذا الاستعلام: CREATE TABLE `comments` (id INT PRIMARY KEY AUTO_INCREMENT, post_id INT NOT NULL, user_id INT NOT NULL, body VARCHAR(500) NOT NULL, created TIMESTAMP, FOREIGN KEY (post_id) REFERENCES `posts` (id), FOREIGN KEY (user_id) REFERENCES `users` (id), INDEX (post_id));سنُعدّل قالب صفحة التّدوينة post.jade ونضيف حقلاً يسمح للمستخدم المُسجّل دخوله بإضافة التّعليق، ويعرض للزوّار إمكانيّة تسجيل الدّخول: doctype html html(lang="ar", dir="rtl") head title مُدوّنتي! body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr h2 #{ post.title } p #{ post.body } small كُتِبَت #{ formatDate(post.date) } #comments h3 التعليقات if post.comments for comment in post.comments p يقول b #{ comment.full_name }: br | #{ comment.body } small #{ formatDate(comment.created) } hr else p لا تعليقات إلى الآن if user form(action="/posts/" + post.id + "/comments", method="POST") textarea(name="comment", placeholder="أكتب تعليقك") input(type="submit", value="أرسل التّعليق") else span لإضافة تعليقاتك، a(href="/login") سجّل دخولك | أو a(href="/signup") أنشئ حسابًا جديدًا لقد استبقنا الأمور قليلاً، سيرسل النّموذج الذي يشمل التّعليق إلى الرّابط /posts/:post_id/comments، لنقم بتوجيه هذا الرّابط: app.post("/posts/:post_id/comments", parseBody, function(request, response) { var body = request.body.comment; var user_id = request.user.id; var post_id = request.params.post_id; var created = new Date(); connection.query("INSERT INTO `comments` (post_id, user_id, body, created) VALUES (?, ?, ?, ?)", [ post_id, user_id, body, created ], function(err) { if (err) { response.status(500); response.send("تعذّرت إضافة التّعليق، حاول مجدّدًا."); return; } response.status(201); response.send("أُضيف التعليق"); }) }) ملاحظة: اخترنا أن يُرسل الرّابط إلى النّمط /posts/:post_id/comments بدل /posts/:slug/comments على سبيل تبسيط الأمور، لا يُميّز Express بين :slug و:post_id فكلاهما بالنّسبه له متُغيّران لا يخضعان لأيّة شروط، يمكننا التأكد من كون :post_id رقمًا باستخدام الوظيفة param() على التّطبيق والّتي نشترط بها أن يطابق المُتغيّر post_id التعبير النظامي (regular expression) التّالي: app.param('post_id', /^[0-9]+$/); وعندها لن تتلقّى هذا الدّالة سوى الرّوابط التي تحمل رقمًا في موقع :post_id. هذه الشّيفرة تكفي لإضافة التّعليق، لكنّنا بحاجة إلى تعديل دالّة توجيه رابط التّدوينة لإضافة التّعليقات إلى الصّفحة: app.get("/posts/:slug", function(request, response, next) { var slug = request.params.slug; connection.query("SELECT * from `posts` WHERE slug = ?", [ slug ], function(err, rows) { if (err || rows.length == 0) return next(); var post = rows[0]; connection.query("SELECT * FROM `comments` JOIN `users` ON comments.user_id=users.id WHERE post_id=?", [ post.id ], function(err, comments) { if (err) return next(err); post.comments = comments; response.render("post", { post: post, formatDate: formatDate, user: request.user }); }) }); })لنُجرّب أولًا زيارة صفحة التّدوينة http://localhost:3000/posts/hello-world دون تسجيل الدّخول وقبل إضافة أيّ تعليق: لنقم الآن بتسجيل الدّخول باسم admin وكلمة مرور 123456، ولنعُد للصفحة ونُحدّثها: لنجرّب إضافة تعليق: لنعُد لصفحة التّدوينة ونُعد تحميلها: رائع جدًا! لقد أنشأنا نظام تعليق بسيطًا بخطوات بسيطة بعد أن أتممنا القسم الأكبر من العمل عندما أنشأنا الجلسات ونظام المستخدمين. إتاحة كتابة التّدوينات وتعديلها لمدير المُدوّنةلنبدأ أوّلاً بصفحة إنشاء تدوينة جديدة، ولنُنشئ رابطًا جديدًا /new لعرض القالب views/post-editor.jade: doctype html html(lang="ar", dir="rtl") head title تدوينة جديدة body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr h2 تسجيل الدخول form(action="/posts", method="POST") label(for="title") عنوان التدوينة: input(type="text", name="title", required) br label(for="slug") الرابط الفرعي: input(type="text", name="slug" required) br label(for="body") نص التّدوينة: textarea(name="body") br input(type="submit", value="أرسل التّدوينة")app.get("/new", function(request, response, next) { if (request.user && request.user.is_author) { response.render("post-editor", { user: request.user }); } else { response.status(403); response.send("ليس لديك صلاحيات إضافة تدوينة."); } }) app.post("/posts", parseBody, function(request, response) { if (request.user && request.user.is_author) { var title = request.body.title, body = request.body.body, date = new Date(), author_id = request.user.id, slug = request.body.slug; connection.query("INSERT INTO `posts` (title, body, date, author_id, slug) VALUES (?, ?, ?, ?, ?)", [ title, body, date, author_id, slug ], function(err) { if (err) { response.status(500); response.send("تعّذرت إضافة التّدوينة"); return; } response.status(201); response.send("أضيفت التّدوينة."); }) } else { response.status(403); response.send("ليس لديك صلاحيات إضافة تدوينة."); } })الجزء الأكثر أهمّيّة في شفرتنا هو التّحقق من كون المستخدم يحمل صلاحيات الكتابة، فإن لم يكن كذلك، نُرسل الرّمز 403 Forbidden (محظور). بإمكاننا تجاوز التّحقق قبل عرض القالب ونترك عرض الرسالة المناسب للقالب ذاته، لكنّ المهمّ هو إجراء التّحقّق عند إدخال التّدوينة في فاعدة البيانات. سنتيح تعديل التّدوينة على رابط التّدوينة ذاته متبوعًا بـ/edit: app.get("/posts/:slug/edit", function(request, response, next) { var user_id = request.user.id; var slug = request.params.slug; connection.query("SELECT * FROM `posts` WHERE author_id=? AND slug=?", [ user_id, slug ], function(err, rows) { if (!err && rows[0]) { response.render("post-editor", { post: post }); } else { response.status("401"); response.send("إمّا أن التّدوينة غير موجودة، أو أنّك لا تملك الصلاحيات للوصول إليها"); } }) })الجزء المهمّ من شفرتنا هو اشتراط أن يكون مؤلف التّدوينة المطلوب تعديلها هو صاحب الجلسة ذاته وهو ما كتبناه في استعلام MySQL، وإلّا سيكون بإمكان أن شخص أن يضيف /edit إلى نهاية التّدوينات ويجري ما يشاء من التغييرات عليها. لنقم بتعديل القالب post-editor.jade لجعله يتعامل مع التّدوينات الموجودة مسبقًا بالإضافة إلى التّدوينات الجديدة: doctype html html(lang="ar", dir="rtl") head title تدوينة جديدة body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr - var editMode = post && post.id h2 #{editMode ? "تعديل التّدوينة" : "تدوينة جديدة" } form(action= editMode ? ("/posts/" + post.slug + "?_method=PUT") : "/posts", method="POST") label(for="title") عنوان التدوينة: input(type="text", name="title", required, value= editMode ? post.title : "") br if !editMode label(for="slug") الرابط الفرعي: input(type="text", name="slug" required) br label(for="body") نص التّدوينة: textarea(name="body") #{ editMode ? post.body : "" } br input(type="submit", value="أرسل التّدوينة")سيُرسل طلب التّعديل باستخدام الفعل PUT الّذي يُستخدم للطلب من الخادوم "تحديث" محتوى موجود لديه، خلافًا لـPOST المستخدم لإضافة محتوى جديد. المشكلة أنّ المتصفّحات لا تدعم استخدام سوى فعلين ضمن نماذج HTML هما POST وGET، ولذلك سنضطر إلى إيجاد طريقة "ملتوية" لتجاوز هذه المشكلة. استخدمنا الفعل POST ذاته في حالة التّعديل مع إضافة حقل إلى رابط action في النّموذج، سيستخدم هذا الحقل من قبل وحدة method-override الّتي تقرأه وتغيّر من فعل الطّلب إلى PUT ليمرّ عبر دالّة التّوجيه الّتي كتبناها: var methodOverride = require('method-override'); app.use(methodOverride('_method')); /* ... */ app.put("/posts/:slug", parseBody, function(request, response) { if (!requestest.user) { response.status(403); response.send("يجب تسجيل الدخول لتعديل التدوينات."); return; } var slug = request.params.slug; var new_title = request.body.title; var new_body = request.body.body; var user_id = request.user.id; connection.query("UPDATE `posts` SET title=?, body=? WHERE slug=? AND author_id=?", [ new_title, new_body, slug, user_id ], function(err) { if (err) { console.log(err); response.status("500"); response.send("حدث خطأ أثناء تعديل التدوينة"); return; } response.send("حُدِّثت التّدوينة."); }) })تأكد من اشتراطك كون مؤلّف التّدوينة هو ذاته صاحب الجلسة مرّة ثانية قبل إدخال البيانات. قلنا أنّ أفعال HTTP تستخدم استخدامًا دلاليًّأ (semantic) ولا شيء يُجبرك على استخدام PUT، بل يمكنك استخدام POST للحصول على نفس النّتيجة، لكنّه العرف المتّفق عليه، والذي ستعتاد على اتّباعه عندما تتقدّم في مستويات أعلى كبناء واجهة برمجيّة للمدوّنة (RESTful API) الّتي يتوقّع الطّرف الذي يتعامل معها هذا الأسلوب الدّلاليّ. يحقّ لنا الاحتفال الآن، فقد أنشأنا مدوّنة حقيقيّة من الصّفر! لنقم الآن بتحسين مظهرها وتنظيف شيفرتنا! تنظيف الشّيفرةحسنًا، قد تبدو شيفرتنا طويلة في الملفّ index.js طويلة بعض الشيء وفيها الكثير من التّكرار، وحالما نُشاهد سطورًا مكرّرة في شيفرة برمجيّة، نعلم أنّ بإمكاننا كتابة شيفرة أفضل. لقد أهملنا ذلك قليلًا لنحصل على برنامج يعمل بأسرع وقت ممكن، لكن علينا الآن أن نعود لنلقي نظرة أكثر إمعانًا في برنامجنا. تكرّر في كثير من المواضع استخدامنا للدّالة parseBody لتفسير متن طلبات POST، وحدة body-parser هي واحدة من البرامج الوسيطة التي يمكن استعمالها على مستوى التّطبيق أيضًا، لنقم بحذف عبارة parseBody من كل طلبات POST ولننقل تفسير متن الطّلب إلى مستوى التّطبيق، ذكرنا أنّه بإمكاننا استخدام app.use() لذلك: /* ... */ var app = express(); var parseBody = bodyParser.urlencoded({ extended: true }); app.use(session({ secret: "my top secret", resave: true, saveUninitialized: true })); app.use(parseBody); app.use(cookieParser()); // ...سيكون من المفيد بعد إضافة تدوينة جديدة أو تعديلها أو تعليق جديد على تدوينة العودة مجدًّدا إلى هذه التّدوينة بدل عرض رسالة تفيد بنجاح العمليّة فقط، يوفّر Express الوظيفة redirect() على الكائن response التي تُخبر المتصفّح بالانتقال إلى صفحة أخرى كجواب على الطّلب الّذي أُرسل. سأدع لك تنفيذ هذه المهمّات: عند كتابة تدوينة جديدة، انتقل إلى صفحة هذه التّدوينة.عند إضافة تعليق جديد، عُد إلى صفحة التّدوينة المعنيّة.عند إنشاء مستخدم جديد، انتقل إلى صفحة تسجيل الدّخول.عند تسجيل الدّخول، انتقل إلى صفحة الملفّ الشّخصيّ.في معظم دوالّ التّوجيه التي كتبناها، قمنا بالتّحقّق من الخطأ وإرسال رسالة مناسبة مع رمز حالة مثل 404 و403... من الأفضل أن نُصمّم صفحة خطأ خاصّة تتلقّى الخطأ ورسالته وتعرضه للمستخدم بأسلوب موحّد، سنحذف كلّ عبارات response.send() الّتي ترسل رسالة خطأ ونبدلها بالتّوجيه إلى الدّالة التّالية next() الّتي ستعرض قالب صفحة الخطأ views/error.jade: doctype html html(lang="ar", dir="rtl") head title مُدوّنتي! - خطأ body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr h2 خطأ #{ response.statusCode } p #{ error.message || error.toString() }هذا مثال عن تعديل دالّة التّوجيه للرّابط /new: app.get("/new", function(request, response, next) { if (request.user && request.user.is_author) { response.render("post-editor", { user: request.user }); } else { response.status(403); return next(new Error("ليس لديك صلاحيات إضافة تدوينة.")); } })تقبل الدّالة next معاملاً اختياريًّا يشير إلى وجود خطأ، وهي الطّريقة المناسبة لتمرير الخطأ عبر دوالّ التّوجيه، عدم تمرير الخطأ يعني أنّ التّوجيه يسير من دالّة إلى أخرى بشكل سليم، يستفيد Express من وجود هذا المعامل لعرض الخطأ في حال انتهت عمليّة التّوجيه دون توفير دالّة تتعامل معه. سيلجأ Express إلى الدّالة التّالية الّتي يجب أن نُضيفها إلى نهاية شيفرتنا: app.use(function(err, request, response, next) { response.render("error", { error: err, statusCode: response.statusCode }) }) لاحظ أنّه خلافًا لدوالّ التّوجيه السّابقة، فقد استخدمنا 4 معاملات، قد تتساءل كيف يمكن لدالّة واحدة أن تتلقّى عددًا مختلفًا من المعاملات وتتصرّف بطريقة مختلفة، أو كيف تعرف الدّالة أن العنصر الأوّل هو كائن الخطأ وليس كائن الطّلب، الجواب هو أنّ Express يُجري تحقّقًا من عدد المعاملات في دالّة التّوجيه ويغيّر تصرّفه، وهذا الأمر متاح لأن JavaScript توفّر الكائن arguments بشكل تلقائيّ لكلّ الدّوالّ، والذي يمكن التّحقّق من طوله (length) بجملة شرطيّة وتغيير سلوك الدّالة. الهدف النّهائي من هذا أن يكون Express سهل الاستعمال وبديهيًّا، وهذا الأسلوب ستجده كثيرًا في Node.js. من الضّروري استخدام 4 معاملات ليستطيع Express التفريق بين: err, request, response وrequest, response, next. لنجرّب الآن زيارة بعض الصّفحات الّتي نتوقّع حدوث خطأ عندها: هذا أفضل! توحيد صفحة الخطأ سيجعلنا نفكّر في تقديم حلول لهذا الخطأ بناء على رمز الحالة، مثلاً نستطيع تقديم مربّع بحث في حال كان الرّمز 404 (غير موجود)، أو نستطيع أن نطلب من المستخدم تسجيل الدّخول في حال كان 403... إلخ. شيفرة JavaScript نظيفة الآن، لنلقِ نظرةً على القوالب الّتي أنشأناها، يتكرّر في معظمها استخدام ترويسة للصّفحة مع استخدام تنسيق موحّد، قد يبدو تضمين CSS في الصّفحة مقبولًا الآن، لكنّنا سنحتاج إلى نقله إلى ملفّ منفصل عندما نتوسّع في إضافة الأنماط لكي لا نحتاج لتكرارها في كلّ القوالب. لنُنشئ ملفًا للأنماط style.css في مجلّد جديد ضمن المشروع نُسمّيه public، ولننقل إليه شيفرة CSS من أحد القوالب: body { font-family: Arial, sans-serif; }لنحذف الآن شيفرة CSS من القوالب ونكتب بدلاً منها رابطًا لملفّنا: head title إنشاء مستخدم جديد link(rel="stylesheet", href="/style.css") body h1 مُدوّنتي //- ... حسنًا، لن يعثر الخادوم على الملفّ style.css عندما يطلبه المتصفّح، لأنّنا نحتاج لتوفيره صراحةً. من الشّائع استضافة كلّ الملفّات الثّابتة (static) مثل ملفّات CSS وJavaScript للمتصفّح ضمن مجلّد public، ثمّ توفير هذا المجلّد بكامل محتوياته على الخادوم. تتوفّر آليّة مُدمجة في Express للقيام بذلك: app.use(express.static(__dirname + '/public'));تتوفّر أيضًا وحدات خارجيّة يمكنها القيام بالمهمّة ذاتها وبخيارات أكثر مثل تحديد لواحق الملفّات وأذوناتها... مُلاحظة: المُتغيّر __dirname توفّره Node.js وهو يشير إلى المجلّد الذي يحوي الملفّ الحاليّ (index.js). سنستفيد من هذا المجلّد في استضافة ملفّات favicon وJavaScript الّتي تعمل في المتصفّح عندما نطوّر مدوّنتنا لتستخدم AJAX. تحسين مظهر المدوّنةسنحتاج إلى إجراء تغييرات في القوالب كإضافة بعض المُعرّفات (IDs) والأصناف (classess) لنقوم بتنسيقها وفق القواعد الّتي نكتبها في ملفّ style.css الّذي أنشأناه للتّوّ. يوفّر Express صياغتين مختصرتين للتّعبير عن الأصناف والمُعرّفات لكونهما شديدتي الشّيوع، لإضافة صنفين ومُعرّف على عنصر div ما، يمكن كتابة: #comments.post-comments.cardوهي مكافئة لكتابة: div(class="post-comments card", id="comments")والتي ستُترجم إلى HTML الّتالي: <div class="post-comments card" id="comments"></div>لاحظ أنّك لست بحاجة لكتابة div، لأنّ Jade يفهم المغزى من هذا الأسلوب على أنّه كائن div تلقائيًّا، لأنّه الكائن الأكثر استخدامًا لإضافة الأصناف والمعرّفات بهدف تنسيق مكوّنات الصّفحة. قد تلاحظ أثناء العمل الحاجة لتكرار أجزاء معيّنة من الشّيفرة في كلّ القوالب مثل إظهار عنوان المدوّنة وروابطها على المواقع الاجتماعيّة ومربّع البحث ضمن ترويسة (header) في كلّ الصّفحات، مع تذييل (footer) يحوي بعض الرّوابط الإضافيّة في نهاية كلّ صفحة، يمكنك التّخلصّ من عناء التّكرار باستخدام الكلمة المفتاحية extends لبناء قالب على قالب آخر، فلنقم ببناء قالب يتضمّن الهيكل العامّ لكلّ الصّفحات، ولنسمّه _layout.jade (اجعل اسمّ هذه الملفّات يبدأ بالرّمز _ لتستطيع فيما بعد تمييزها بسرعة بين ملفّات القوالب): doctype html html(lang="ar", dir="rtl") head title مُدوّنتي! link(rel="stylesheet", href="/style.css") body header h1#blog-title مُدوّنتي ul#blog-nav li: a(href="/") الرئيسية li: a(href="/login") تسجيل الدخول li: a(href="/signup") إنشاء حساب block content footer hr p جميع الحقوق غير محفوظةالعبارة block متبوعةً باسم نختاره نحن كما نشاء، تسمح لنا ببناء قوالب تشترك جميعها في الهيكل العّام لهذا الملفّ وتختلف فقط في هذا الجزء، مثلاً يمكننا الآن إعادة كتابة الصّفحة الرئيسيّة (home.jade) لتصبح: extends _layout block content for post in posts .post h2.post-title #{ post.title } p.post-body #{ post.body } small.post-date كُتِبَت #{ formatDate(post.date) }سيبحث Jade عن القطعة المُسمّاة content ويضيفها في المكان المناسب للقالب. لا داع لإرفاق لاحقة الملفّ فهي معروفة بالنّسبة لـJade. ملاحظة: التّعبير li: a(href=... هو اختصار تتيحه Jade للاستغناء عن الحاجة لكتابة الوسمين على سطرين. كثيرًا ما تحتاج إلى إدخال أجزاء متكرّرة من HTML مع إجراء بعض التّعديلات عليها، وهذا ما يمكن تنفيذه من خلال الدّوالّ في Jade والّتي تُسمّى mixins، وهي تشبه كثيرًا الدّوال في أي لغة برمجة، لتوضيح المفهوم أكثر، لنفترض أنّنا نريد توحيد مظهر التّدوينات بين صفحة التّدوينة والصّفحة الرئيسيّة، مع فارق بسيط هو جعل نصّ التّدوينة في الصّفحة الرئيسية محدودًا بمئتي حرف مثلاً، يمكن فعل هذا بنقل شيفرة التّدوينة المفردة إلى دالّة في ملفّ منفصل نُسمّيه _mixins.jade: mixin post(post, full) h2.post-title: a(href="/posts/" + post.slug) #{ post.title } p.post-body #{ full ? post.body : (post.body.substr(0, 199) + "...") } small.post-date كُتِبَت #{ formatDate(post.date) }لاحظ كم تشبه هذه الصّياغة صياغة الدّوالّ في لغات البرمجة، حيث يمكن إمرار مُعاملات لها بين قوسين. يمكن استدعاءها في قوالبنا بالرّمز + بعد تضمين الملفّ _mixins.jade بالكلمة المفتاحية include الّتي تشبه استدعاء وحدة خارجية بـrequire في Node.js: extends _layout include _mixins block content for post in posts .post +post(post, false)سأقوم الآن بتنسيق المُدوّنة بأسلوبي الخاصّ، وسأترك المجال لك لتفعل الأمر ذاته! إذا أردت استلهام بعض الأفكار، أنصحك بالاطّلاع على مواقع مثل Codrops. في الدّرس القادم سنطّلع على بعض المواضيع الّتي يجب أخذها بالحُسبان قبل نشر المدوّنة على الويب.
-
استضافة المشروع على الويب تختلف في كثير من نواحي عن البيئة المحلّيّة أثناء التّطوير، علينا أخذ عدّة أمور في عين الاعتبار قبل أن يصبح مشروعنا جاهزًا للنّشر (deployment). الأمان والحمايةعندما طوّرنا المشروع، لم نلقِ بالًا لمواضيع الحماية والأمان (سوى تجزئة كلمة المرور)، لأنّ الطّلبات في بيئة التّطوير تصدر وتعود للجهاز نفسه دون خوف من مرورها على جهات أخرى، أمّا على الويب فإنّ الطّلبات ستمرّ عبر عشرات ومئات بل ربّما آلاف الأجهزة قبل أن تصل للطّرف الآخر. ولذا علينا تنفيذ ما يلي كحدّ أدنى لحماية خادومنا والمستخدمين في وقت واحد: استخدام HTTPS: عند إنشاء المستخدم لحساب جديد أو تسجيله دخوله، يجب أن تنتقل كلمة المرور مُعمَّاة (encrypted) من المتصفّح للخادوم لكي لا تستطيع أطراف أخرى، مثل مزوّد خدمة الإنترنت أو مُخترقي الشّبكة، الاطّلاع عليها أثناء انتقالها، وهو ما يمكن تحقيقه باستخدام بروتوكول HTTPS، كيفيّة اعتماد HTTPS على الخادوم أمر خارج عن نطاق هذه السّلسلة. صلاحية الجلسات: ذكرنا أنّه من المُفضّل فرض حدّ على صلاحيّة الجلسة يتمّ بعدها إعادة طلب تسجيل الدّخول من المستخدم. إذا استطاع مخترق ما الوصول إلى جهاز المستخدم أو سرقة الكعكات من متصفّحه، فسيصبح بإمكانه انتحال شخصيّة هذا المستخدم والتّعليق باسمه أو إضافة التّدوينات إن كان الكاتب يملك هذا الإذن. التحقق من البريد الإلكتروني: لم نطلب من المستخدم عنوان بريده الإلكتروني عند إنشاء نموذج التّسجيل في الموقع لغرض تبسيط الدرس، ولكن من الأفضل طلبه مع التّحقّق من صحته بإرسال رسالة تحوي رابط التّحقّق وذلك لحماية المدوّنة من الهجمات الآلية الّتي تهدف إلى خلق ضغط على الخادوم أو إغراقه بالتّعليقات غير المرغوبة. الأداءالتخزين المؤقت (caching): الحواسيب اليوم سريعة للغاية، لا سيما الخواديم، معالجات بقدرات هائلة وأحجام كبيرة من الذاكرة وسعة التّخزين، لكن المشكلة الوحيدة الّتي لا تزال تؤثّر على أدائها، على الرّغم من كلّ التّطوّرات في السّنوات الأخيرة، ما تزال أقراص التّخزين، نعم هناك أنواع جديدة من الأقراص (SSD) تقدّم سرعات أكبر، لكنّها ما تزال مُكلفة وغير شائعة على الخوادم، في كلّ استعلام لقاعدة البيانات سيذهب خادوم MySQL إلى القرص ليجلب النّتائج، وهذا يعني أنّ كلّ صفحة يزورها كلّ مستخدم ستتطلّب على الأقل مرورًا واحدًا على القرص، وإذا كان القرص ميكانيكيًّا، وهي الأقراص الأكثر شيوعًا في الحواسيب إلى اليوم، فإنّ هذا سيستغرق وقتًا ملحوظًا. من الأفضل تخزين النّتائج للاستعلامات الشّائعة والّتي لا تتغيّر كثيرًا (كنصّ التّدوينة، ومعلومات المستخدمين) في الذّاكرة (RAM) لأنّها كبيرة الحجم عادةً، وأسرع بكثير من الأقراص الصّلبة. يمكننا تخزين هذه النّتائج بأسلوب بدائي بشكل كائن JavaScript ضمن متغيّر يبقى ضمن الذّاكرة، لكنّ هذا الحلّ ليس عمليًّا، لذلك وُجدت العديد من الحلول الّتي تعتمد بنية خادم/عميل مثل Redis وMemcached، ببساطة يمكن تخزين نتائج الاستعلامات في قاعدة بيانات Memcached الّتي تعمل في الذّاكرة، والخادوم يتولّى إدارة مساحة الذّاكرة وتوزيعها على عدّة حواسيب إن تطلّب الأمر، كما في المواقع الضّخمة. العديد من المواقع الكبيرة تستخدم Memcached، منها Google وTwitter وWikipedia.من الوسائل الأخرى لتسريع عمل الموقع أن نطلّب من المتصفّح تخزين الملفّات الّتي لا تتغيّر كثيرًا لفترة أطول قبل أن يجلبها من جديد، يمكن القيام بهذا من خلال ترويسات في جواب HTTP تُدعى بترويسات إدارة الذّاكرة المؤقتة (Cache Control Headers)، يمكن أن نطلب من المتصفّح على سبيل المثال أن يُخزّن ملفّ style.css لمدّة 10 أيّام على جهاز المتصفّح بحيث لا يضطّر لجلبه مجدّدًا لكلّ صفحة يزورها المستخدم. في Express يمكن استخدام خيار maxAge للوظيفة static() لتعيين المُدّة القصوى لتخزين الملفّات الثّابتة مُقدّرة بالميلي ثانية: app.use(express.static(__dirname + '/public', { maxAge: 60 * 60 * 24 * 1000 }));وأمّا لطلباتنا الأخرى، فيكمننا تعيين قيم ترويسات الجواب بالطّريقة التّالية: response.set("Cache-Control", "private, max-age=3600"); أو: response.set({ "Cache-Control": "private, max-age=3600, must-revalidate", "Expires": "Tue, 13 Jan 2015 21:49:10 GMT" }); إضافة الفهارس لقاعدة البيانات: قاعدة البيانات لدينا ما تزال صغيرة ولا تحتوي الكثير، لكنّنها ستتضخّم مع زيادة التّعليقات والتّدوينات والمستخدمين بلا شكّ، وعندها سيصبح من الضّروريّ إضافة الفهارس على الأعمدة الأكثر استعلامًا لتسريع جلب النّتائج. يمكن تشبيه الفهارس في قواعد البيانات بالفهارس في الكتب؛ إذا أردت الوصول إلى قسم معيّن في كتاب ضخم، فإنّك ستفضّل الاطّلاع على الفهرس، لأنّ ذلك أسرع من المرور على كلّ أقسام الكتاب، على الرّغم من أنّ الفهرس قد يتطلّب بضع صفحات إضافية من الكتاب، الّتي يمكن تشبيهها بمساحة تخزين إضافيّة على القرص. تقليص الملفات: قد يصبح حجم وعدد ملفّات المشروع الّتي ستصل للمتصفح مثل ملفّات CSS وJavaScript كبيرًا مع تطوّر المشروع، على الرّغم أنّنا لم نستخدم أيّة مكتبات JavaScript ضخمة أو خطوط ويب كبيرة الحجم، إلّا أنّنا سنفعل ذلك في المشاريع الواقعيّة غالبًا، عندها ستصبح الحاجّة لتقليص حجم هذه الملفّات وجمعها أمرًا له ما يُبرّره، المتصفّحات لا تهتمّ إن كانت ملفّات CSS الّتي تصلها خمسة أو واحدًا، ما يهمّها هو المحافظة على ترتيب سطور الشّيفرة، كما أنّها لا تهتم بالفواصل والمسافات في ملفّات JavaScript، ولا حتّى بأسماء المتغيّرات طالما استخدمت الأسماء نفسها، وهذا هو مبدأ التّقليص (minification) والجمع (concatenation)، لنأخذ نصّ JavaScript كهذا: var myVeryLongVariableName = "something"; function doSomething() { return myVeryLongVariableName; }بعد تقليص هذا الملفّ: var a="something";function doSomething(){return a;}بالطّبع سيصبح فارق الحجم بين الملفّين كبيرًا مع تضخّم الشّيفرة وزيادة تعقيدها. كلا الشّيفرتين تؤدّيان المهمّة ذاتها على المتصفّح، ولكن الأخيرة ستقلل من حجم طلب HTTP وهو ما ينعكس على سرعة تحميل الموقع وبالتّالي على قبول المستخدم للموقع وتجربته. تتوفّر في Node.js الكثير مع الوحدات الّتي تنفّذ مهمّات الجمع والتّقليص لمختلف لغات الويب، ولعلّ من أشهرها UglifyJS لنصوص JavaScript، وclean-css لملفّات CSS. أمّا كيفيّة استخدامها وأتمتتها فهو موضوع خارج عن نطاق هذه السّلسة. استخدام وسائل ضغط مثل gzip: وهي آليّة لضغط الصّفحات لتقليص حجمها ثمّ تعيين ترويسة Encoding في جواب HTTP لتُشير للمتصفّح بأنّ المحتوى المنقول مضغوط، وسيقوم المتصفّح الّذي يدعم هذه الآليّة بفكّ ضغطه ثمّ التعامل معه كأيّ جواب آخر، يتعرّف الخادوم على المتصفّحات الّتي تدعم gzip عبر ترويسة Accept-Encoding، أمّا المتصفّحات الأخرى فتُرسل لها الإجابة دون ضغط. معظم المتصفّحات الحديثة تدعم gzip، وتتوفّر لـExpress الوحدة compression للقيام بالمهمّة تلقائيًّا. ملفات المشروعللحصول على نسخة من ملفّات المشروع لتجربتها محلّيًّا والتّطوير عليها، يمكن عمل فرع عن المشروع أو استنساخه من صفحته على GitHub.
-
في الجزء السّابق أنشأنا الصّفحة الرئيسيّة للمدوّنة وصفحات مفردة لكلّ تدوينة بعد تهيئة المشروع وإنشاء قواعد البيانات، الآن سنقوم بإنشاء نظام للمستخدمين لنسمح للقرّاء بالتّعليق. إنشاء صفحة "حساب جديد" و"تسجيل الدّخول"نعلم إذًا أنّنا بحاجة أولاً إلى آلية لإنشاء الحسابات على خادومنا، وأوّل ما نقوم به إنشاء صفحة على الرّابط /signup تحوي نموذجًا يعبّئه المستخدم: doctype html html(lang="ar", dir="rtl") head title إنشاء مستخدم جديد body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr h2 إنشاء مستخدم جديد form(action="/accounts", method="POST") label(for="name") اسمك: input(type="text", name="name", placeholder="الاسم كاملًا", required) br label(for="name") كلمة المرور: input(type="password", name="password", placeholder="اختر كلمة مرور قويّة", required) br label(for="username") اسم المستخدم: input(type="text", name="username", placeholder="حروف لاتينية، 50 حرفًا على الأكثر", required) br input(type="submit", value="أنشئ الحساب") احفظ النصّ السّابق في ملف signup.jade في مجلّد views؛ ثمّ أضف النّص التّالي إلى index.js قبل آخر سطر: app.get("/signup", function(request, response) { response.render("signup"); })زر الصّفحة http://localhost:3000/signup بعد تشغيل البرنامج وحاول إنشاء مستخدم جديد، سيرسلك Express إلى صفحة تفيد بعد إمكانية تنفيذ الفعل POST على الرّابط /accounts، وهو الرابط الذي اخترناه لتلقّي نماذج إنشاء المستخدمين بالنّموذج الّذي أنشأناه (لاحظ الخاصّتين action وmethod للنّموذج ضمن قالب Jade)، كلّ ما علينا الآن هو تسجيل دالّة تتعامل مع هذا الرّابط: app.post("/accounts", function(request, response) { // أنشئ الحساب }) إن كنت تتساءل لم استخدمنا POST بدلاً من GET، فالإجابة هي أنّ POST يستخدم للطّلب من الخادوم "إنشاء" الأشياء الجديدة (بينما يطلب GET "الحصول" عليها)، هذا أوّلًا، ثانيًا فإنّ إرسال الطّلب باستخدام GET، وعلى الرّغم من أنّه ممكن، إلّا أنّه قد يكشف كلمة المرور الّتي اختارها المستخدم، لأنّ محتويات النّموذج (بما فيها كلمة المرور) ستُرمّز ضمن الرّابط (URL-encoded)، وكلّ المتصفّحات تحتفظ بنسخة من سجلّ تصفّح المستخدم، وهذا قد يجعلها عرضة لأن يراها الآخرون. هذا مثال عن كيفية ترميز النّماذج في طلبات GET: http://myblog.com/signup?username=fwz&password=123456&full_name=فواز كما ترى، ليس هذا أفضل ما يمكننا فعله لإخفاء كلمة المرور! يرسل المتصفّح محتويات النّموذج بالفعل POST كمتن الطّلب، وعندما يتلقّاه الخادوم فإنّنا بحاجة إلى تحويله من نصّ مجرّد إلى صيغة كائن JavaScript، لا يقدّم Express هذه الإمكانيّة وحده، ولكنّه يوفّر وحدة منفصلة تُدعى body-parser للقيام بهذه المهمّة، قد يبدو هذا غريبًا للقادمين من PHP، لكنّها الطّريقة الّتي تسير بها الأمور في Node.js، ولهذا فوائده إذ يمكنك استبدال وحدة بوحدة أخرى تؤدّي الوظيفة ذاتها لكن قد تكون أسرع أو تقدّم وظائف أكثر، وكذلك يسمح هذا النّهج بتطوير الوحدات الصّغيرة بشكل أسرع دون الانتظار إلى صدور نسخة جديدة من إطار العمل كاملاً. كاختبار لك، قم بتثبيت body-parser وحفظه في متطلّبات المشروع. هل تذكر عندما تحدّثنا عن البرامج الوسيطة (middleware)؟ حسنًا، وحدة body-parser ليست سوى واحدة من هذه البرامج، ويأتي الاسم من كونها تتوسّط وظيفة Express لتوسّع خياراته بشكل منسجم مع سير توجيه الرّوابط. لإخبار Express برغبتنا باستخدام body-parser، علينا استيرادها ثمّ إدخالها كوسيط لعمليّة توجيه الرّابط /accounts: var express = require("express"); var bodyParser = require("body-parser"); /* ... */ var parseBody = bodyParser.urlencoded({ extended: true }); app.post("/accounts", parseBody, function(request, response) { console.log(request.body); }) app.listen(3000);هذه إحدى الطّرق لاستخدام البرامج الوسيطة على أحد الرّوابط، يمكن إدخال أي عدد من البرامج الوسيطة وسينفّذها Express واحدًا تلو الآخر حتّى يصل أخيرًا إلى دالّتنا الّتي تتعامل مع الرّابط. يمكننا أيضًا استخدام body-parser وأي برنامج وسيط آخر ليتدخّل في سير التّطبيق كاملاً (ليس على رابط واحد فقط)، وسنرى كيفيّة ذلك في وقتٍ لاحق. إن كانت صياغة السّطر var parseBody... غامضة فراجع صفحة توثيق وحدة body-parser، الأمر يتعلّق بأسلوب المطوّر الّذي أنشأ الوحدة، قد يكون أسلوب الوحدات الأخرى مختلفًا لكنّ ما يهمّك هو أن تتعلّم كيفيّة استخدام البرامج الوسيطة. في دالّة التّوجيه الأخيرة، سنقوم مبدئيًا بتسجيل محتويات النّموذج إلى الطّرفيّة الّتي تشغّل برنامجنا، يتوفّر الكائن request.body فقط لأنّنا استخدمنا body-parser قبل دالّتنا، والذي أتاح محتويات النّموذج في عنصر الطّلب. أعد تشغيل البرنامج وزُر الصّفحة ثم املأ الحقول واضغط "أنشئ الحساب"، عُد للطّرفيّة لتشاهد محتويات النّموذج وقد وصلت للخادوم: حسنًا لقد وصلَنا النّموذج وهو جاهز لإدخاله في قاعدة البيانات، لكن ليس قبل التّحقّق من محتوياته. القاعدة الرئيسيّة في حماية قواعد البيانات: لا تثق بما يُدخله المستخدم! تحقّق من سلامة كلّ حقل في النّموذج قبل إدخاله، ماذا لو أرسل المستخدم حقلاً إضافيًا is_author وجعل قيمته true، سيكون بإمكانه حينئذٍ كتابة التّدوينات دون أن نسمح له بذلك! app.post("/accounts", parseBody, function(request, response) { var username = request.body.username; var password = request.body.password; var full_name = request.body.name; if (!username || !password || username.length > 50) { response.status(400); response.send("تعذّر إنشاء الحساب، تحقّق من سلامة المُدخلات وأعد المحاولة"); return; } connection.query("INSERT INTO `users` (username, password, full_name) VALUES (?, ?, ?)", [username, password, full_name], function(err) { if (err) { response.status(500); response.send("وقع خطأ أثناء إنشاء الحساب، أعد المحاولة"); return; } response.status(201); response.send("أُنشئ الحساب، يمكنك الآن إنشاء المستخدم"); }); }) من المّهمّ ألا تأخذ الكائن response.body كاملاً وتلقيه مباشرة في قاعدة بياناتك، فقد يحتوي على حقول إضافية مثل is_author. قمنا بإجراء تحقّق بسيط من طول اسم المستخدم. تتوفّر في Node.js وحدات تُعطينا إمكانيّات أوسع للتحقّق من المدخلات بحسب أنواعها (نصّيّة، أرقام، عناوين البريد... إلخ)، سنستعرض إحداها لاحقًا. استخدمنا الرّمز 400 في حال الخطأ ومعناها Bad Request، تستخدم الأرقام ضمن 400-499 للإشارة إلى خطأ من جهة مُرسل الطّلب (خلافًا للفئة 5xx الّتي تعني أنّ الخطأ من جهة الخادوم). أمّا الرّمز 201 فيعني Created (أُنشئ). حماية كلمة المرور بدوال التّجزئة (Password hashing functions)التجزئة (hashing) موضوع معقّد للغاية، ويحتاج شرح مفاهيمه إلى سلسلة أطول من هذه! لكنّنا سنحاول توضيحه باختصار شديد للمبتدئين. بحسب ويكيبيديا، فإنّ دالّة التّجزئة التّشفيريّة: بالطّبع هذا التّعريف غامض جدًّا، والسّبب يعود إلى حدّ ما إلى غياب مصطلح عربيّ معتمد للكلمة hash، لعلّ الصّورة المرفقة مع التّعريف أعلاه تساعدنا في فهم المقصود: التّجزئة إذًا هي تحويل النّصوص المقروءة (كلمات المرور مثلاً) إلى تلك المجموعة من الحروف والأرقام الغامضة لنا، والغاية من ذلك الحصول على قيمة مميّزة للنصّ المُدخل دون الحاجة لمعرفة النّصّ ذاته، وبحيث يكون من المستحيل الحصول على نصّين مختلفين لهما قيمة مُجزّأة واحدة. إذا تمكّن شخصٌ ما من الحصول على القيم المجزّئة (يمين الصّورة) فلن يستطيع معرفة النّصّ الأصليّ (يسار الصّورة)، والطّريقة الوحيدة الّتي يمكن الاستفادة منها من القيمة المُجزّئة، هي إمكانية الإجابة على هذا السّؤال: هل النّصّ x يطابق تمامًا النّصّ y؟ يمكن الإجابة بنعم بالتّأكيد إذا كانت القيمة المُجزّئة لـx تطابق القيمة المُجزئّة لـy. نحفظ كلمة المرور مُجزّئة في قاعدة البيانات لأنّنا لا نهتمّ (ولا نرغب) بمعرفة كلمة المرور الّتي اختارها المستخدم. ما يهمّنا فقط هو أن نتحقّق من كون القيمة المُجزّئة المخزّنة في قاعدة البيانات تطابق ما يدخله المستخدم عند تسجيل دخوله بعد تجزئته بنفس الخوارزميّة، من المهمّ كذلك ألّا تتطابق القيمة المجزّئة لكلمتي مرور مختلفتين وإلّا سيتمكّن شخص محظوظ ما (أو ذكيّ) من تسجيل الدّخول باسم مستخدم آخر بكلمة مرور مختلفة! علينا أنّ نفرّق التّجزئة عن التعمية (encryption) والّتي هي تحويل نصّ مجرّد (plaintext) إلى نصّ مُشفّر (ciphertext) وفق عمليّة رياضيّة قابلة للعكس، بينما تهدف التّجزئة إلى تحويل البيانات المختلفة الحجم إلى قيمة ثابتة الطّول باتّجاه واحد فقط (one-way). في المثال السّابق أدخلنا كلمة المرور في قاعدة البيانات دون تجزئة، وهذا خطأ فادح لأنّه يسمح لمن يستطيع الوصول إلى جدول المستخدمين بالاطّلاع على كلمات مرورهم جميعًا، لعلّك تستخدم md5 أو sha1 في PHP لتجزئة كلمة المرور بشكل تقليدي، تتوفّر وحدات Node.js تسمح بتجزئة النّصوص بهذه الخوارزميات، لكنّنا سنستخدم خوارزميّة bcrypt الّتي تُعد أكثر أمانًا بمراحل من الخوارزميّتين سابقتي الذّكر: npm install bcrypt --save ملاحظة: تحتاج الوحدة bcrypt إلى إصدار متوافق من Python مثبّتًا على جهازك، راجع صفحة الوحدة على GitHub لمزيد من التّفاصيل. var bcrypt = require("bcrypt"); /* ... */ app.post("/accounts", parseBody, function(request, response) { var username = request.body.username; var password = request.body.password; var full_name = request.body.name; if (!username || !password || username.length > 50) { response.status(400); response.send("تعذّر إنشاء الحساب، تحقّق من سلامة المُدخلات وأعد المحاولة"); return; } bcrypt.hash(password, 8, function(err, hash) { if (err) { response.status(500); response.send("تعذّر إنشاء الحساب، تحقّق من سلامة المُدخلات وأعد المحاولة"); return; } connection.query("INSERT INTO `users` (username, password, full_name) VALUES (?, ?, ?)", [username, hash, full_name], function(err) { if (err) { response.status(500); response.send("وقع خطأ أثناء إنشاء الحساب، أعد المحاولة"); return; } response.send(201); response.send("أُنشئ الحساب، يمكنك الآن تسجيل الدخول"); }); }); }) // ... لنجرّب إنشاء مستخدم جديد الآن، شغّل البرنامج ثم انتقل إلى http://localhost:3000/signup، املأ الحقول بمُدخلات سليمة ثمّ اضغط "أنشئ الحساب": لنتأكد من وجود الحساب في قاعدة البيانات، افتح صدفة MySQL ونفّذ الاستعلام التّالي بعد الاتّصال بقاعدة البيانات: SELECT FROM `users` WHERE username="muhammad";بدّل اسم المستخدم بالاسم الذي ملأته في حقل "اسم المستخدم" عند إنشاء الحساب، ستحصل على نتيجة مشابهة لهذه: +----+----------+--------------------------------------------------------------+-----------+-----------+ | id | username | password | full_name | is_author | +----+----------+--------------------------------------------------------------+-----------+-----------+ | 2 | muhammad | $2a$08$6GFnpkKY6VQuB6/y4NCrg.AK9jI25XyfS6APz4rP8w1bpICKNR79G | محمد | 0 | +----+----------+--------------------------------------------------------------+-----------+-----------+ 1 row in set (0.00 sec)لاحظ كون كلمة المرور مُجزّئة ممّا يجعل معرفتها مستحيلة لمن يصل لجدول المستخدمين. تسجيل الدّخوللُننشئ صفحة تسجيل الدّخول على الرابط /login مع القالب views/login.jade: doctype html html(lang="ar", dir="rtl") head title تسجيل الدخول body style :css body { font-family: Arial, sans-serif; } h1 مُدوّنتي hr h2 تسجيل الدخول form(action="/sessions", method="POST") label(for="username") اسم المستخدم: input(type="text", name="username", required) br label(for="name") كلمة المرور: input(type="password", name="password" required) br input(type="submit", value="سجّل الدخول") سنضيف هذه الشيفرة للتّعامل مع تسجيل الدّخول: app.get("/login", function(request, response) { response.render("login"); }) app.post("/sessions", parseBody, function(request, response) { // ابحث عن المستخدم وتأكد من صحة كلمة المرور }) // ...الخطوة الأولى في تسجيل الدّخول تتضمّن التّحقّق من وجود اسم المستخدم ومقارنة كلمة المرور بعد تجزئتها (hashing) للكلمة المُجزئة في قاعدة البيانات. app.post("/sessions", parseBody, function(request, response) { var username = request.body.username; var password = request.body.password; if (!username || !password) { response.status(400); response.send("يجب توفير اسم المستخدم وكلمة المرور"); return; } connection.query("SELECT username, password FROM `users` WHERE username=?", [ username ], function(err, rows) { var user = rows[0]; if (!user) { response.status(400); response.send("لا يوجد مستخدم يطابق اسمه اسم المستخدم المطلوب"); return; } bcrypt.compare(password, user.password, function(err, result) { if (err) { response.status(500); response.send("وقع خطأ من جهة الخادم، حاول تسجيل الدخول لاحقًا"); return; } if (result == true) { // كلمتا المرور متطابقتان response.status(200); // احفظ الجلسة على المتصفّح } else { response.status(401); response.send("كلمة المرور التي أرسلتها خاطئة"); } }) }); })في البداية نبحث في قاعدة البيانات عن سطر يوافق حقل username فيه القيمة username الّتي أرسلها المتصفّح، إن وُجد هذا المستخدم فإنّنا نستخدم الوظيفة compare() الّتي توفّرها bcrypt لمقارنة كلمة المرور المُجزّئة مع كلمة المرور الّتي أرسلها المستخدم، إن كانت نتيجة المعامل result مساوية لـtrue، فهذا يعني أنّ كلمة المرور صحيحة. وإلّا فإنّنا نُرسل الرّمز 401 ويعني Unauthorized (غير مُصرّح له) مع رسالة مناسبة للدّلالة على فشل تسجيل الدّخول. لم ننتهِ بعد من تسجيل الدّخول، لكنّنا سنؤجّل الخطوة الثانية قليلاً، لأنّها تعتمد على فهمنا للجلسات (sessions)، الّتي ستكون موضوع الدّرس القادم.
-
لا قيمة لمدوّنتنا إن لم يكن باستطاعتنا كتابة التّدوينات الجديدة ونشرها، لذا علينا إنشاء صفحة تُتيح لنا (لنا فقط) كتابة تدوينة جديدة وحفظها في قاعدة البيانات. لكن أوّل ما يتبادر إلى الذّهن تساؤل عن الكيفيّة الّتي نستطيع أن نمنع فيها الزّائر من إضافة التّدوينات، إذ كيف يستطيع المتصفّح والخادوم التّفريق بين صاحب المدوّنة وزائرها؟ ملايين المواقع على الويب تقدّم محتوىً مخصّصًا لكلّ مستخدم، إذا عدنا لمثال فيس بوك وطرحنا السؤال ذاته: كيف يعرض فيس بوك نشرة أخبار خاصّة بكلّ مستخدم؟ بالطّبع لكلّ مستخدم حساب في الموقع محميّ بكلمة مرور، لكن ما الذي يحدث بين المتصفّح والخادوم ويجعل الخادوم يُرسل الصّفحة الخاصّة بالمستخدم إليه دون غيره؟ إن كنت سمعت من قبل بالكعكات (cookies) ولم تعرف ما علاقتها بالويب، فقد حان الوقت لنعرف ما هي وكيف تستخدم. الكعكات (Cookies)الكعكات هي أجزاء صغيرة من البيانات تخزّن في المتصفّح وتنتقل بينه وبين الخادوم مع كلّ طلب إلى هذا الخادوم (كترويسة Heading في طلب HTTP)، يمكن للخادوم أن يأمر المتصفّح بحفظ بيانات جديدة ضمن الكعكات، ويمكن للمتصفّح منع تخزين هذه البيانات بأمر المستخدم أو حذفها متى شاء. تُستخدم الكعكات بشكل شائع لتخزين "الجلسة" (session)، وهي الطّريقة الّتي يتذكّر بها الخادوم هذا المتصفّح بين الطّلب والآخر بحيث يستطيع تمييزه من بين الطّلبات الّتي تصله من حواسيب مختلفة حول العالم. من المهمّ أن نعرف أنّ طلبات HTTP هي طلبات مستقلّة بذاتها وعديمة الحالة (stateless)، بمعنى أن كلّ طلب يصدر من المتصفّح للخادوم نفسه لا يعرف أيّ شيء عن الطّلبات السّابقة أو اللاحقة، وكذلك الخادوم؛ إلا إذا تمّ إرفاق معرّف مُميّز (session ID) يتّفق عليه الطّرفان وينتقل مع كلّ طلب بين الجهتين. بدون الجلسات كنت ستحتاج إلى إدخال اسم مستخدمك وكلمة المرور في كلّ مرة تنتقل فيها إلى صفحة جديدة على فيس بوك! من المهمّ إذًا أن يكون معرّف الجلسة (session ID) مُميّزًا للمتصفّح ولا يتطابق مع معرّف جلسة أي متصفّح آخر، وهذا يتمّ بتوليد معرّف الجلسة بشكل عشوائي على الخادوم أولًا ثمّ إرساله للمتصفّح لحفظه ضمن الكعكات، وسيقوم المتصفّح بنقل الكعكات مع كلّ طلب، ممّا يسمح للخادوم بمعرفة تتابع الطّلبات من نفس المتصفّح. من الاستخدامات الأخرى للكعكات تتبّع المستخدمين بين المواقع عن طريق استضافة محتوى من طرف ثالث ضمن صفحة الموقع (third-party cookies) وهي حيلة تستخدم لمعرفة ذوق المستخدم وتوجّهه من خلال أنواع المواقع الّتي يزورها وبالتّالي استهدافه بالإعلانات أو مراقبة نشاطه. لا غرابة أن تعطينا المتصفّحات وسيلة لمنع كعكات الطّرف الثّالث، أو لمنع الكعكات بالكامل! لنلخّص الأمر: تسمح الجلسات بربط طلبات HTTP المتتالية بحيث يتعرّف الخادوم على كونها صادرة من جهة واحدة، مما يسمح له بتخصيص الإجابة على هذه السلسلة من الطّلبات دون غيرها، سنستفيد من هذا في حفظ تسجيل الدّخول المستخدم بحيث لا نطلب منه كلمة المرور عندما ينتقل من صفحة لأخرى. لنعد الآن إلى شيفرة تسجيل الدّخول ولنفكّر، ما الذي نحتاجه لحفظ الجلسة؟ عندما يُسجّل المستخدم دخوله للمرّة الأولى، نحتاج إلى حفظ مُعرّف الجلسة على الخادوم ليكون بإمكاننا مقارنته مع الطّلبات التّالية، وهذا يعني أنّنا بحاجة لوسيلة لحفظ معرّف الجلسة لكل مستخدم. في Express يتوفّر البرنامج الوسيط express-session الذي يتولّى هذه المهمّة كاملةً. قم بتثبيت هذه الوحدة، ثم لنقم باستيرادها: var express = require('express'); // ... var session = require('express-session');نريد استخدام express-session على مستوى التّطبيق بالكامل، ما يعني أنّنا نريد لها أن تتعقّب كلّ الطّلبات على جميع الرّوابط المسجّلة مما يسمح بمتابعة الجلسة، تسمّى هذه الوحدات البرامج الوسيطة على مستوى التّطبيق (Application-level middleware) بعكس الأسلوب الّذي استخدمناه في body-parser لتفسير متن الطّلب في نماذج إنشاء المستخدم وتسجيل الدّخول (برامج وسيطة على مستوى المُوجّه Router-level middleware). يمكن للوحدة أن تُستعمل بالأسلوبين. تستخدم الوحدات على مستوى التّطبيق باستدعاء الوظيفة use() لتطبيقنا: app.use(session({ secret: "my top secret", resave: false, saveUninitialized: true })); تستقبل وحدة session كائن الإعدادات الذي يتضمّن: secret: كلمة سرّيّة تسمح بتجزئة معرّف الجلسة لحمايته، ضع ما تشاء هنا.resave: هل يجب كتابة الجلسة مع كلّ طلب حتّى وإن لم تتغيّر؟saveUninitialized: هل يجب حفظ الجلسات الجديدة تلقائيًّا إلى الخادوم؟لا تقلق إن كانت هذه الإعدادات غامضة، ستتعرّف على فائدتها مع مرور الوقت. تذكّر أنّ ترتيب استدعاءات البرامج الوسيطة مهمّ، يجب أن نضيف الشّيفرة السّابقة قبل تسجيل الروابط لنتمكّن من متابعة الجلسة عبر كلّ الرّوابط المسجّلة. حسنًا من المفترض الآن أن يقوم المتصفّح بحفظ معرّف الجلسة ثم نقله مع كلّ طلب، لنتحقّق من هذا؛ شغّل البرنامج ثم زر الصفحة الرئيسية للمدوّنة، افتح أدوات المطوّرين (Ctrl + Shift + K في فيرفكس)، ثم انتقل إلى تبويب الشّبكة واضغط زر إعادة تحميل الصّفحة، سيبدأ المتصفّح بمراقبة الطّلبات، سيظهر طلب للصفحة الرئيسيّة مع بداية تحميلها، انقر عليه وابحث عن ترويسة Cookie في الجانب، لاحظ أنّها تحوي قيمة connect.sid، وهذا هو المعرّف المميّز الذي سينتقل بين الطّلبات، للتأكد من ذلك انتقل إلى صفحة أخرى مثل /posts/hello-world وكرّر العمليّة، ستجد أن معرّف الجلسة ثابت لا يتغيّر. عظيم! أصبح بإمكاننا الآن تمييز الطّلبات ومتابعتها، لكن ما الفائدة التي جنيناها إلى الآن! في الحقيقة لا شيء، نحتاج إلى الاستفادة من كون معرّف الجلسة مميّزًا بحيث نعلم أن المستخدم الذي تحمل طلباته هذا المعرّف قد سجّل دخوله فلا نطلب منه كلمة المرور في كلّ طلب، وسنستفيد من ذلك أيضًا في تخصيص محتوى الصّفحة وإتاحة وصول الكُتَّاب إلى صفحة إنشاء التّدوينات فيما بعد. نحتاج إلى حفظ معرّف الجلسة في جدول بقاعدة البيانات لنتمكّن من طلبه لاحقًا ومقارنته مع الطّلبات القادمة، لنُنشئ جدولاً يحفظ معرّفات الجلسات لكلّ مستخدم: CREATE TABLE `sessions` (session_id VARCHAR(100) NOT NULL PRIMARY KEY, username VARCHAR(50) NOT NULL, FOREIGN KEY (username) REFERENCES `users` (username), INDEX (username));من المهمّ أن نفهم أنّ معرّف الجلسة يحلّ محلّ كلمة المرور واسم المستخدم معًا، لهذا من الضّروري أن يكون مميّزًا (PRIMARY KEY) بحيث لا يتطابق معرّفان لمستخدمين مختلفين، من المهمّ، للسبب ذاته، حماية الجلسة وتوليدها بطريقة عشوائية، وهذا هو سبب استخدامنا للكلمة السّريّة secret في إعدادات express-session، من إجراءات الأمان الشّائعة إضافة مهلة تنتهي بعدها صلاحيّة الجلسة، وهذا هو السّبب الذي تطالبك لأجله بعض المواقع بإعادة تسجيل دخولك بعد فترة من الزّمن؛ لكنّنا لن نُشغل بالنا بهذه التّفاصيل الآن. حسنًا لدينا الآن معرّف الجلسة وجدول لحفظه، كل ما نحتاجه عند تسجيل الدّخول بشكل صحيح حفظ معرّف الجلسة إلى الجدول، لنعد إذًا إلى شفرة تسجيل الدّخول الّتي تركناها في الفقرة السّابقة: /* ... */ var cookieParser = require("cookie-parser"); app.use(cookieParser()); /* ... */ app.post("/sessions", parseBody, function(request, response, next) { var username = request.body.username; var password = request.body.password; if (!username || !password) { response.status(400); response.send("يجب توفير اسم المستخدم وكلمة المرور"); return; } connection.query("SELECT username, password FROM `users` WHERE username=?", [ username ], function(err, rows) { var user = rows[0]; if (!user) { response.status(400); response.send("لا يوجد مستخدم يطابق اسمه اسم المستخدم المطلوب"); return; } bcrypt.compare(password, user.password, function(err, result) { if (err) { response.status(500); response.send("وقع خطأ من جهة الخادم، حاول تسجيل الدخول لاحقًا"); return; } if (result == true) { connection.query("INSERT INTO `sessions` (session_id, username) VALUES (?, ?)", [ request.cookies["connect.sid"], username ], function(err) { if (err) return next(err); // تعامل مع الخطأ response.status(200); response.send("تم تسجيل الدّخول"); }) } else { response.status(401); response.send("كلمة المرور التي أرسلتها خاطئة"); } }) }); }) توفّر لنا express-session معرّف الجلسة ضمن الكعكة تحت الاسم connect.sid والذي يمكن تغييره بضبط القيمة name في إعدادات الوحدة. استخدمنا الوحدة cookie-parser التي تقوم بما يوحي به اسمها وتوفّر لنا الكعكات ضمن كائن الطّلب للحصول على معرّف الجلسة. نكاد ننتهي من إنشاء نظام المستخدمين، بقي علينا فقط إرفاق معلومات المستخدم مع كلّ دالّة توجيه لنتمكّن من عرض اسم المستخدم في الصّفحة وإتاحة تسجيل الخروج، بل يمكننا أيضًا توجيهه إلى صفحات خاصّة به أو منعه من الوصول إلى صفحات أخرى، سنقوم بإضافة دالّة توجيه تسبق جميع روابطنا وتقوم بإرفاق بيانات المستخدم (بعد جلبها من قاعدة البيانات) وإضافتها إلى كائن الطّلب: app.use(function(request, response, next) { var session_id = request.cookies["connect.sid"]; if (session_id) { connection.query("SELECT users.id, users.username, full_name, is_author FROM `users` JOIN `sessions` ON users.username=sessions.username WHERE session_id=?", [ session_id ], function(err, rows) { if (!err && rows[0]) { request.user = rows[0]; } next(); }) } else { next(); } }) في الحقيقة، ما كتبناه للتوّ ليس سوى برنامج وسيط، لا يختلف في شيء عن البرامج الوسيطة التي استعملناها مثل express-session عدا أنّ الأخيرة يوفّرها مطوّرون آخرون كحزمة على npm يمكن استيرادها. في دوال التّوجيه التّالية، سيتوفّر لدينا كائن request.user يتضمّن معلومات المستخدم الحالي، لنجرّب ذلك بإنشاء صفحة الملفّ الشّخصي للمستخدم (views/profile.jade): doctype html html(lang="ar", dir="rtl") head title الملف الشخصي body style :css body { font-family: Arial, sans-serif; } if (user) h1 #{ user.full_name } (#{ user.username }) hr else p لم تقم بتسجيل دخولك app.get("/profile", function(request, response) { response.render("profile", { user: request.user }) }) سنقوم بتوفير الكائن request.user للقالب، والذي سيكون غير معرّف إن لم يُوجد في قاعدة البيانات أو إن لم يسجل المستخدم دخوله، سيتولى القالب هذه الحالة ويعرض الرسالة المناسبة. لاحظ دعم Jade للجمل الشّرطيّة. حسنًا، لنجرّب الآن ما كتبناه، شغّل البرنامج ثم زر الصفحة http://localhost:3000/login، سجّل الدّخول باسم المستخدم admin وكلمة المرور 123456، من المفترض أن تنتقل إلى صفحة تخبرك بنجاح العملية، الآن انتقل إلى http://localhost:3000/profile لتشاهد الملف الشّخصيّ (حسنًا لا يبدو عظيمًا جدًّا، لكنّنا سنحسّنه لاحقًا): تهانينا! لقد أنشأنا نظامًا للمستخدمين وأصبح بإمكاننا عرض محتوى مخصّص لكلّ مستخدم! في الجزء القادم سنتيح لأنفسنا كتابة التّدوينات، وللمستخدمين إضافة التعليقات، وستكون أعظم مدوّنة في التّاريخ!