اذهب إلى المحتوى

حفظ بيانات تطبيقات Node.js في قاعدة بيانات MongoDB


ابراهيم الخضور

قبل أن ننتقل إلى الموضوع الرئيسي في هذا الفصل، سنلقي نظرة على بعض الطرق المتبعة في تنقيح تطبيقات Node.

تنقيح تطبيقات Node

إن تنقيح التطبيقات المبنية باستخدام Node أصعب قليلًا من تنقيح شيفرة JavaScript التي تنفذ على المتصفح. لكن مع ذلك تبقى فكرة الطباعة على الطرفية وأسلوب المحاولة والخطأ طريقة فعالة في حل المشاكل. قد تجد من المطورين من يعتقد أن استخدام أساليب أكثر تطورًا هو أمر ضروري، لكن تذكر أن نخبة مطوري البرمجيات مفتوحة المصدر في العالم يستخدمون تلك الطريقة.

برنامج Visual Studio Code

ستجد أن المنقح المدمج ضمن هذا البرنامج ذو فائدة كبيرة في بعض الحالات. يمكنك أن تشغل البرنامج على وضع التنقيح كالتالي:

vs_debug_mode_001.png

ملاحظة: يمكن أن تجد التعليمة Run بدلًا من Debug في الإصدار الأحدث من Visual Studio Code. وقد يكون عليك أيضًا أن تهيئ ملف launch.json لتبدأ التنقيح. يمكن تنفيذ ذلك باختيار الأمر ...Add Configuration من القائمة المنسدلة الموجودة بجانب زر التشغيل الأخضر وفوق قائمة VARIABLES، ثم اختيار الأمر Run "npm start" in a debug terminal. يمكنك إيجاد المزيد من الإرشادات بالاطلاع على توثيق التنقيح لبرنامج Visual Studio Code.

تذكر أن لا تشغل البرنامج ضمن أكثر من طرفية لأن ذلك سيحجز المنفذ مسبقًا ولن تتمكن من العمل. تعرض لقطة الشاشة التالية الطرفية وقد أوقفنا التنفيذ مؤقتًا في منتصف عملية حفظ الملاحظة الجديدة:

vs_paused_002.png

توقَّف التنفيذ عندما وصلنا إلى نقطة التوقف التي وضعناها في السطر 63. يمكنك أن ترى في الطرفية قيمة المتغير note. وفي أعلى يسار النافذة ستجد بعض التفاصيل المتعلقة بحالة التطبيق. تُستخدم الأسهم في الأعلى للتحكم بترتيب عملية التنقيح.

أدوات تطوير Chrome

من الممكن أن نستخدم أدوات تطوير Chrome لتنقيح تطبيقات Node، وذلك بتشغيل التطبيق مستخدمين الأمر التالي:

node --inspect index.js

ستدخل إلى المنقح بالضغط على الأيقونة الخضراء (شعار Node) التي تظهر على طرفية تطوير Chrome:

chrome_node_debug_003.png

يعمل المنقح بنفس الطريقة التي يعمل بها عند تنقيح تطبيقات React. يمكنك استخدام النافذة Sources لزرع نقاط توقف في الشيفرة لإيقاف التنفيذ بشكل مؤقت عندها.

chrome_debug_sources_004.png

ستظهر جميع الرسائل التي يطبعها الأمر console.log في النافذة Console من المنقح. كما يمكنك التحري عن قيم المتغيرات وتنفيذ شيفرة JavaScript إن أردت.

chrome_debug_console_005.png

تحقق من كل شيء

قد يبدو لك تنقيح تطبيقات التطوير الشامل (واجهة خلفية وأمامية) محيّرًا في البداية. وقريبًا سيتواصل التطبيق مع قاعدة بيانات. وبالتالي سيزداد احتمال ظهور الأخطاء في أجزاء عدة من الشيفرة.

عندما يتوقف التطبيق عن العمل، يجب علينا أولًا تصور المكان الذي قد تظهر فيه المشكلة. وعادة تظهر المشاكل في الأماكن غير المتوقعة وقد يستغرق إيجادها دقائق أو ساعات أو حتى أيامًا. مفتاح الحل هو تنظيم البحث. فالمشكلة قد تتواجد في أي مكان، لذلك تحقق من كل شيء واستبعد احتمالات المشكلة واحدًا تلو الآخر. ستساعدك الطباعة على شاشة الطرفية وكذلك برنامج Postman والمنقحات الأخرى، كما ستلعب الخبرة دورًا هامًا أيضًا.

لا تتابع تطوير التطبيق إن لم تعثر على مصدر الخطأ، فهذه أسوأ استراتيجية. لأن ذلك سيسبب أخطاء أكثر وسيكون التنقيح أصعب. اتبع سياسة شركة Toyota لإنتاج الأنظمة (توقف وأصلح) فهي بالفعل سياسة مجدية جدًا في حالتنا.

قاعدة البيانات MongoDB

سنحتاج قطعًا إلى قاعدة بيانات لحفظ الملاحظات بشكل دائم. تتعامل معظم مناهج جامعة هلسينكي مع قواعد البيانات العِلاقيّة (Relational Databases)، لكننا سنتعامل في منهاجنا مع قاعدة البيانات MongoDB وهي من نمط قواعد البيانات المستقلة.

