البحث في الموقع
المحتوى عن 'rest'.
-
نكمل في هذا المقال تطوير تطبيق الواجهة البرمجية 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 (الجزء الأول)
-
توفر الكثير من المواقع واجهات برمجية Application Programming Interface ،API بهدف إتاحة موارد الموقع لتطبيقات خارجية؛ قد تكون تطبيقات ٍللجوال، أجهزةً لوحية، أو أجهزةً مكتبية. قد نود مثلا إنشاء تطبيق للجوال نعرض فيه منتجات الموقع. نستخدم لغة البرمجة المناسبة للتطبيق (جافا مثلا لتطبيقات أندرويد) الذي يرسل طلبات لواجهتنا البرمجية يحصُل بموجبها على بيانات يتولى هو طريقة عرضها. نقول إن تطبيق الجوال في هذه الحالة يستهلك Consume الواجهة البرمجية. هذا الدرس جزء من سلسلة تعلم Laravel والتي تنتهج مبدأ "أفضل وسيلة للتعلم هي الممارسة"، حيث ستكون ممارستنا عبارة عن إنشاء تطبيق ويب للتسوق مع ميزة سلة المشتريات. يتكون فهرس السلسلة من التالي: مدخل إلى Laravel 5.تثبيت Laravel وإعداده على كلّ من Windows وUbuntu.أساسيات بناء تطبيق باستخدام Laravel.إنشاء روابط محسنة لمحركات البحث (SEO) في إطار عمل Laravel.نظام Blade للقوالب.تهجير قواعد البيانات في Laravel. استخدام Eloquent ORM لإدخال البيانات في قاعدة البيانات، تحديثها أو حذفها. إنشاء سلة مشتريات في Laravel.الاستيثاق في Laravel. إنشاء واجهة لبرمجة التطبيقات API في Laravel. (هذا الدرس)إنشاء مدوّنة باستخدام Laravel.استخدام AngularJS واجهةً أمامية Front end لتطبيق Laravel.الدوّال المساعدة المخصّصة في Laravel.استخدام مكتبة Faker في تطبيق Laravel لتوليد بيانات وهمية قصدَ الاختبار. نغطي في هذا الدرس المواضيع التالية: ماهي واجهات REST البرمجية؟الممارسات المنصوح بها في واجهات REST البرمجية.إنشاء واجهة برمجية لمشروع Larashop.ماهي واجهات REST البرمجية؟توصف الكثير من الواجهات البرمجية بأنها RESTful، فما المقصود بهذا الوصف؟ تختصر REST العبارة Representational State Transition (النقل التمثيلي للحالة) وهي طريقة لتصميم البرمجيات تعرِّف معاييرَ يجب على خدمات الويب اتباعها من أجل أداء أعلى وصيانة أسهل. تعتمد بنية التطبيقات REST على بروتوكول HTTP لإرسال الطلبات والحصول على إجابات؛ ومن أهم القيود التي يجب الالتزام بها في تطبيقات REST: العمل حسب مبدأ خادوم-عميل Server-Client، انعدام الحالة Stateless وتوحيد الواجهات (إضافة لقيود أخرى). مبدأ خادوم-عميل: يجب التفريق بين واجهة المستخدم والخادوم الذي يخزن البيانات ويطبق العمليات عليها.انعدام الحالة: يجب أن يحوي الطلب الموجّه من العميل إلى الخادوم كل المعلومات الضرورية ليستطيع الخادوم فهمه والإجابة عليه؛ دون الحاجة لسياق محفوظ على الخادوم (لفهم الطلب).توحيد الواجهات بمعنى أن كل مورد على الخادوم يمكن تعريفه فرديا واستغلاله عبر بيانات تمثله يحتفظ بها العميل. يجب أن تكون الطلبات واضحة يمكن فهمها والإجابة عنها دون الحاجة لمعلومات خارجة عنها. يدخل ضمن توحيد الواجهات أيضا افتراضُ العميل أن أي إجراء Action غيرُ متوفر على الخادوم، ما لم يصّرح هذا الأخير بتوفره.تساهم هذه القيود (والقيود الأخرى التي تعرفها بنية REST) في تسهيل عمل الواجهات، الرفع من أدائها، تيسير الصيانة وقابلية التمدد Scalability. سنرى في الفقرة التالية توصيات لبناء واجهات برمجية تساعد في احترام مبادئ REST. ملحوظة: يكثُر وصف الواجهات البرمجية بأنها RESTful (تلتزم بقيود REST) دون أن تلتزم بكامل القيود التي تعرِّفها بنية REST، وهو ما يجعلها أقرب لواجهات شبيهة لـREST منها لواجهات RESTful. الممارسات المنصوح بها في واجهات REST البرمجيةيُساعد الالتزام بالممارسات التالية في بناء واجهة تطبيقات برمجية ذات أداء عال وقابلية كبيرة للتمدد والصيانة. استخدام إجراءات HTTP لتحديد العمل الذي سيؤديه الخادوم: GET للحصول على مورد، POST لإنشاء مورد جديد، PUT لتحديث مورد وDELETE لحذفه.أَصْدَرَة Versioning الواجهة: يساعد استخدام إصدارات في عدم كسر التطبيقات التي تستهلك الواجهة البرمجية. يحدّد العميل إصدار واجهة التطبيق الذي يود العمل عليه مما يسمح بإحداث تغييرات على الخادوم تضمَّن في إصدار جديد دون أن يتوقف عملاء الواجهة البرمجية.من المتعارف عليه استخدام أسماء جموع لوصف الموارد، api.mysite/v1/products مثلا للمنتجات. ليس واجبا اتباع هذا العرف لكن الأهم هو تناسق تسمية الموارد: لا تخلط بين أسماء مفردة للموارد وجموع.استخدام الإجابات الجزئية: طلب العميل اسم المنتج؟ أرسل اسم المنتج فقط، وليس كامل بيانات المنتج، في الإجابة.استخدام رموز الحالة: تسهّل رموز الحالة في HTTP التخاطب مع العميل. عولج الطلب على النحو الأمثل؟ أرسِل الرمز 200 في الإجابة. طلب العميل إنشاء مورد وتم الأمر؟ أرسل الرمز 201 في الإجابة؛ وهكذا. راجع هذا الرابط للمزيد من رموز الحالة في HTTP.ضع حدًّا أقصى لعدد الطلبات القادمة من نفس عنوان IP في الواجهات المفتوحة للجميع. يساعد هذا الأمر في التصدي للعملاء الذي يفرطون في استخدام واجهة تطبيقاتك البرمجية. ينصح أيضا بحظر عناوين IP ذات السلوك المشبوه حتى لا يؤثر على بقية المستخدمين.قد يُختلف حول هذه التوصية، إلا أنه يُنصح باستخدام صيغة JSON لإرسال البيانات في الإجابة عن الطلب، ما لم يحدّد العميل عكس ذلك.خبِّئ Cache نتائج طلبات GET التي لا تتغير كثيرا. ربما تكون قائمة العلامات التجارية في مواقع التسوق مثالا جيدا للبيانات التي يجب تخبئتها (قد تمضي أشهر دون الحاجة لإضافة علامة تجارية جديدة).واجهة Larashop البرمجيةسننشئ في هذه الفقرة واجهة تطبيقات برمجية لمشروع Larashop. تشتمل الواجهة على المسارات أدناه. تستخدم جميع المسارات إجراء GET للحصول على المورد. التسلسل المورد الرابط الوصف رمز الحالة1Product/api/v1/productsسرد لائحة بالمنتجات وخاصياتها2002Product/api/v1/products/1سرد خاصيات المنتج رقم 12003Category/api/v1/categoriesسرد لائحة بتصنيفات المنتجات2004Category/api/v1/categories/1التصنيف ذو المعرّف 1200لاحظ أننا لم نتح إمكانية التعديل على الموارد عبر الواجهة. تشير v1 في المسارات إلى رقم الإصدار 1. نفتح ملف المسارات routes.php ونعدّله بإضافة المسارات التالية: // API routes... Route::get('/api/v1/products/{id?}', ['middleware' => 'auth.basic', function($id = null) { if ($id == null) { $products = App\Product::all(array('id', 'name', 'price')); } else { $products = App\Product::find($id, array('id', 'name', 'price')); } return Response::json(array( 'error' => false, 'products' => $products, 'status_code' => 200 )); }]); Route::get('/api/v1/categories/{id?}', ['middleware' => 'auth.basic', function($id = null) { if ($id == null) { $categories = App\Category::all(array('id', 'name')); } else { $categories = App\Category::find($id, array('id', 'name')); } return Response::json(array( 'error' => false, 'user' => $categories, 'status_code' => 200 )); }]);يعرف المسار Route::get('/api/v1/products/{id?}', ['middleware' => 'auth.basic', function($id = null)رابطًا يطلب المنتجات مع معرّف اختياري id. يُستخدم المعرف لطلب منتج واحد وفي حال عدم ذكره ترجِع واجهة التطبيقات جميع المنتجات. نحمي المورد بالتعليمة 'middleware' => 'auth.basic' التي تستوثق من العميل. حددنا نمط الاستيثاق بـauth.basic لاستخدام بريد المستخدِم (حقل email في جدول users) مع كلمة السر. يستدعي كل مسار النموذج المناسب للعثور على البيانات في القاعدة ثم نرسل الإجابة بصيغة JSON بالتعليمة Response::json. عند طلب الرابط http://larashop.dev/api/v1/products ستظهر نافذة تطلب إدخال بريد المستخدم وكلمة سره. استخدم الحساب الذي أنشأته في درس الاستيثاق وستظهر النتيجة التالية في المتصفح (بعد التنسيق) { "error":false, "products": [ { "id":"1", "name":"Mini skirt black edition", "price":"35" }, { "id":"2", "name":"T-shirt blue edition", "price":"64" }, { "id":"3", "name":"Sleeveless Colorblock Scuba", "price":"13" } ], "status_code":200 }حصلنا على إجابة بصيغة JSON للطلب الذي أرسلناه من أجل الحصول على منتجات الموقع. يشير الرمز 200 إلى أن معالجة الطلب تمّت دون مشاكل. يمكن للعميل الآن تنسيق الإجابة لعرضها بطريقة مناسبة. خاتمةوضعنا في هذا الدرس أساسا يمكن البناء عليه لإنشاء واجهات برمجية أكثر تطورا. يتلخص إنشاء واجهات برمجية في Laravel في تعريف المسارات، استخدام النماذج للحصول على البيانات المطلوبة ثم تهيئة الإجابة بصيغة JSON ثم إرسالها. ترجمة -وبتصرّف- للمقال Laravel 5 REST API لصاحبه Rodrick Kazembe.
- 1 تعليق
-
- 2
-
- واجهة برمجية
- laravel5
-
(و 2 أكثر)
موسوم في:
-
تعرّفنا في مقال سابق على ميزات جديدة في الإصدار ES6 من جافاسكريبت. سنتابع في هذا المقال الحديث عن الميزات الأكثر استخداما من هذا الإصدار وذلك بتناول الإضافات الجديدة التالية: المُعاملان restوspread. تحسينات على الكائنات. القوالب Templates. المعاملان rest وspread يبدو المعاملان rest وspread متشابهين، ويُشار إلى كليهما بثلاث نقاط .... يختلف عمل المعاملين تبعا لطريقة استخدامهما. المعامل rest يعمل rest حرفيا على أخذ بقيّة الشيء ووضعها ضمن مصفوفة. يحوّل المعامل لائحة من المعاملات المحدّدة بفاصلة إلى مصفوفة. فلنر أمثلة عملية على rest. فلنتخيّل أن لدينا دالة باسم add تجمع المعطيات المُمرَّرة لها: sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) // 55 نعتمد في الإصدار ES5 على المتغيّر arguments في كل مرة نحتاج فيها للتعامل مع دالة تأخذ عددا غير محدّد من المعاملات. المتغيّر arguments هو من النوع Symbol الشبيه بالمصفوفات Array. function sum () { console.log(arguments) } sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) إحدى الطرق التي يمكن استخدامها لجمع قيم arguments هي تحويلها إلى مصفوفة Array باستخدام Array.prototype.slice.call(arguments) ثم المرور على كل عدد باستخدام تابع مصفوفة مثل forEach وreduce. من السهل استخدام forEach لهذا الغرض، لذا سأشرح استخدام reduce: function sum () { let argsArray = Array.prototype.slice.call(arguments) return argsArray.reduce(function(sum, current) { return sum + current }, 0) } يتيح لنا المعامل rest جعل جميع القيم المفصولة بفاصلة في مصفوفة مباشرة: const sum = (...args) => args.reduce((sum, current) => sum + current, 0) أو إن أردنا التقليل من استخدام الدوال السهمية: function sum (...args) { return args.reduce((sum, current) => sum + current, 0) } عرّجنا سريعا عند الحديث عن تفكيك المصفوفات على المعامل rest بسرعة. حاولنا حينها تفكيك المصفوفة scores إلى القيم الثلاث الأعلى: let scores = ['98', '95', '93', '90', '87', '85'] let [first, second, third] = scores console.log(first) // 98 console.log(second) // 95 console.log(third) // 93 إن رغبنا في الحصول على بقية النتائج فبإمكاننا جعلها في مصفوفة مستقلة بالمعامل rest: let scores = ['98', '95', '93', '90', '87', '85'] let [first, second, third, ...restOfScores] = scores console.log(restOfScores) // [90, 97, 95] تذكّر دائما - لتحنب الخلط - أن المعامل rest يجعل كل شيء في مصفوفة؛ ويظهر في معاملات الدوال وأثناء تفكيك المصفوفات. المعامل spread يعمل المعامل spread بطريقة معاكسة لعمل rest. يأخذ المعامل مصفوفة ويوزّعها على لائحة معاملات يُفصَل بين قيمها بفاصلة: let array = ['one', 'two', 'three'] // نتيجة التعليمتين التاليتين متطابقة console.log(...array) // one two three console.log('one', 'two', 'three') // one two three يُستخدَم المعامل spread غالبا لجمع المصفوفات بطريقة تسهّل قراءتها وفهمها. نريد على سبيل المثال جمع المصفوفات التالية: let array1 = ['one', 'two'] let array2 = ['three', 'four'] let array3 = ['five', 'six'] يُستخدَم التابع Array.concat في الإصدارات القديمة من جافاسكريبت لجمع عدد غير محدَّد من المصفوفات: let combinedArray = array1.concat(array2).concat(array3) console.log(combinedArray) // ['one', 'two', 'three', 'four', 'five', 'six'] يتيح المعامل spread توزيع قيم المصفوفات على مصفوفة جديدة على النحو التالي: let combinedArray = [...array1, ...array2, ...array3] console.log(combinedArray) // ['one', 'two', 'three', 'four', 'five', 'six'] يُستخدَم المعامل spread كذلك لحذف عنصُر من مصفوفة دون التعديل عليها. تُستخدَم هذه الطريقة كثيرا في Redux (يشرح هذا الفيديو) كيف يفعلون ذلك. تحسينات على الكائنات الكائنات من الأمور التي يجدر بكلّ مبرمج جافاسكريبت التعوّد عليها. للتذكير؛ تبدو الكائنات بالهيئة التالية: const anObject = { property1: 'value1', property2: 'value2', property3: 'value3', } يضيف الإصدار ES6 ثلاث ميزات جديدة للكائنات في جافاسكريبت: اختصار قيم الخاصيّات Properties، اختصارات للتوابع Methods، إمكانية استخدام أسماء محسوبة للخاصيّات. سنعرّج على كل واحدة من هذه الميزات. اختصار قيم الخاصيّات هل سبق لك ملاحظة أنك تسند أحيانا متغيّرا إلى خاصية كائن تشترك معه في الاسم؟ شيء من قبيل: const fullName = 'Zell Liew' const Zell = { fullName: fullName } قد ترغب في طريقة أكثر اختصارا من السابق بما أن الخاصيّة fullNameتساوي قيمة المتغيّر fullName. تساعد ميزة اختصار قيم الخاصيّات التي يضيفها الإصدار ES6 في تقليل الشفرة اللازمة لكتابة الكائنات عندما يوافق اسمُ المتغيّر اسمَ الخاصيّة: const fullName = 'Zell Liew' // استخدام ميزة الاختصار في ES6 const Zell = { fullName } // يُترجَم الاختصار في الخلفية إلى ... const Zell = { fullName: fullName } اختصارات التوابع التوابع هي خاصيّات بصيغة دوالّ. تُسمّى هذه الخاصيّات توابع لأنها دوال. في ما يلي مثال على تابع: const anObject = { aMethod: function () { console.log("I'm a method!~~")} } يتيح الإصدار ES6 كتابة التوابع بطريقة مختصرة. يمكننا حذف الكلمة المفتاحية function ولن يتغيّر شيء: const anObject = { // حسب ES6 aShorthandMethod (arg1, arg2) {}, // حسب ES5 aLonghandMethod: function (arg1, arg2) {}, } استخدم هذه الميزة لاختصار توابع الكائنات ولا تلجأ إلى الدوال السهمية لكتابة التوابع (راجع الدوال السهمية في مقال سابق). const dontDoThis = { // تجنّب هذا arrowFunction: () => {} } أسماء محسوبة للخاصيّات تحتاج أحيانا إلى أسماء متغيّرة (ديناميكية) لخاصيّات الكائن. في هذه الحالة ننشئ متغيّرا نضع فيه اسم الخاصيّة الديناميكية. تضطرّ في الإصدارات القديمة من جافاسكريبت لإنشاء الكائن ثم إسناد الخاصيّة على النحو التالي: // متغيّر لحفظ اسم الخاصيّة الجديدة const newPropertyName = 'smile' // ننشئ الكائن أولا const anObject = { aProperty: 'a value' } // ثم نسند قيمة للخاصية الجديدة anObject[newPropertyName] = ':D' // إضافة خاصيّة مختلفة قليلا عن السابقة وإسناد قيمة لها anObject['bigger ' + newPropertyName] = 'XD' // النتيجة // { // aProperty: 'a value', // 'bigger smile': 'XD' // smile: ':D', // } ينزع الإصدار ES6 الحاجة للّف والدوران كما في المثال السابق؛ إذ أصبح بإمكانك إسناد أسماء متغيّرة للخاصيّات مباشرة أثناء إنشاء الكائن بجعل الخاصيّة المتغيّرة داخل أقواس معكوفة: const newPropertyName = 'smile' const anObject = { aProperty: 'a value', // أسماء متغيّرة للكائنات [newPropertyName]: ':D', ['bigger ' + newPropertyName]: 'XD', } // النتيجة // { // aProperty: 'a value', // 'bigger smile': 'XD' // smile: ':D', // } القوالب التعامل مع سلاسل المحارف Strings في جافاسكريبت مزعج للغاية. رأينا مثالا على ذلك في دالة announcePlayer عند الحديث عن المعاملات المبدئية. أنشأنا في تلك الدالة سلاسل محارف فارغة ودمجناها باستخدام عامل الجمع +: function announcePlayer (firstName, lastName, teamName) { console.log(firstName + ' ' + lastName + ', ' + teamName) } تأتي القوالب في الإصدار ES6 لتفادي هذا المشكل (كانت القوالب تُسمى سلاسل محارف القوالب Template strings في مسودات ES6). توضع سلاسل المحارف التي نريد جعلها قالبا بين علامتيْ `. يمكن استخدام متغيّرات جافاسكريبت في القوالب داخل ماسك المكان {}$ . هكذا يبدو الأمر: const firstName = 'Zell' const lastName = 'Liew' const teamName = 'unaffiliated' const theString = `${firstName} ${lastName}, ${teamName}` console.log(theString) // Zell Liew, unaffiliated يمكنك كلك إنشاء سلاسل محارف متعدّدة الأسطُر بسهولة. تعمل الشفرة التالية دون مشكل: const multi = `One upon a time, In a land far far away, there lived a witich, who could change night into day` يمكنك كذلك إنشاء شفرات HTML في جافاسكريبت باستخدام القوالب (ربما لا تكون هذه هي أفضل طريقة لإنشاء عناصر HTML، لكنها على كل حال أفضل من إنشاء عناصر HTML الواحد تلو الآخر). const container = document.createElement('div') const aListOfItems = `<ul> <li>Point number one</li> <li>Point number two</li> <li>Point number three</li> <li>Point number four</li> </ul>` container.innerHTML = aListOfItems document.body.append(container) تأتي مع القوالب ميزة الوسوم Tags، وهي دوال يمكن بواسطتها التعامل مع سلاسل المحارف الموجودة في القوالب إن أردت استبدال سلسلة بأخرى. const animal = 'lamb' // هذه الدالة تمثّل وسما const tagFunction = () => { // Do something here } const string = tagFunction `Mary had a little ${animal}` عليّ الاعتراف أنه على الرغم من أن وسوم القوالب تبدو ميزة مفيدة للغاية إلا أنني لم أحتج حتى الساعة لاستخدامها. خاتمة تعرّفنا في هذا الدليل على أكثر ميزات الإصدار ES6 من جافاسكريبت استخداما. سيكون من المفيد التعوّد على استخدام هذه الإضافات كل ما كان ذلك ممكنا، فمن المؤكّد أنها ستجعل شفرتك البرمجية أقصر وأسهل قراءة وبالتالي تزيد من إنتاجيّتك. ترجمة - بتصرّف - للمقال Introduction to commonly used ES6 features لصاحبه Zell.
-
بدأ تطوير لغة البرمجة Go بتجربة من مهندسين يعملون في Google لتلافي بعض التعقيدات الموجودة في لغات برمجة أخرى مع الاستفادة من نقاط قوّتها. تُطوَّر لغة Go باستمرار بمشاركة مجتمع مفتوح المصدر يزداد باضطّراد. تهدف لغة البرمجة Go إلى أن تكون سهلة، إلا أن اصطلاحات كتابة الشفرة البرمجية في Go قد تكون صعبة الاستيعاب. سأريكم في هذا الدرس كيف أبدأ جميع مشاريعي البرمجية عندما أستخدم Go، وكيفية استخدام التعابير التي توفّرها هذه اللغة. سننشئ خدمة سند خلفي Backend لتطبيق وِب. إعداد بيئة العمل الخطوة الأولى هي - بالطبع - تثبيتُ Go. يمكن تثبيت Go من المستودعات الرسمية على توزيعات لينكس؛ مثلا بالنسبة لأوبونتو: sudo apt install golang-go إصدارات Go الموجودة في المستودعات الرسمية تكون في العادة أقدم قليلا من تلك الموجودة على الموقع الرسمي، إلا أنها تؤدي الغرض؛ علاوة على سهولة التثبيت. يمكنك تثبيت إصدارات أحدث على أوبونتو (هنا) وCentos (هنا). بالنسبة لمستخدمي نظام Mac OS فيمكنهم تثبيت اللغة عن طريق Homebrew: brew install go يحوي الموقع الرسمي كذلك الملفات التنفيذية لتثبيت اللغة على أغلب أنظمة التشغيل، بما في ذلك ويندوز. تأكّد من تثبيت Go بتنفيذ الأمر التالي: go version مثال لنتيجة اﻷمر أعلاه (على توزيعة أوبونتو): go version go1.6.2 linux/amd64 – روابط للتثبيت على وندوز وإعداد المسارات – توجد الكثير من محرّرات النصوص والإضافات المتاحة لكتابة شفرات Go. أفضّل شخصيّا محرّر الشفرات Sublime Text وإضافة GoSublime؛ إلا أن طريقة كتابة Go تتيح استخدام محرّرات نصوص عاديّة بسهولة خصوصا للمشاريع الصغيرة. أعمل مع محترفين يقضون كامل اليوم في البرمجة بلغة Go باستخدام محرّر النصوص Vim، دون أي إضافة لإبراز صيغة الشفرات البرمجية Syntax highlighting. بالتأكيد لن تحتاج لأكثر من محرّر نصوص بسيط للبدء في تعلّم Go. مشروع جديد إن لم تكن أنشأت مجلّدا للعمل أثناء تثبيت Go وإعداده فالوقت مناسب لذلك. تتوقّع أدوات Go أن توجد جميع الشفرات البرمجية على المسار GOPATH/src$، وبالتالي سيكون عملنا دائما في هذا المجلّد. يمكن لمجموعة أدوات Go كذلك أن تتخاطب مع مشاريع مُضيَّفة على مواقع مثل GitHub وBitbucket إن أُعدّت لذلك. سننشئ لأغراض هذا الدرس مستودعا جديدا فارغا على GitHub ولنسمّه “hello” (أو أي اسم يناسبك). ننشئ مجلّدا ضمن مجلّد GOPATH لاستقبال ملفات المستودع (أبدل your-username باسم المستخدم الخاصّ بك على GitHub): mkdir -p $GOPATH/src/github.com/your-username cd $GOPATH/src/github.com/your-username ننسخ المستودع ضمن المجلّد الذي أنشأناه أعلاه: git clone git@github.com:your-username/hello cd hello سننشئ الآن ملفا باسم main.go ليحوي برنامجا قصيرا بلغة Go: package main func main() { println("hello!") } نفّذ الأمر go build لتصريف جميع محتويات المجلّد الحالي. سينتُج ملف تنفيذي بنفس الاسم؛ يمكنك بعدها طلب تشغيله بذكر اسمه على النحو التالي: go build ./hello النتيجة: hello! ما زلت رغم سنوات من التطوير بلغة Go أبدأ مشاريعي بنفس الطريقة: مستودع Git فارغ، ملف main.go وبضعة أوامر. يصبح أي تطبيق يتبع الطرق المتعارف عليها لتنظيم شفرة Go قابلا للتثبيت بسهولة بالأمر go get. إن أودعت على سبيل المثال الملف أعلاه ودفعته إلى مستودع Git فإن أي شخص لديه بيئة عمل Go يمكنه تنفيذ الخطوتين التاليتين لتشغيل البرنامج: go get github.com/your-username/hello $GOPATH/bin/hello إنشاء خادوم وب فلنجعل برنامجنا البسيط السابق خادوم وب: package main import "net/http" func main() { http.HandleFunc("/", hello) http.ListenAndServe(":8080", nil) } func hello(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello!")) } هناك بضعة سطور تحتاج للشرح. نحتاج أولا لاستيراد الحزمة net/httpمن المكتبة المعيارية لـGo: import "net/http" ثم نثبّت دالة معالجة Handler function في المسار الجذر لخادوم الوِب. تتعامل http.HandleFunc مع الموجّه المبدئي لطلبات Http في Go، وهو ServeMux. http.HandleFunc("/", hello) الدالة hello هي من النوع http.HandlerFunc الذي يسمح باستخدام دوال عاديّة على أنها دوال معالجة لطلبات HTTP. للدوال من النوع http.HandlerFunc توقيع Signature خاص (توقيع الدالة هو المعطيات المُمرَّرة لها وأنواع البيانات التي تُرجعها هذه الدالة) ويمكن تمريرها في معطى إلى الدالة HandleFunc التي تسجّل الدالة المُمرَّرة في المُعطى لدى الموجِّه ServeMux، وبالتالي يُنشئ خادوم الوِب، في كلّ مرة يصله فيها طلب جديد يطابق المسار الجذر، يُنشئ نسخة جديدة من الدالة hello. تستقبل الدالة hello متغيّرا من النوع http.ResponseWriter الذي تستخدمه الدالة المُعالِجة لإنشاء إجابة HTTP وبالتالي إنشاء ردّ على طلب العميل عن طريق التابع Write الذي يوفّره النوع http.ResponseWriter. بما أن التابع http.ResponseWriter.Write يأخذ معطى عامًّا من النوع []byte أو byte-slice، فنحوّل السلسة النصيّة hello إلى النوع المناسب: func hello(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello!")) } في الأخير نشغّل خادوم وب على المنفذ 8080 عبر الدالة http.ListenAndServe التي تستقبل معطيَين، الأول هو المنفَذ Port والثاني دالة معالجة. إذا كانت قيمة المعطى الثاني هي nil فهذا يعني أننا نريد استخدام الموجّه المبدئي DefaultServeMux. هذا الاستدعاء متزامن Synchronous، أو معترِض Blocking، يبقي البرنامج قيد التشغيل إلى أن يُقطَع الاستدعاء. صرّف وشغّل البرنامج بنفس الطريقة السابقة: go build ./hello افتح سطر أوامر - طرفيّة - آخر وأرسل طلب HTTP إلى المنفذ 8080: curl http://localhost:8080 النتيجة: hello! الأمر بسيط. ليست هناك حاجة لتثبيت إطار عمل خارجي، أو تنزيل اعتماديات Dependencies أو إنشاء هياكل مشاريع. الملف التنفيذي نفسه هو شفرة أصيلة Native code بدون اعتمادات تشغيلية. علاوة على ذلك، المكتبة المعيارية لخادوم الوِب موجهة لبيئة الإنتاج مع دفاعات ضدّ الهجمات الإلكترونية الشائعة. يمكن لهذه الشفرة الإجابة على الطلبات عبر الشبكة مباشرة ودون وسائط. إضافة مسارات جديدة يمكننا فعل أمور أكثر أهمية من مجرّد قول مرحبا (hello). فليكن المُدخَل اسم مدينة نستخدمه لاستدعاء واجهة تطبيقات برمجيّة API لأحوال الطقس ونعيد توجيه الإجابة - درجة الحرارة - في الرد على الطلب. توفّر خدمة OpenWeatherMap واجهة تطبيقات برمجيّة مجانيّة وسهلة الاستخدام للحصول على توقّعات مناخية. سجّل في الموقع للحصول على مفتاح API. يمكن الاستعلام من OpenWeatherMap حسب المدن. تُرجع واجهة التطبيقات البرمجية إجابة على النحو التالي (عدّلنا قليلا على النتيجة): { "name": "Tokyo", "coord": { "lon": 139.69, "lat": 35.69 }, "weather": [ { "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" } ], "main": { "temp": 296.69, "pressure": 1014, "humidity": 83, "temp_min": 295.37, "temp_max": 298.15 } } المتغيّرات في Go ذات أنواع ثابتة Statical type، بمعنى أنه ينبغي التصريح بنوع البيانات التي تخزّنها المتغيّرات قبل استخدامها. لذا سيتوجّب علينا إنشاء بنية بيانات لمطابقة صيغة رد الواجهة البرمجية. لا نحتاج لحفظ جميبع المعلومات، بل يكفي أن نحتفظ بالبيانات التي نهتم بشأنها. سنكتفي الآن باسم المدينة ودرجة الحرارة المتوقّعة التي تأتي بوحدة الكيلفن Kelvin. سنعرّف بنية لتمثيل البيانات التي نحتاجها من خدمة التوقعات المناخية. type weatherData struct { Name string `json:"name"` Main struct { Kelvin float64 `json:"temp"` } `json:"main"` } تعرّف الكلمة المفتاحية type بنية بيانات جديدة نسمّيها weatherData ونصرّح بكونها من النوع struct. يحوي كلّ حقل في المتغيّرات من نوع struct اسما (مثلا Name أو Main)، نوع بيانات (string أو struct آخر مجهول الاسم) وما يُعرَف بالوسم Tag. تشبه الوسوم في Go البيانات الوصفية Metadata، وتمكّننا من استخدام الحزمة encoding/json لإعادة صفّ الإجابة التي تقدّمها خدمة OpenWeatherMap وحفظها في بنية البيانات التي أعددناها. يتطلّب الأمر كتابة شفرة برمجية أكثر ممّا عليه الحال في لغات برمجيّة ذات أنواع ديناميكية للبيانات (بمعنى أنه يمكن استخدام متغيّر فور احتياجنا له دون الحاجة للتصريح بنوع البيانات) مثل روبي وبايثون، إلا أنه يمنحنا خاصيّة الأمان في نوع البيانات. عرّفنا بنية البيانات، نحتاج الآن لطريقة تمكّننا من ملْء هذه البنية بالبيانات القادمة من واجهة التطبيقات البرمجية؛ سنكتُب دالة لهذا الغرض. func query(city string) (weatherData, error) { resp, err := http.Get("http://api.openweathermap.org/data/2.5/weather?APPID=YOUR_API_KEY&q=" + city) if err != nil { return weatherData{}, err } defer resp.Body.Close() var d weatherData if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { return weatherData{}, err } return d, nil } تأخذ الدالة سلسلة محارف تمثّل المدينة وتُرجِع متغيّرا من بنية بيانات weatherData وخطأ. هذه هي الطريقة الأساسية للتعامل مع الأخطاء في Go. تغلّف الدوال سلوكا معيَّنا، ويمكن أن يخفق هذا السلوك. بالنسبة لمثالنا، يمكن أن يخفق طلب GET الذي نرسله لـOpenWeatherMap لأسباب عدّة، وقد تكون البيانات المُرجَعة غير تلك التي ننتظرها. نُرجِع في كلتا الحالتين خطأ غير فارغ Non-nil للعميل الذي يُنتظَر منه أن يتعامل مع هذا الخطأ بما يتناسب مع السياق الذي أرسل فيه الطلب. إن نجح الطلب http.Get نؤجّل طلبا لغلق متن الإجابة لننفّذه بعد الخروج من نطاق Scope الدالة (أي بعد الرجوع من دالة طلب HTTP)، وهي طريقة أنيقة لإدارة الموارد. في أثناء ذلك نحجز بنية weatherData ونستخدم json.Decoder لقراءة بيانات الإجابة وإدخالها مباشرة في بنيتنا. عندما تنجح إعادة صياغة بيانات الإجابة نعيد المتغيّر weatherData إلى المُستدعي مع خطأ فارغ للدلالة على نجاح العملية. ننتقل الآن إلى ربط تلك الدالة بالدالة المعالجة للطلب: http.HandleFunc("/weather/", func(w http.ResponseWriter, r *http.Request) { city := strings.SplitN(r.URL.Path, "/", 3)[2] data, err := query(city) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(data) }) نعرّف دالة معالجة على السطر In-line بدلا من تعريفها منفصلة. نستخدم الدالة strings.SplitN لأخذ كل ما يوجد بعد /weather/ في المسار والتعامل معه على أنه اسم مدينة. ننفّذ الطلب وإن صادفتنا أخطاء نعلم العميل بها باستخدام الدالة المساعدة http.Error، ونوقف تنفيذ الدالة للدلالة على اكتمال طلب HTTP. إن لم يوجد خطأ نخبر العميل بأننا بصدد إرسال بيانات JSON إليه ونستخدم الدالة json.NewEncode لترميز محتوى weatherData بصيغة JSON مباشرة. الشفرة لحدّ الساعة أنيقة، تعتمد أسلوبا إجرائيا Procedural ويسهل فهمها. لا مجال للخطأ في تفسيرها ولا يمكنها تجاوز الأخطاء الشائعة. إن نقلنا الدالة المعالجة لـ "hello, world" إلى المسار hello/ واستوردنا الحزم المطلوبة فسنحصُل على البرنامج المُكتمل التالي: package main import ( "encoding/json" "net/http" "strings" ) func main() { http.HandleFunc("/hello", hello) http.HandleFunc("/weather/", func(w http.ResponseWriter, r *http.Request) { city := strings.SplitN(r.URL.Path, "/", 3)[2] data, err := query(city) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(data) }) http.ListenAndServe(":8080", nil) } func hello(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello!")) } func query(city string) (weatherData, error) { resp, err := http.Get("http://api.openweathermap.org/data/2.5/weather?APPID=YOUR_API_KEY&q=" + city) if err != nil { return weatherData{}, err } defer resp.Body.Close() var d weatherData if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { return weatherData{}, err } return d, nil } type weatherData struct { Name string `json:"name"` Main struct { Kelvin float64 `json:"temp"` } `json:"main"` } نصرّف البرنامج وننفّذه بنفس الطريقة التي شرحناها أعلاه: go build ./hello نفتح طرفيّة أخرى ونطلب المسار http://localhost:8080/weather/tokyo (الحرارة بمقياس كلفن): curl http://localhost:8080/weather/tokyo النتيجة: {"name":"Tokyo","main":{"temp":295.9}} الاستعلام من واجهات برمجية عدّة ربما من الممكن الحصول على درجات حرارة أكثر دقّة إن استعلمنا من خدمات طقس عدّة وحسبنا المتوسّط بينها. تتطلّب أغلب الواجهات البرمجية لخدمات الطقس التسجيل. سنضيف خدمة Weather Underground، لذا سجّل في هذه الخدمة واعثر على مفاتيح الاستيثاق الضرورية لاستخدام واجهة التطبيقات البرمجية. بما أننا نريد أن نحصُل على نفس السلوك من جميع الخدمات فسيكون من المجدي كتابة هذا السلوك في واجهة. type weatherProvider interface { temperature(city string) (float64, error) // in Kelvin, naturally } يمكننا الآن تحويل دالة الاستعلام من openWeatherMap السابقة إلى نوع بيانات يوافق الواجهة weatherProvider. بما أننا لا نحتاج لحفظ أي حالة لإجراء طلب HTTP GET فسنستخدم بنية struct فارغة، وسنضيف سطرا قصيرا في دالة الاستعلام الجديدة لتسجيل ما يحدُث عند الاتصال بالخدمات لمراجعته في ما بعد: type openWeatherMap struct{} func (w openWeatherMap) temperature(city string) (float64, error) { resp, err := http.Get("http://api.openweathermap.org/data/2.5/weather?APPID=YOUR_API_KEY&q=" + city) if err != nil { return 0, err } defer resp.Body.Close() var d struct { Main struct { Kelvin float64 `json:"temp"` } `json:"main"` } if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { return 0, err } log.Printf("openWeatherMap: %s: %.2f", city, d.Main.Kelvin) return d.Main.Kelvin, nil } لا نريد سوى استخراج درجة الحرارة (بالكلفن) من الإجابة، لذا يمكننا تعريف بنية struct على السطر Inline. في ما عدا ذلك فإن الشفرة البرمجية مشابهة لدالة الاستعلام السابقة، ولكنّها معرَّفة على صيغة تابع Method لبنية openWeatherMap. تتيح لنا هذه الطريقة استخدام عيّنة Instance من openWeatherMap مكان الواجهة weatherProvider. سنفعل نفس الشيء بالنسبة لخدمة Weather Underground. الفرق الوحيد مع الخدمة السابقة هو أننا سنخزّن مفتاح الواجهة البرمجية في بنية struct ثم نستخدمه في التابع. يجدر ملاحظة أن Weather Underground لا تعالج أسماء المدن المتطابقة بنفس جودة تعامل Open WeatherMap، وهو ما ينبغي الانتباه إليه في التطبيقات الفعلية. لن نعالج هذا الأمر في مثالنا البسيط هذا. type weatherUnderground struct { apiKey string } func (w weatherUnderground) temperature(city string) (float64, error) { resp, err := http.Get("http://api.wunderground.com/api/" + w.apiKey + "/conditions/q/" + city + ".json") if err != nil { return 0, err } defer resp.Body.Close() var d struct { Observation struct { Celsius float64 `json:"temp_c"` } `json:"current_observation"` } if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { return 0, err } kelvin := d.Observation.Celsius + 273.15 log.Printf("weatherUnderground: %s: %.2f", city, kelvin) return kelvin, nil } لدينا الآن مزوّدا خدمة طقس. فلنكتب دالّة تستعلم من الاثنين وتعيد متوسّط درجة الحرارة. سنكفّ - حفاظا على بساطة المثال - عن الاستعلام إذا واجهتنا مشكلة في الحصول على بيانات من الخدمتين. func temperature(city string, providers ...weatherProvider) (float64, error) { sum := 0.0 for _, provider := range providers { k, err := provider.temperature(city) if err != nil { return 0, err } sum += k } return sum / float64(len(providers)), nil } لاحظ أن تعريف الدالة قريب جدّا من تعريف التابع temperature المُعرَّف في الواجهة weatherProvider. إن جمعنا الواجهات weatherProvider في نوع بيانات ثم عرّفنا تابعا باسم temperature على هذا النوع فسيمكننا إنشاء نوع جديد يجمع الواجهات weatherProvider. type multiWeatherProvider []weatherProvider func (w multiWeatherProvider) temperature(city string) (float64, error) { sum := 0.0 for _, provider := range w { k, err := provider.temperature(city) if err != nil { return 0, err } sum += k } return sum / float64(len(w)), nil } رائع! سنتمكّن من تمرير multiWeatherProvider إلى أي دالة تقبل weatherProvider. نربُط الآن خادوم HTTP بدالة temperature للحصول على درجات الحرارة عند طلب مسار به اسم مدينة: func main() { mw := multiWeatherProvider{ openWeatherMap{}, weatherUnderground{apiKey: "your-key-here"}, } http.HandleFunc("/weather/", func(w http.ResponseWriter, r *http.Request) { begin := time.Now() city := strings.SplitN(r.URL.Path, "/", 3)[2] temp, err := mw.temperature(city) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(map[string]interface{}{ "city": city, "temp": temp, "took": time.Since(begin).String(), }) }) http.ListenAndServe(":8080", nil) } صرّف البرنامج، شغّله واطلب رابط خادوم الوب كما فعلنا سابقا. ستجد - علاوة على الإجابة بصيغة JSON في نافذة الطلب - مُخرجات قادمة من تسجيلات الخادوم التي أضفناها أعلاه في النافذة التي شغّلت منها البرنامج. ./hello 2015/01/01 13:14:15 openWeatherMap: tokyo: 295.46 2015/01/01 13:14:16 weatherUnderground: tokyo: 273.15 $ curl http://localhost:8080/weather/tokyo {"city":"tokyo","temp":284.30499999999995,"took":"821.665230ms"} جعل الاستعلامات تعمل بالتوازي نكتفي لحدّ الساعة بالاستعلام من الواجهات البرمجية بالتتالي، الواحدة تلو الأخرى. لا يوجد ما يمنعنا من الاستعلام من الواجهتيْن البرمجيّتين في نفس الوقت، وهو ما سياسهم في تقليل الوقت اللازم للإجابة. نستفيد من إمكانات Go في التشغيل المتزامن عبر وحدات Go الفرعية goroutines والقنوات Channels. سنضع كل استعلام في وحدة فرعية خاصّة به ثم نشغّلها بالتوازي. نجمع الإجابات بعد ذلك في قناة واحدة ثم نحسب المعدّلات عندما تكتمل جميع الاستعلامات. func (w multiWeatherProvider) temperature(city string) (float64, error) { // ننشئ قناتيْن، واحدة لدرجات الحرارة والأخرى للأخطاء // يُضيف كل مزوّد خدمة قيمة إلى إحدى القناتيْن فقط temps := make(chan float64, len(w)) errs := make(chan error, len(w)) // نطلق بالنسبة لكلّ مزوّد خدمة وحدة فرعية جديدة بدالة مجهولة الاسم. تستدعي الدالة مجهولة الاسم التابع temperature ثم تعيد توجيه النتيجة المتحصًّل عليها. for _, provider := range w { go func(p weatherProvider) { k, err := p.temperature(city) if err != nil { errs <- err return } temps <- k }(provider) } sum := 0.0 // نجمع درجات الحرارة - أو الأخطاء في حالة وجودها - من كل خِدمة for i := 0; i < len(w); i++ { select { case temp := <-temps: sum += temp case err := <-errs: return 0, err } } // نُرجع الحرارة كما في السابق return sum / float64(len(w)), nil } يساوي الوقت اللازم الآن لتنفيذ جميع الاستعلامات المدة الزمنية اللازمة للحصول على إجابة من أبطأ خدمة طقس؛ بدلا من مجموع المدد الزمنية لجميع الاستعلامات كما كان سابقا. كل ما احتجنا له هو تعديل سلوك multiWeatherProvider الذي ما زال مع ذلك يُرضي حاجات واجهة weatherProvider البسيطة وغير المتوازية. السهولة انتقلنا ببضع خطوات وبالاقتصار فقط على المكتبة المعيارية لـGo من مثال “hello world” إلى خادوم سند خلفي Backend server يحترم مبادئ REST. يمكن نشر الشفرة التي كتبناها على أي معمارية خواديم تقريبا. الملف التنفيذي الناتج سريع ويتضمّن جميع ما يحتاجه للعمل؛ والأهم من ذلك، الشفرة البرمجيّة واضحة لقراءتها وفهمها. كما تمكن صيانتها وتمديدها بسهولة حسب الحاجة. أنا مقتنع أن كلّ هذه الميزات هي نتيجة لتفاني Go في البساطة. ترجمة - بتصرّف - للمقال How I start Go لصاحبه Peter Bourgon.
-
أصبحت واجهات REST البرمجية أكثر انتشارًا بين المطوّرين، نظرًا لأنها توفّر واجهة بسيطة، موحّدة وواضحة لخدمات الطرف الثالث third-party مثل Twitter، MailChimp وGitHub. ومع ازدياد شهرة واجهة ووردبريس البرمجية (المتوفرة عبر إضافة) فإن الوقت قد حان لنتعلم حول واجهة HTTP البرمجية في ووردبريس، وكيفية عملها واستخداماتها. ما هي واجهة HTTP في ووردبريس؟ لن يكون من المستغرب القول بأنها طريقة لإرسال واستقبال الرسائل بواسطة HTTP – لغة شبكة الويب. حيث يقوم المتصفح بإرسال واستقبال الرسائل طوال الوقت وبهذا الشكل يتم استقبال صفحات الويب. ويمكن من خلال واجهة REST البرمجية، يمكن باستخدام رسائل HTTP القيام بأشياء أكثر كتعديل منشور، حذف حساب مستخدم أو نشر وصفة جديدة على موقعك. لهذا السبب تعتبر واجهة ووردبريس البرمجية مهمة جدًا، فهي تسمح بفصل التطبيق من جهة المستخدم عن النواة البرمجية الخاصة بووردبريس. ولاستخدامها، تحتاج أن تكون معتادًا على إرسال طلبات HTTP واستقبال الأجوبة، وهذا هو أساس عمل واجهة HTTP البرمجية. وهناك العديد من الطرق لإرسال طلبات HTTP، حيث توفّر واجهة HTTP البرمجية طريقة موحّدة باستخدام مجموعة من التوابع المساعدة والتي سنطّلع عليها بعد قليل. طرق HTTP ومصادرها ترتكز HTTP حول الطرق methods (تسمّى أحيانًا بالأفعال verbs) والمصادر resources. تحدّد المصادر العنصر الذي سيتم تنفيذ العملية عليه، وتحدّد الطريقة نوع العملية التي سيتم تنفيذها. ويعتبر العنوان URL هو المصدر الذي يشير إلى الغرض على شبكة الويب، كمنشور على سبيل المثال. وتوجد العديد من الطرق، لكن الأهم فيها هي GET, POST, PUT و DELETE. ولا بد أن لديك الكثير من الخبرة مع GET فهذه الطريقة هي المستخدمة في الحصول على المصادر، فمثلًا عند استعراض مقال في المتصفح يتم إرسال طلب GET إلى عنوان المقال وليكن مثلًا https://premium.wpmudev.org/blog/using-the-wordpress-http-api/. أما طلبات PUT فتستخدم لتعديل المصادر، وتستخدم طلبات POST لإنشاء مصادر جديدة، وتستخدم طلبات DELETE لحذف مصادر موجودة مسبقًا. ولو كان لدى موقع WPMU DEV واجهة REST برمجية، فربما كان من الممكن أن يقوم المدير بإرسال طلب DELETE لحذف المقال على الرابط https://premium.wpmudev.org/blog/wordpress-http-api، وهذا الأمر مفيد جدًا للمواقع الكبيرة التي تملك برامج إدارة على الهواتف الذكية. مثال عن طلب HTTP بسيط للقيام بإرسال طلب GET بسيط على سبيل التجربة، سنقوم باستخدام التابع wp_remote_get() الذي يملك مُعاملين، المُعامل الأول هو رابط المصدر، والمُعامل الثاني هو مصفوفة اختيارية optional من الخيارات التي من الممكن استخدامها لتحديد بعض التفاصيل. $test = wp_remote_get( 'http://google.com' ); echo "<pre>"; var_dump($test); echo "</pre>"; يقوم المثال السابق بجلب الصفحة الرئيسية لموقع جوجل، ومن ثم يتم طباعة محتوى المتغير test الذي يحوي على جواب الطلب الذي أرسله موقع جوجل – حيث يمكن رؤية جميع العناصر التي يحتويها. تحتوي الترويسات headers على المزيد من المعلومات عن كل رسالة. وقد تطلب منك بعض واجهات REST البرمجية أن تقوم بإرسال معلومات محددة في الترويسات عندما ترسل الطلب. يحتوي الجواب response على رمز الحالة status code وعبارة قد تكون معروفة بالنسبة لك كأخطاء 404، أو خطأ في معالجة الطلب 500 أو تحويل من النوع 301 أو 302. يحتوي موقع W3.org على جميع رموز أخطاء HTTP المعرّفة والمشروحة، ويعتبر مصدرًا مفيدًا إن احتجت أن تعرف ما يعني أي خطأ. بإمكانك أيضا الاطّلاع على هذا المقال على أكاديمية حسوب لتعرف المزيد حول هذه الرّموز: رموز الإجابة في HTTP يحتوي الجسم body على الجواب وهو المكان الذي تحتاج أن تنظر إليه بحثًا عن النتيجة المطلوبة. في حالة المثال السابق، فإننا نحصل على وسوم HTML التي تشكّل الصفحة الرئيسية، ولكن عند التعامل مع واجهات REST البرمجية فمن الشائع أن نحصل على نص بصيغة JSON. وعادة ما تطلب الواجهات البرمجية APIs أن يتم إضافة نص محدّد إلى الجسم عند إرسال طلبات أيضًا. يحتوي قسم cookies على أي كعكات تم استلامها مع الرسالة. كما ترى، فإن إرسال طلب باستخدام واجهة HTTP البرمجية أمر بسيط جدًا. ما يجعل العمل مع HTTP معقّد نوعًا ما هو أن واجهات REST البرمجية قد تكون حساسة جدًا لصيغة البيانات المدخلة (وهو أمر جيّد) لذا فإن تجاوزت سطرًا عند دراسة توثيق الواجهة البرمجية قد ينتهي بك الأمر ببرمجية لا تعمل كما ترغب. العمل مع الواجهات البرمجية أعتقد بأنه من الآمن القول بأن معظمكم سيستخدم HTTP للتعامل مع واجهات REST البرمجية في شبكة الويب، وفي هذه الحالة سنحتاج لاستخدام المعامل الثاني لتحديد بعض الأمور، كالاستيثاق وتجنب بعض الأخطاء الشائعة. لنبدأ بمثال بسيط – استعادة البيانات من لوحة Pinterest. تتطلب جميع الواجهات البرمجية الجيّدة تنفيذ عملية استيثاق للتعريف عن المستخدم الذي يرسل الطلب، لكنّنا سنغش قليلًا في هذا المثال باستخدام مولّد رموز الأمان الخاص بـ Pinterest. بعد إكمال الاستيثاق ستحصل على رمز أمان يمكن استخدامه في المثال التالي، حيث سنقوم بإنشاء طلب لجلب وعرض قائمة بمنشورات Pinterest. $request = wp_remote_get( 'https://api.pinterest.com/v1/boards/marticz/home-office/pins/?access_token=<your access token>' ); $pins = json_decode( $request['body'], true ); if( !empty( $pins['data'] ) ) { echo '<ul>'; foreach( $pins['data'] as $pin ) { echo '<li><a href="' . $pin['url'] . '">' . $pin['note']. '</a></li>'; } echo '</ul>'; } لو قمنا بنسخ المثال السابق ولصقه في صفحة content-page.php في قالب Twenty Fifteen لتجربته فإن النتيجة هو الحصول على لائحة بالمنشورات من لوحة Pinterest حول المكاتب الشخصية في المنزل. لا تنس أن تستبدل <your access token> في المثال السابق برمز الأمان الذي حصلت عليه. يقوم السطر الثاني في المثال بفك ترميز جسم الجواب من JSON إلى شكله الأساسي كمصفوفة ونقوم بإنشاء حلقة loop تقوم بطباعة محتوى عنصر المصفوفة $pins['data']. الاستيثاق يقع العديد من الأشخاص في عثرات في هذه المرحلة لأنها تتطلب خطوة إضافية على الأقل وربما بعض الترويسات الإضافية أيضًا. ولننظر إلى واجهة تويتر البرمجية على سبيل المثال، وتحديدًا إلى الاستيثاق الخاص بالتطبيقات، الذي يمكنك استخدامه لاستيثاق تطبيقك مع تويتر. قراءة التوثيق إن أول خطأ قد يتم ارتكابه هو عدم قراءة التوثيق بشكل جيّد. ولو كنت مبرمجًا متمرسًا في واجهات REST البرمجية فيمكنك القفز مباشرة إلى الجزء حول الاستيثاق، ولو فعلت هذا فقد لا تنتبه إلى سطر يقول: وإهمال هذا الشرط سيؤدي إلى فشل حتمًا في الحصول على نتيجة، على الرغم من أن كل شيء آخر مطبّق بشكل كامل، لذا حتى توفّر على نفسك الحاجة لمراجعة شفرة برنامجك، تأكد من قراءة التوثيق بشكل جيّد وكامل. إضافة ترويسات ومعاملات أخرى بعد اتباع التوجيهات في التوثيق حرفيًّا، قمت بإنشاء طلب POST، ينبغي أن يؤدي إلى توليد رمز أمان للوصول من أجلي، ويبدو الكود على الشكل: $key = base64_encode( urlencode( "n8KP16uvGZA6xvFTtb8IAA:i4pmOV0duXJv7TyF5IvyFdh5wDIqfJOovKjs92ei878" ) ); $request = wp_remote_post('https://api.twitter.com/oauth2/token', array( 'headers' => array( 'Authorization' => 'Basic ' . $key, 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8' ), 'body' => 'grant_type=client_credentials', 'httpversion' => '1.1' )); $token = json_decode( $request['body'] ); echo "<pre>"; var_dump($token); echo "</pre>"; إن الخطوة الأولى هو ترميز رمز أمان الوصول والكلمة السرّية بصيغة URL encoding. قمت أيضًا بإضافة ترويستين، واحدة ترويسة استيثاق تحوي على بيانات الوصول. أما الترويسة الثانية فهي ترويسة نوع المحتوى content type، والتي يطلب توثيق تويتر إضافتها. إضافة لما سبق، قمت بملء جسم الطلب تمامًا كما ذكرت الملاحظة السابقة حول grant_type=client_credentials وتم إضافة إصدار HTTP كما يطلب توثيق تويتر أيضًا. سيحتوي الجواب بالإضافة للعديد من المعلومات الأخرى على رمز أمان الوصول في جسم الجواب، وسنحتاج لرمز الأمان هذا في جميع الطلبات اللاحقة المرسلة إلى الواجهة البرمجية الخاصة بتويتر. تخزين رمز أمان الوصول إن رمز الأمان صالح لبعض الوقت، ويعتبر طلبه مجدّدًا هدرًا غير ضروري عند تحميل كل صفحة أو عندما يحتاج تطبيقك أن يقوم بعملية ما وسيؤدي إلى استهلاك عدد الطلبات المسموح بسرعة. يمكن في ووردبريس استخدام عابرة transient لتخزين قيمة رمز الأمان ومن ثم استخدام العابرة عند كل طلب لاحق للواجهة البرمجية. $token = get_transient( 'twitter_access_token' ); $token = ( empty( $token ) ) ? get_twitter_access_token() : $token; $request = wp_remote_get('https://api.twitter.com/1.1/followers/ids.json?screen_name=danielpataki&count=5', array( 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8' ), 'httpversion' => '1.1' )); $token = json_decode( $request['body'] ); سيؤدي إرسال الطلب السابق إلى واجهة تويتر البرمجية إلى عرض 5 من متابعيّ على تويتر (قائمة بسيطة لأرقام حساباتهم Ids). وكما يظهر في المثال السابق، فإنني أقوم بجلب القيمة التي خزّنتها في العابرة فإن لم تكن موجودة سأقوم باستدعاء التابع get_twitter_access_token() للحصول على رمز أمان جديد ومن ثم إضافته إلى العابرة كي أستخدمه في الطلبات اللاحقة. تجدر الإشارة إلى أن هذه الطريقة تهدف فقط إلى إظهار آلية التنفيذ وليست أفضل طريقة للتنفيذ، وكسيناريو أبسط كنت لأقوم بوضع جميع العبارات الشرطية IFs داخل التابع get_twitter_access_token() الذي سيقوم بتنفيذ جميع الشيفرة السابقة ضمنيًا. التوابع المساعدة في واجهة HTTP البرمجية الآن وبعد أن حصلنا على لمحة جيدة حول هدف المقال، دعونا نلق نظرة على التوابع التي ستساعدك في واجهة HTTP البرمجية في ووردبريس. هناك 4 توابع يمكن بواسطتها تنفيذ طلبات: wp_remote_get() wp_remote_post() wp_remote_head() wp_remote_request() ومن الواضح تمامًا وظيفة كل تابع، أما التابع الأخير wp_remote_request() فهو تابع عام يمكنك استخدامه مع أي طريقة HTTP method. أما التوابع الخمسة التالية فتسمح لك بالحصول على جواب على الطلب بسهولة باستخدام توابع قياسية عوضًا عن الخوض في التعامل مع المصفوفات وعناصرها. wp_remote_retrieve_body() wp_remote_retrieve_header() wp_remote_retrieve_headers() wp_remote_retrieve_response_code() wp_remote_retrieve_response_message() أيضًا يبين اسم كل تابع الهدف الوظيفي له بسهولة، وينصح عند الإمكان باستخدام هذه التوابع عوضًا عن التعامل مع المصفوفات يدويًا، حيث ستسمح هذه الطريقة الموحّدة لمطوّرين آخرين بفهم البرمجية بسهولة واستخدام الخطافات hooks إن أصبحت متاحة في المستقبل. الخلاصة كما ترى فإن التعامل مع واجهة REST البرمجية سهل باستخدام واجهة HTTP البرمجية في ووردبريس وبعض توابع ووردبريس كالعبّارات. أنصح بشدّة تجربة هذا الأمر لأن عملية التطوير في ووردبريس تسير بثقة باتجاه العالم المُقاد باستخدام الواجهات البرمجية وعليك أن تقفز إلى الموكب قبل فوات الأوان. إن كان لديك المزيد من الأسئلة حول استخدام واجهة HTTP البرمجية أو لديك بعض الأفكار حول كيفية استخدامها، فلا تتردد بمناقشة الأمر في قسم التعليقات في الأسفل. ترجمة -وبتصرّف- للمقال How to Use the WordPress HTTP API لصاحبه Daniel Pataki.