تختلف قواعد البيانات المستقلة (أو التي تأتي على شكل مستندات منفصلة) عن العِلاقيّة في كيفية تنظيم البيانات ولغة الاستعلام التي تدعمها. وعادة ما تصنف قواعدة البيانات المستقلة تحت مظلة NoSQL أي التي لا تستخدم لغة الاستعلام SQL.

اطلع على الفصلين collections و documents من دليل استخدام MongoDB لتتعلم أساسيات تخزين البيانات في قواعد البيانات المستقلة.

يمكنك أن تُثبّت وتُشغّل MongoDB على حاسوبك. كما يمكنك الاستفادة من المواقع التي تقدم خدمات MongoDB على الإنترنت. سنتعامل في منهاجنا مع مزود الخدمة MongoDB Atlas.

حالما تنشئ حسابًا على الموقع وتسجل الدخول، سينصحك الموقع بإنشاء عنقود:

mongo_atlas_006.png

سنختار المزود AWS والمنطقة Frankfurt ثم ننشئ العنقود:

mongo_atlas_provider_007.png

انتظر حتى يكتمل العنقود ويصبح جاهزًا. قد يستغرق ذلك 10 دقائق.

ملاحظة: لا تتابع قبل أن يصبح العنقود جاهزًا.

سنستخدم نافذة database access لإنشاء معلومات التوثيق اللازمة لدخول قاعدة البيانات. وانتبه إلى أنها معلومات توثيق مختلفة عن تلك التي تستخدمها عند تسجيل الدخول، وسيستخدمها تطبيقك عندما يتصل بقاعدة البيانات.

mongo_cred_008.png

لنمنح المستخدم إمكانية القراءة والكتابة إلى قاعدة البيانات:

mongo_permission_009.png

ملاحظة: أبلغَ بعض المستخدمين عن عدم القدرة على الوصول إلى قاعدة البيانات بمعلومات التوثيق التي وضعوها بعد إنشاء القاعدة مباشرة. تريّث، فقد يستغرق الأمر دقائق حتى تفعّل هذه المعلومات.

سنعرّف بعد ذلك عناوين IP التي يسمح لها بدخول قاعدة البيانات.

mongo_ip_010.png

ولتسهيل الأمر، سنسمح بالوصول إلى القاعدة من أي عنوان IP:

mongo_all_ip_011.png

أخيرًا أصبحنا جاهزين للاتصال بقاعدة البيانات، إبدأ بالنقر على connect:

mongo_connect_012.png

اختر Connect your application:

mongo_connect_app_013.png

سيظهر لك عنوان موقع MongoDB، وهو عنوان قاعدة البيانات التي أنشأناها والتي تزود مكتبة عميل MongoDB التي سنضيفها إلى تطبيقنا بالبيانات. يبدو العنوان كالتالي:

mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/test?retryWrites=true

نحن الآن جاهزين لاستخدام قاعدة البيانات. يمكننا استخدام قاعدة البيانات مباشرة عبر شيفرة JavaScript بمساعدة المكتبة official MongoDb Node.js driver، لكن استخدامها مربك قليلًا. لذلك سنستخدم بدلًا منها مكتبة Mongoose التي تؤمن واجهة برمجية عالية المستوى. يمكن توصيف Mongoose بأنها رابط (Mapper) لكائنات من النوع document واختصارًا (ODM). وبالتالي سيكون حفظ كائنات JavaScript كمستندات Mongo مباشرًا باستخدام هذه المكتبة.

لنثبت الآن Mongoose كالتالي:

npm install mongoose --save

لن نكتب أية شيفرات تتعامل مع Mongo في الواجهة الخلفية حاليًا، بل سننشئ تطبيقًا تدريبيًا ونضع في مجلده الجذري الملف mongo.js:

const mongoose = require('mongoose')

if (process.argv.length < 3) {
  console.log('Please provide the password as an argument: node mongo.js <password>')
  process.exit(1)
}

const password = process.argv[2]

const url =
  `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true`

mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })

const noteSchema = new mongoose.Schema({
  content: String,
  date: Date,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

const note = new Note({
  content: 'HTML is Easy',
  date: new Date(),
  important: true,
})

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

ملاحظة: قد يختلف عنوان موقع MongoDB عن العنوان الذي عرضناه سابقًا بناء على المنطقة التي اخترتها عند إنشائك العنقود. تأكد من استخدامك العنوان الصحيح الذي حصلت عليه من MongoDB Atlas.

ستفترض الشيفرة بأنها ستمرر كلمة المرور الموجودة ضمن معلومات التوثيق كمعامل لسطر أوامر. حيث يمكننا الوصول إلى معامل سطر الأوامر كالتالي:

const password = process.argv[2]

عندما تنفذ الشيفرة باستخدام الأمر node mongo.js password ستضيف Mongo ملفًا جديدًا إلى قاعدة البيانات.

ملاحظة: استخدم كلمة السر التي اخترتها عند إنشاء قاعدة البيانات وليست كلمة سر الدخول إلى MongoDB Atlas. وانتبه أيضًا إلى الرموز الخاصة التي قد تضعها في كلمة مرورك فستحتاج عندها إلى تشفير الرموز في كلمة المرور عند كتابة عنوان الموقع.

يمكنك الاطلاع على حالة قاعدة البيانات على MongoDB Atlas من Collections في النافذة Overview:

atlas_overview_database_014.png

وكما هو واضح، أضيف مستند يطابق الملاحظة إلى المجموعة notes في قاعدة البيانات التجريبية.

atlas_note_document_015.png

توصي توثيقات Mongo بإعطاء أسماء منطقية لقواعد البيانات. ويمكننا تغيير اسم قاعدة البيانات من عنوان الموقع للقاعدة:

db_name_change_016.png

سندمّر الآن قاعدة البيانات التجريبية بتغيير اسم قاعدة البيانات التي يشير إليها مؤسس الاتصال (connection string) إلى note-app بمجرد تعديل عنوان موقع القاعدة:

mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/note-app?retryWrites=true

سننفذ الشيفرة الآن:

db_new_name_017.png

لقد خُزِّنت الآن البيانات في القاعدة الصحيحة. يمكن باستخدام create database أن ننشئ قواعد بيانات مباشرة على موقع الويب MongoDB Atlas. لكن لا حاجة لذلك طالما أن الموقع ينشئ تلقائيًا قاعدة بيانات جديدة عندما يحاول التطبيق أن يتصل مع قاعدة بيانات غير موجودة.

تخطيط قاعدة البيانات

بعد تأسيس الاتصال مع قاعدة البيانات، سنعرف تخطيطًا (Schema) للملاحظة ونموذجًا (Model) مطابقًا للتخطيط:

const noteSchema = new mongoose.Schema({
  content: String,
  date: Date,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

عرّفنا أولًا تخطيطًا للملاحظة وأسندناه إلى المتغيّر noteSchema. يخبر التخطيط Mongosse كيف سيُخزّن الكائن الذي يمثل الملاحظة في قاعدة البيانات ثم نعرّف نموذجًا باسم Note يقبل معاملين، الأول هو اسم النموذج المفرد. حيث يختلف الاسم المفرد عن اسم المجموعة بأن الأخير يحمل صيغة الجمع ويكتب بأحرف صغيرة "notes" فهذا عرف تتبعه Mongoose وتقوم به تلقائيًا بينما يشير التخطيط إلى الملاحظات بالاسم المفرد. المعامل الثاني كما هو واضح هو تخطيط الملاحظة.

لا تملك قواعد البيانات المستقلة مثل Mongo أية تخطيطات (schemaless). ويعني ذلك أن قاعدة البيانات لا تهتم ببنية البيانات المخزنة فيها، حيث يمكن تخزين ملفات بتخطيطات مختلفة تمامًا ضمن نفس المجموعة. لكن الفكرة وراء منح Mongoose البيانات المخزنة في قاعدة البيانات تخطيطًا على مستوى التطبيق، هي تحديد شكل المستندات المخزنة في مجموعة ما.

إنشاء وحفظ الكائنات

ينشئ التطبيق في الشيفرة التالية كائن ملاحظة جديد بمساعدة النموذج Note:

const note = new Note({
  content: 'HTML is Easy',
  date: new Date(),
  important: false,
})

تدعى النماذج "دوال البناء". فهي التي تنشئ الكائنات الجديدة في JavaScript بناء على المعاملات التي تملكها. وطالما أن الكائنات ستبنى باستخدام الدوال البانية للنماذج، ستمتلك كل خصائص النموذج بما فيها توابع حفظ الكائن في قاعدة البيانات. وتحفظ الكائنات في قواعد البيانات باستخدام التابع save الذي يُزوّد بمعالج حدث ضمن التابع then:

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

بعدما يُحفظ الكائن في قاعدة البيانات، يُستدعَى معالج الحدث المعرّف ضمن التابع then. حيث يغلق معالج الحدث قناة الاتصال مع قاعدة البيانات باستخدام الأمر ()mongoose.connection.close. إن لم يُغلق الاتصال، فلن ينتهي البرنامج من تنفيذ العملية.

تُخزّن نتيجة عملية الحفظ في المعامل result لمعالج الحدث، لكنها غير مهمة كثيرًا، خاصة أننا خزّنا كائنًا مفردًا في قاعدة البيانات. يمكنك طباعة الكائن على الطرفية إن أردت التمعن فيه أثناء عمل التطبيق أو أثناء تنقيحه. لنخزّن عدة ملاحظات أخرى في قاعدة البيانات. لكن علينا أولًا تعديل الشيفرة ثم إعادة تنفيذها.

ملاحظة: لسوء الحظ، فإن توثيق mongoose ليس مستقرًا جدًا. لقد استخدمت الاستدعاءات في بعض الأجزاء عند عرض الأمثلة بينما استخدمت الأجزاء الأخرى أساليب أخرى. لذلك لا ننصحك بنسخ ولصق الشيفرة مباشرة من تلك التوثيقات. لا تخلط الوعود مع استدعاءات المدرسة التقليدية في نفس الشيفرة فهذا أمر غير محبذ.

إحضار كائنات من قاعدة البيانات

حَوِّل الشيفرة السابقة التي استخدمناها لإنشاء ملاحظة جديدة إلى تعليقات واستخدم الشيفرة التالية بدلًا منها:

Note.find({}).then(result => {
  result.forEach(note => {
    console.log(note)
  })
  mongoose.connection.close()
})

عندما تُنفّذ الشيفرة سيطبع التطبيق كل الملاحظات الموجودة ضمن قاعدة البيانات:

print_all_notes_018.png

نحصل على الكائنات من قاعدة البيانات مستعملين التابع find العائد للنموذج Note. يقبل التابع السابق معاملًا على هيئة كائن يحتوي على معايير البحث. وطالما أن المعامل في الشيفرة السابقة كائن فارغ ({})، فسنحصل على جميع الملاحظات المخزنة في المجموعة notes. تخضع معايير البحث إلى قواعد الاستعلام في Mongo. ويمكننا تحديد البحث ليشمل مثلًا الملاحظات الهامة فقط على النحو التالي:

Note.find({ important: true }).then(result => {
  // ...
})

التمرين 3.12

3.12 قاعدة بيانات بسطر أوامر

أنشئ قاعدة بيانات سحابية باستخدام MongoDB لتطبيق دليل الهاتف وذلك على موقع الويب MongoDb Atlas. أنشئ الملف mongo.js في مجلد المشروع، حيث تضيف الشيفرة في الملف مُدخلات إلى دليل الهاتف، وتشكيل قائمة بكل المُدخلات الموجودة في الدليل.

ملاحظة: لا تضع كلمة المرور في الملف الذي سترفعه إلى GitHub.

يجب أن يعمل التطبيق على النحو التالي: تمرير ثلاثة معاملات إلى سطر الأوامر عند تشغيل التطبيق، على أن تكون كلمة السر هي المعامل الأول كما في المثال التالي:

node mongo.js yourpassword Anna 040-1234556

سيطبع التطبيق النتيجة التالية:

added Anna number 040-1234556 to phonebook

يُخزَّن المُدخل الجديد ضمن قاعدة البيانات. وانتبه إلى وضع الاسم الذي يحتوي على فراغات ضمن قوسي تنصيص مزدوجين:

node mongo.js yourpassword "Arto Vihavainen" 045-1232456

إذا شغلت التطبيق بمعامل واحد فقط هو كلمة السر كما يلي:

node mongo.js yourpassword

على التطبيق عندها أن يعرض كل المُدخَلات في دليل الهاتف:

phonebook:
Anna 040-1234556
Arto Vihavainen 045-1232456
Ada Lovelace 040-1231236

يمكنك الحصول على معاملات سطر الأوامر من المتغيّر process.argv.

ملاحظة: لا تغلق الاتصال في المكان غير المناسب. فلن تعمل على سبيل المثال الشيفرة التالية:

Person
  .find({})
  .then(persons=> {
    // ...
  })

mongoose.connection.close()

سيُنفَّذ الأمر ()mongoose.connection.closeمباشرة بعد أن تبدأ العملية Person.find. أي ستغلق قاعدة البيانات مباشرة قبل أن تنتهي العملية السابقة وتستدعى دالة معالج الحدث. لذلك فالمكان الصحيح لإغلاق قاعدة البيانات سيكون في نهاية معالج الحدث:

Person
  .find({})
  .then(persons=> {
    // ...
    mongoose.connection.close()
  }
)

ملاحظة: إذا سميت النموذج person، ستسمي mongoose المجموعة المقابلة people.

ربط الواجهة الخلفية مع قاعدة البيانات

لقد تعلمنا ما يكفي لبدء استخدام Mongo في تطبيقنا. لننسخ ونلصق القيم التي عرفناها في التطبيق التجريبي، ضمن الملف index.js:

const mongoose = require('mongoose')

// DO NOT SAVE YOUR PASSWORD TO GITHUB!!
const url =
  'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true'

mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })

const noteSchema = new mongoose.Schema({
  content: String,
  date: Date,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

لنغير معالج حدث إحضار كل الملاحظات إلى الشكل التالي:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

يمكن أن نتحقق من عمل الواجهة الخلفية بعرض كل الملاحظات على المتصفح:

verify_backend_work_019.png

يعمل التطبيق بشكل ممتاز الآن. تفترض الواجهة الأمامية أن لكل كائن معرفّا فريدًا id في الحقل id. وانتبه إلى أننا لا نريد إعادة حقل إصدار mongo إلى الواجهة الأمامية (v__). إحدى الطرق لنتحكم بصيغة الكائن الذي سنعيده، هي تعديل تخطيط الكائن باستخدام التابع toJSON والذي سنستعمله في كل النماذج التي تُنشأ اعتمادًا على هذا التخطيط. سيكون التعديل على النحو:

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

حتى لو بدت الخاصية (id__) لكائن Mongoose كسلسلة نصية فهي في الواقع كائن. لذلك يحولها التابع toJSON إلى سلسلة نصية حتى نتأكد أنها آمنة. إن لم نفعل ذلك ستظهر المشاكل أمامنا بمجرد أن نبدأ كتابة الاختبارات.

ستجيب الواجهة الخلفية على طلب HTTP بقائمة من الكائنات التي أعيدت صياغتها باستخدام التابع toJSON:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

أسندت مصفوفة الكائنات التي أعادتها Mongo إلى المتغير notes. وعندما ترسل الاستجابة بصيغة JSON، يستدعى التابع toJSON تلقائيًا من أجل كل كائن من المصفوفة باستخدام التابع JSON.stringify.

تهيئة قاعدة البيانات في وحدة خاصة بها

قبل أن نعيد كتابة بقية تطبيق الواجهة الخلفية، لنضع الشيفرة الخاصة بالتعامل مع Mongoose في وحدة مستقلة خاصة بها. لذلك سننشئ مجلدًا جديدًا للوحدة يدعى models ونضيف إليه ملفًا باسم note.js:

const mongoose = require('mongoose')

const url = process.env.MONGODB_URI
console.log('connecting to', url)
mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })
  .then(result => {
    console.log('connected to MongoDB')  })
  .catch((error) => {
    console.log('error connecting to MongoDB:', error.message)  })
const noteSchema = new mongoose.Schema({
  content: String,
  date: Date,
  important: Boolean,
})
noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

يختلف تعريف الوحدات في Node قليلًا عن الطريقة التي عرفنا فيها وحدات ES6 في القسم 2. فتُعرّف الواجهة العامة للوحدة بإسناد قيمة للمتغيّر module.exports. سنسند له إذًا النموذج Note. لن يتمكن مستخدم الوحدة من الوصول أو رؤية الأشياء المعرفة داخلها، كالمتغيرات وmonogose وعنوان موقع القاعدة url.

يجري إدراج الوحدة بإضافة السطر التالي إلى الملف index.js:

const Note = require('./models/note')

وهكذا سيُسند المتغيّر Note إلى نفس الكائن الذي تعرّفه الوحدة.

تغيّرت قليلًا الطريقة التي نجري بها الاتصال مع قاعدة البيانات:

const url = process.env.MONGODB_URI

console.log('connecting to', url)

mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })
  .then(result => {
    console.log('connected to MongoDB')
  })
  .catch((error) => {
    console.log('error connecting to MongoDB:', error.message)
  })
const noteSchema = new mongoose.Schema({
  content: String,
  date: Date,
  important: Boolean,
})

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

لا تكتب عنوان الموقع لقاعدة البيانات بشكل مسبق في الشيفرة فهذه فكرة سيئة. بل مرر العنوان إلى التطبيق عبر متغيّر البيئة MONGODB_URI.

تقدم الطريقة التي اتبعناها في تأسيس الاتصال دالتين للتعامل مع حالتي نجاح الاتصال وفشله. حيث تطبع كلتا الدالتين رسائل إلى الطرفية لوصف حالة الاتصال.

modules_connect_state_020.png

ستجد طرقًا عديدة لتعريف قيمة متغيّر البيئة، إحداها أن تعرّفه عندما تشغل التطبيق:

MONGODB_URI=address_here npm run dev

الطريقة الأخرى الأكثر تعقيدًا هي استخدام المكتبة dotenv التي يمكنك تثبيتها بتنفيذ الأمر:

npm install dotenv --save

عليك إنشاء ملف لاحقته env. عند جذر المشروع، ومن ثم تعرف متغيرات البيئة داخله. سيبدو الملف بالشكل التالي:

MONGODB_URI='mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true'
PORT=3001

لاحظ أننا حددنا رقم المنفذ بشكل مسبق ضمن متغير البيئة PORT.

ملاحظة تَجاهل الملف ذو اللاحقة "env."، لأننا لا نريد أن ننشر معلومات التوثيق الخاصة بنا في العلن.

env_variable_021.png

نستخدم متغيرات البيئة التي عرّفناها في الملف env. بكتابة العبارة ()require('dotenv').config، ثم يمكنك بعدها الإشارة إليهم في الشيفرة بالطريقة المعهودة process.env.MONGODB_URI.

لنغيّر الملف index.js على النحو التالي:

require('dotenv').config()const express = require('express')
const app = express()
const Note = require('./models/note')
// ..

const PORT = process.env.PORTapp.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

من المهم إدراج المكتبة dotenv قبل إدراج الوحدة note، لكي نضمن أن متحولات البيئة التي عرّفت داخلها ستكون متاحة للاستخدام ضمن كامل الشيفرة، وذلك قبل إدراج بقية الوحدات.

استخدام قاعدة البيانات مع معالجات المسار

لنغيّر بقية الوظائف في تطبيق الواجهة الخلفية ليتعامل مع قواعد البيانات. تضاف ملاحظة جديدة كالتالي:

app.post('/api/notes', (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    return response.status(400).json({ error: 'content missing' })
  }

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })

  note.save().then(savedNote => {
    response.json(savedNote)
  })
})

تنشئ الدالة البانية للنموذج Note الكائنات التي تمثل الملاحظات. ثم ترسل الاستجابة داخل دالة استدعاء التابع save. يضمن هذا أن الاستجابة لن تُعاد إلى المرسل إن لم تنجح العملية. وسنناقش بعد قليل آلية التعامل مع الأخطاء.

يحمل معامل دالة الاستدعاء savedNote الملاحظة الجديدة التي أنشئت. وتذكر أن البيانات التي أعيدت في الاستجابة قد أعيد تنسيقها باستعمال التابع toJSON.

response.json(savedNote)

تغيرت طريقة إحضار الملاحظات المفردة لتصبح على النحو:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id).then(note => {
    response.json(note)
  })
})

التحقق من تكامل أداء الواجهتين الخلفية والأمامية

من الجيد اختبار التطبيق الذي يعمل على الواجهة الخلفية عندما نضيف إليه وظائف جديدة، يمكن أن نستعمل في الاختبار برنامج Postman أو VS Code REST client أو المتصفح الذي تستخدمه.

لننشئ ملاحظة جديدة ونخزّنها في قاعدة البيانات:

backend_db_note_022.png

بعد أن نتأكد من أن كل شيء يعمل على ما يرام في الواجهة الخلفية، نختبر تكامل الواجهة الأمامية مع الخلفية. فمن غير الكافي إطلاقًا اختبار الأشياء ضمن الواجهة الأمامية فقط.

ربما عليك أن تدرس تكامل الوظائف بين الواجهتين وظيفة تلو الأخرى. فيمكننا أولًا إضافة الشيفرة التي تحضر كل البيانات من قاعدة البيانات ونختبرها على طرفية الواجهة الخلفية ضمن المتصفح. بعدها نتأكد أن الواجهة الأمامية تعمل جيدًا مع الشيفرة الجديدة للواجهة الخلفية. عندما يجري كل شيء بشكل جيد ننتقل إلى الوظيفة التالية.

علينا تفقد الحالة الراهنة لقاعدة البيانات بمجرد بدأنا العمل معها. يمكننا القيام بذلك على سبيل المثال، عبر لوحة التحكم في MongoDB Atlas. كما ستفيدك أثناء التطوير بعض برامج Node الصغيرة، كالبرنامج mongo.js الذي كتبناه في هذا الفصل.

ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-4 على موقع GitHub.

التمارين 3.13 - 3.14

يمثل التمرينين التاليين تطبيقًا مباشرًا وسهلًا لما تعلمناه. لكن إن لم تتكامل الواجهتين معًا أثناء العمل، فستكمن الأهمية في كيفية إيجاد ومعالجة الأخطاء التي سببت ذلك.

3.13 دليل هاتف بقاعدة بيانات: الخطوة 1

غيِّر طريقة إحضار جميع المُدخَلات لكي يتم ذلك من قاعدة بيانات. تأكد أن الواجهة الأمامية ستعمل بعد إجراء هذه التغييرات. سنتكتب شيفرة التعامل مع قاعدة البيانات MongoDB في وحدة خاصة بها خلال التمارين القادمة، كما فعلنا سابقًا في هذا الفصل (تهيئة قاعدة البيانات في وحدة خاصة بها).

3.14 دليل هاتف بقاعدة بيانات: الخطوة 2

غيّر في شيفرة الواجهة الخلفية بحيث تحفظ الأرقام في قاعدة البيانات، وتحقق أن الواجهة الأمامية ستعمل بشكل جيد بعد التغييرات في الواجهة الخلفية.

يمكنك في هذه المرحلة أن تجعل المستخدمين يدخلون كل ما يشاؤون في دليل الهاتف. ولاحظ أن دليل الهاتف قد يحوي الاسم نفسه مكررًا مرات عدة.

معالجة الأخطاء

لو حاولنا الوصول إلى موقع ملاحظة بمعرّف id غير موجود. فستكون الإجابة Null (لا شيء). لنغير ذلك بحيث يستجيب الخادم على هذا الطلب برمز الحالة 404 (غير موجود). سنضيف أيضًا كتلة catch لتتعامل مع الحالات التي يُرفض فيها الوعد الذي يعيده التابع findById:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
          response.json(note)
      }
      else {
          response.status(404).end()
      }
  })
    .catch(error => {
      console.log(error)
      response.status(500).end()
  })
})

إن لم يُعثر على تطابق في قاعدة البيانات، ستكون قيمة الكائن note هي null، وبالتالي ستنفذ كتلة else. سينتج عن ذلك استجابة برمز الحالة 404 (غير موجود). وأخيرًا، إن رفض الوعد الذي يعيده التابع findById، ستكون الاستجابة برمز الحالة 500 (خطأ داخلي في الخادم). ستعرض لك طرفية التطوير معلومات مفصلة أكثر عن الخطأ.

بالإضافة إلى الخطأ الناتج عن عدم وجود ملاحظة، ستواجه حالة أخرى يتوجب عليك معالجتها. تتلخص هذه الحالة بمحاولة إحضار ملاحظة بمعرّف id من نوع خاطئ لا يطابق تنسيق Mongo للمعرفات IDs. فلو ارتكبنا خطأً كهذا، سنرى الرسالة التالية:

Method: GET
Path:   /api/notes/someInvalidId
Body:   {}
---
{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id"
    at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
    at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
    ...

إن إعطاء معرّف id بصيغة خاطئة، سيدفع التابع findById لإشهار خطأ يسبب رفضًا للوعد. كنتيجة لذلك ستُستدعى الدالة الموجودة في الكتلة catch. لنغيّر طريقة الاستجابة قليلًا في تلك الكتلة:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end() 
      }
    })
    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })    })
})

إن لم يكن تنسيق المعرف id صحيحًا، ستنتهي العملية بتنفيذ معالج الخطأ الموجود في catch. إن الاستجابة الملائمة لهذا الخطأ هو رمز الحالة 400 (طلب خاطئ) 400 Bad Request، لأن هذه الحالة تطابق تمامًا الوصف التالي:

اقتباس

لم يتمكن الخادم من فهم الطلب نظرًا لخطأ في صياغته. لا ينبغي على العميل إعادة هذا الطلب قبل تعديله.

أضفنا أيضًا بعض البيانات إلى الاستجابة لنلقي الضوء على سبب الخطأ. يفضل دائمًا عند التعامل مع الوعود إضافة معالجات للأخطاء والاستثناءات، لأن إهمال ذلك سيدفعك لمواجهة أخطاء غريبة. ومن الجيد أيضًا طباعة الكائن الذي سبب الاستثناء على طرفية التطوير:

.catch(error => {
  console.log(error)
  response.status(400).send({ error: 'malformatted id' })
})

قد يُستدعى معالج الخطأ التي زرعته، نتيجة لخطأ مختلف تمامًا عن الخطأ الذي تريد اعتراضه. فإن طبعت الخطأ على الطرفية ستوفر على نفسك عناء جلسات التنقيح المحبطة. وعلاوة على ذلك تزودك معظم الخدمات الحديثة بوسيلة ما لطباعة عمليات النظام، بحيث يمكنك الاطلاع عليها متى أردت والخادم Heroku مثال مهم عليها.

وطالما أنك تعمل على الواجهة الخلفية فابق نظرك على الطرفية التي تظهر لك خرج العملية حتى لو عملت على شاشة صغيرة، سيلفت وقوع أي خطأ انتباهك.

error_screen_023.png

تحويل معالجات الأخطاء إلى أداة وسطية

لقد كتبنا شيفرة ملاحقة الأخطاء ضمن بقية أجزاء الشيفرة، ويبدو الأمر معقولًا أحيانًا، لكن من الأفضل إضافة معالجات الأخطاء في مكان واحد. سيكون هذا الأمر مفيدًا إذا أردنا لاحقًا أن نقدم تقريرًا عن الأخطاء إلى منظومة تتبع أخطاء خارجية مثل Sentry.

لنغيّر معالج المسار api/notes/:id/، بحيث يمرر الخطأ إلى الدالة next كمعامل، وتمثل هذه الدالة بدورها المعامل الثالث لمعالج المسار:

app.get('/api/notes/:id', (request, response, next) => {  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end()
      }
    })
    .catch(error => next(error))})

عندما تستدعى next دون معامل، سينتقل التنفيذ بكل بساطة إل المسار أو الأداة الوسطية التالية. بينما لو امتلكت هذه الدالة معاملًا فسيتابع التنفيذ إلى الأداة الوسطية لمعالجة الخطأ.

معالجات أخطاء المكتبة Express هي أدوات وسطية تعرّف على شكل دالة تقبل أربع معاملات. سيبدو معالج الخطأ بالشكل التالي:

const errorHandler = (error, request, response, next) => {
  console.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } 

  next(error)
}

app.use(errorHandler)

يتحقق المعالج من طبيعة الخطأ. فإن كان الاستثناء هو خطأ تحويل نوع (CastError)، سنتأكد أن مصدر الخطأ هو كائن id بتنسيق مخالف لقواعد Mongo. وعندها سيرسل معالج الخطأ استجابته إلى المتصفح عبر كائن الاستجابة response الذي يمرر كمعامل للمعالج. أما في بقية الاستثناءات، فسيمرر المعالج الخطأ إلى معالج الخطأ الافتراضي في express.

تسلسل استخدام الأدوات الوسطية

تنفذ الأدوات الوسطية بنفس تسلسل استخدامها في express، أي بنفس تسلسل ظهور الأمر app.use. لذلك ينبغي الانتباه أثناء تعريفها. سيكون التسلسل الصحيح للاستخدام كالتالي:

app.use(express.static('build'))
app.use(express.json())
app.use(logger)

app.post('/api/notes', (request, response) => {
  const body = request.body
  // ...
})

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// handler of requests with unknown endpoint
app.use(unknownEndpoint)

const errorHandler = (error, request, response, next) => {
  // ...
}

// handler of requests with result to errors
app.use(errorHandler)

يجب أن تضع الأداة الوسطية json-parser بين الأدوات الوسطية التي تعرّف أولًا. فلو كان الترتيب كالتالي:

app.use(logger) // request.body is undefined!

app.post('/api/notes', (request, response) => {
  // request.body is undefined!
  const body = request.body
  // ...
})

app.use(express.json())

لن تكون البيانات المرسلة عبر طلب HTTP بصيغة JSON متاحة للاستخدام عبر الأداة الوسطية للولوج أو عبر معالج المسار POST، لأن الخاصية request.body ستكون غير محددة undefined في هذه المرحلة. ومن المهم جدًا أن تُعرّف الأداة الوسطية التي تعالج مشكلة المسارات غير المدعومة ضمن التعريفات الأخيرة، تمامًا قبل معالج الأخطاء. سيسبب الترتيب التالي على سبيل المثال مشكلة:

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// handler of requests with unknown endpoint
app.use(unknownEndpoint)

app.get('/api/notes', (request, response) => {
  // ...
})

لقد ظهر معالج النهاية غير المحددة (unknown endpoint) قبل معالج طلبات HTTP. وطالما أن معالج النهايات غير المحددة سيستجيب إلى كافة الطلبات بالرمز 404 unknown endpoint، فلن يُستدعى بعدها أي مسار أو أداة وسطية. ويبقى الاستثناء الوحيد هو معالج الخطأ الذي يجب أن يأتي أخيرًا بعد معالج النهايات غير المحددة.

خيارات أخرى

سنضيف الآن بعض الوظائف التي لم ندرجها بعد، بما فيها الحذف وتحديث ملاحظة مفردة. وأسهل الطرق لحذف ملاحظة من قاعدة البيانات هي استخدام التابع findByIdAndRemove:

app.delete('/api/notes/:id', (request, response, next) => {
  Note.findByIdAndRemove(request.params.id)
    .then(result => {
      response.status(204).end()
    })
    .catch(error => next(error))
})

سيستجيب الخادم في كلتا حالتي نجاح عملية الحذف برمز الحالة 202 (لا يوجد محتوى). ونقصد بالحالتين، حذف ملاحظة موجودة فعلًا، أو حذف ملاحظة غير موجودة. يمكن أن نستخدم المعامل result للتحقق من حذف المورد أم لا، وبالتالي سنتمكن من تحديد أي من حالتي النجاح قد أعيدت إن كنا بحاجة ماسة لذلك. ستمرر كل الاستثناءات إلى معالج الأخطاء.

يمكن بسهولة تغيير أهمية الملاحظة باستخدام التابع findByIdAndUpdate:

app.put('/api/notes/:id', (request, response, next) => {
  const body = request.body

  const note = {
    content: body.content,
    important: body.important,
  }

  Note.findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(updatedNote)
    })
    .catch(error => next(error))
})

تسمح الشيفرة السابقة أيضًا بتعديل محتوى الملاحظة لكنها لا تدعم تغيير تاريخ الإنشاء. ولاحظ كيف يتلقى التابع findByIdAndUpdate معاملًا على هيئة كائن JavaScript نظامي، وليس كائن ملاحظة أنشئ بواسطة الدالة البانية للنموذج Note. يبقى لدينا تفصيل مهم يتعلق بالتابع findByIdAndUpdate. فمعامل معالج الحدث upDateNote سيستقبل الملف الأصلي للملاحظة بلا تعديلات. لذلك وضعنا المعامل الاختياري {new: true}، الذي يسبب استدعاء معالج الحدث بالنسخة المعدَّلة من الملاحظة بدلًا من الأصلية.

بعد اختبار الواجهة الخلفية مباشرة مستخدمين Postman أو VS Code REST client، يمكننا التحقق من أنها تعمل بشكل صحيح. وكذلك التحقق من أن الواجهة الأمامية تتكامل مع الخلفية التي تستخدم قاعدة البيانات.

لكن عندما نحاول تغيير أهمية ملاحظة، تستظهر على الطرفية رسالة الخطأ التالية:

toggle_imporant_error_24.png

استخدم google للبحث عن سبب الخطأ وسيقودك إلى إرشادات لتصحيحه. واتباعًا للاقتراح الموجود في توثيق Mongoose، أضفنا السطر التالي للملف note.js:

const mongoose = require('mongoose')

mongoose.set('useFindAndModify', false)
// ...

module.exports = mongoose.model('Note', noteSchema) 

ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-5 على موقع GitHub.

التمارين 3.15 - 3.18

3.15 دليل هاتف بقاعدة بيانات: الخطوة 3

عدّل شيفرة الواجهة الخلفية لتحذف المُدخَلات مباشرة من قاعدة البيانات. تحقق أن الواجهة الأمامية تعمل بشكل صحيح بعد التعديلات.

3.16 دليل هاتف بقاعدة بيانات: الخطوة 4

حوّل معالج الخطأ في التطبيق إلى أداة وسطية جديدة لمعالجة الأخطاء.

3.17 دليل هاتف بقاعدة بيانات: الخطوة 5 *

إذا حاول المستخدم إنشاء مُدخَل جديد إلى الدليل، وكان اسم الشخص موجودًا مسبقًا، ستحاول الواجهة الأمامية تحديث رقم الهاتف للمُدخَل الموجود بإرسال طلب HTTP-PUT إلى عنوان المُدخَل. عدّل الواجهة الخلفية لتدعم هذا الفعل، ثم تأكد أن الواجهة الأمامية ستعمل بعد التعديل.

3.18 دليل هاتف بقاعدة بيانات: الخطوة 6 *

عدّل معالج المسار api/persons/:id ومعالج المسار api/persons/info ليستخدما قاعدة البيانات، ثم تحقق من أنهما يعملان جيدًا مستخدمًا المتصفح وPostman وVS Code REST client. سيبدو لك الأمر عند التحقق من مُدخَل فردي إلى دليل الهاتف كما في الشكل التالي:

browser_chk_025.png

ترجمة -وبتصرف- للفصل saving data to MongoDB من سلسلة Deep Dive Into Modern Web Development


تفاعل الأعضاء

أفضل التعليقات

بتاريخ On 2/28/2021 at 20:21 قال عبدالله غازي:

السلام عليكم ، اريد ان اقراء بالترتيب ومن البدايه عن nodejs في اكاديمية حسوب لكن كيف لي ان اعرف المقال الاول لكي ابدا بشكل صحيح؟

وعليكم السلام، أهلًا @عبدالله غازي

يمكنك قراءة سلسلة تطوير الويب بشقيه الواجهات الأمامية والخلفية بالضغط على الوسم full_stack_101 بجانب اسم المقال والذي يعرض لك كل مقالات هذه السلسلة، والمقالات الناتجة مرتبة تصاعديًا أي أول مقال هو آخر مقال منشور (مرتبة وفق تاريخ نشرها)، ويمكنك البدء من آخر مقال لقراءة السلسلة بالترتيب.

السلسلة هذه تبدأ بشرح كيفية تطوير الواجهات الأمامية بريآكت أولًا ثم تشرح تطوير واجهات الويب الخلفية عبر Node.js بدءًا من مقال «مدخل إلى Node.js وExpress» وما بعده (تجد المقالات مرتبة ضمن صفحة الوسم full_stack_101).

 

تحياتي،

رابط هذا التعليق
شارك على الشبكات الإجتماعية



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...