في هذا الجزء، ينتقل تركيزنا نحو الواجهة الخلفية: أي نحو تنفيذ الوظائف على جانب الخادم من المنظومة.

سنبني واجهتنا الخلفية على أساس NodeJS، وهي بيئة تشغيل لـ JavaScript مبنية على محرك Chrome V8.

كُتبت مادة هذا المقرر باستخدام الإصدار v22.3.0 من Node.js. تأكد من أن إصدار Node لديك حديث على الأقل بقدر الإصدار المستخدم في المادة (يمكنك التحقق من الإصدار بتنفيذ node -v في سطر الأوامر).

كما ذُكر في الجزء 1، لا تدعم المتصفحات بعد أحدث ميزات JavaScript، ولهذا يجب ترجمة الشيفرة العاملة في المتصفح باستخدام مثل babel. الوضع مختلف مع JavaScript العاملة في الواجهة الخلفية. يدعم أحدث إصدار من Node الغالبية العظمى من أحدث ميزات JavaScript، لذا يمكننا استخدام أحدث الميزات دون الحاجة إلى ترجمة شيفرتنا.

هدفنا هو تنفيذ واجهة خلفية تعمل مع تطبيق الملاحظات من الجزء 2. لكن لنبدأ بالأساسيات عبر تنفيذ تطبيق "hello world" الكلاسيكي.

انتبه إلى أن التطبيقات والتمارين في هذا الجزء ليست كلها تطبيقات React، ولن نستخدم أداة create vite@latest -- --template react لتهيئة المشروع لهذا التطبيق.

سبق أن ذكرنا npm في الجزء 2، وهي أداة تُستخدم لإدارة حزم JavaScript. في الواقع، أصل npm من منظومة Node.

لننتقل إلى مجلد مناسب، وننشئ قالباً جديداً لتطبيقنا باستخدام الأمر npm init. سنجيب عن الأسئلة التي تعرضها الأداة، وستكون النتيجة ملف package.json يُنشأ تلقائياً في جذر المشروع ويحتوي على معلومات عن المشروع.

{
  "name": "backend",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Matti Luukkainen",
  "license": "MIT"
}

يعرّف الملف، على سبيل المثال، أن نقطة دخول التطبيق هي ملف index.js.

لنجرِ تغييراً صغيراً على كائن scripts بإضافة أمر سكربت جديد.

{
  // ...
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

بعد ذلك، لننشئ الإصدار الأول من تطبيقنا بإضافة ملف index.js إلى جذر المشروع بالشيفرة التالية:

console.log('hello world')

يمكننا تشغيل البرنامج مباشرة بـ Node من سطر الأوامر:

node index.js

أو يمكننا تشغيله كـ سكربت npm:

npm start

يعمل سكربت npm المسمى start لأننا عرّفناه في ملف package.json:

{
  // ...
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

رغم أن تنفيذ المشروع يعمل عند تشغيله باستدعاء node index.js من سطر الأوامر، فمن المعتاد في مشاريع npm تنفيذ مثل هذه المهام كسكربتات npm.

بشكل افتراضي، يعرّف ملف package.json أيضاً سكربت npm آخر شائع الاستخدام يسمى npm test. وبما أن مشروعنا لا يملك بعد مكتبة اختبارات، فإن الأمر npm test ينفذ ببساطة الأمر التالي:

echo "Error: no test specified" && exit 1

خادم ويب بسيط

لنحوّل التطبيق إلى خادم ويب بتحرير ملف index.js كما يلي:

const http = require('http')

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

بمجرد تشغيل التطبيق، تُطبع الرسالة التالية في الطرفية:

Server running on port 3001

يمكننا فتح تطبيقنا المتواضع في المتصفح بزيارة العنوان http://localhost:3001:

لقطة شاشة hello world

يعمل الخادم بالطريقة نفسها بغض النظر عن الجزء الأخير من عنوان URL. كما سيعرض العنوان http://localhost:3001/foo/bar المحتوى نفسه.

ملاحظة إذا كان المنفذ 3001 مستخدماً بالفعل من تطبيق آخر، فسيؤدي تشغيل الخادم إلى رسالة الخطأ التالية:

➜  hello npm start

> hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello
> node index.js

Server running on port 3001
events.js:167
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE :::3001
    at Server.setupListenHandle [as _listen2] (net.js:1330:14)
    at listenInCluster (net.js:1378:12)

لديك خياران. إما إيقاف التطبيق الذي يستخدم المنفذ 3001 (كان JSON Server في الجزء الأخير من المادة يستخدم المنفذ 3001)، أو استخدام منفذ مختلف لهذا التطبيق.

لنلقِ نظرة أقرب على السطر الأول من الشيفرة:

const http = require('http')

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

import http from 'http'

في هذه الأيام، تستخدم الشيفرة التي تعمل في المتصفح وحدات ES6. تُعرَّف الوحدات بـ export وتُضمَّن في الملف الحالي بـ import.

يستخدم Node.js وحدات CommonJS. والسبب في ذلك أن منظومة Node احتاجت الوحدات قبل وقت طويل من دعم JavaScript لها في مواصفات اللغة. حالياً، يدعم Node أيضاً استخدام وحدات ES6، لكن بما أن الدعم ليس مثالياً تماماً بعد، سنلتزم بوحدات CommonJS.

تعمل وحدات CommonJS تقريباً تماماً مثل وحدات ES6، على الأقل فيما يتعلق باحتياجاتنا في هذا المقرر.

المقطع التالي في شيفرتنا يبدو هكذا:

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

تستخدم الشيفرة الدالة createServer من وحدة http لإنشاء خادم ويب جديد. يُسجَّل معالج حدث لدى الخادم يُستدعى في كل مرة يُرسَل فيها طلب HTTP إلى عنوان الخادم http://localhost:3001.

تُستجاب للطلب برمز الحالة 200، مع ضبط ترويسة Content-Type على text/plain، وضبط محتوى الموقع المُعاد على Hello World.

تربط الأسطر الأخيرة خادم http المُسنَد إلى المتغير app بالاستماع إلى طلبات HTTP المُرسَلة إلى المنفذ 3001:

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

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

const http = require('http')

// highlight-start
let notes = [
  {
    id: "1",
    content: "HTML is easy",
    important: true
  },
  {
    id: "2",
    content: "Browser can execute only JavaScript",
    important: false
  },
  {
    id: "3",
    content: "GET and POST are the most important methods of HTTP protocol",
    important: true
  }
]

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'application/json' })
  response.end(JSON.stringify(notes))
})
// highlight-end

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

لنعد تشغيل الخادم (يمكنك إيقاف الخادم بالضغط على Ctrl+C في الطرفية) ولنحدّث المتصفح.

القيمة application/json في ترويسة Content-Type تُعلِم المستقبِل بأن البيانات بصيغة JSON. تتحول مصفوفة notes إلى نص بصيغة JSON بواسطة الدالة JSON.stringify(notes). هذا ضروري لأن الدالة response.end() تتوقع نصاً أو مخزوناً (buffer) لإرساله كجسم للاستجابة.

عندما نفتح المتصفح، يكون التنسيق المعروض مطابقاً تماماً لما كان في الجزء 2 حيث استخدمنا json-server لتقديم قائمة الملاحظات:

بيانات الملاحظات بصيغة JSON منسقة

Express

تنفيذ شيفرة خادمنا مباشرةً بخادم الويب http المدمج في Node ممكن. لكنه مرهق، خصوصاً عندما يكبر حجم التطبيق.

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

لنأخذ Express في الاستخدام بتعريفه كاعتمادية للمشروع بالأمر:

npm install express

تُضاف الاعتمادية أيضاً إلى ملف package.json لدينا:

{
  // ...
  "dependencies": {
    "express": "^5.1.0"
  }
}

تُثبَّت الشيفرة المصدرية للاعتمادية في مجلد node_modules الموجود في جذر المشروع. بالإضافة إلى Express، يمكنك العثور على عدد كبير من الاعتماديات الأخرى في المجلد:

سرد بالأمر ls للاعتماديات في المجلد

هذه هي اعتماديات مكتبة Express واعتماديات كل اعتمادياتها، وهكذا دواليك. تسمى هذه الاعتماديات المتعدية لمشروعنا.

ثُبّت الإصدار 5.1.0 من Express في مشروعنا. ماذا تعني علامة الإقحام أمام رقم الإصدار في package.json؟

"express": "^5.1.0"

يسمى نموذج الإصدارات المستخدم في npm الإصدار الدلالي.

تعني علامة الإقحام أمام ^5.1.0 أنه إذا حُدِّثت اعتماديات المشروع ومتى حدث ذلك، فسيكون إصدار Express المثبَّت 5.1.0 على الأقل. غير أن الإصدار المثبَّت من Express يمكن أن يحمل رقم patch أكبر (الرقم الأخير)، أو رقم minor أكبر (الرقم الأوسط). أما الإصدار الرئيسي للمكتبة الذي يشير إليه الرقم major الأول فيجب أن يبقى نفسه.

يمكننا تحديث اعتماديات المشروع بالأمر:

npm update

وبالمثل، إذا بدأنا العمل على المشروع على حاسوب آخر، يمكننا تثبيت جميع اعتماديات المشروع المحدَّثة المعرَّفة في package.json بتنفيذ الأمر التالي في المجلد الجذر للمشروع:

npm install

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

الويب و Express

لنعد إلى تطبيقنا ونجرِ التغييرات التالية:

const express = require('express')
const app = express()

let notes = [
  ...
]

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

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

const PORT = 3001
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

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

لم يتغير التطبيق كثيراً. في بداية شيفرتنا مباشرة، نستورد express، وهي هذه المرة دالة تُستخدم لإنشاء تطبيق Express يُخزَّن في المتغير app:

const express = require('express')
const app = express()

بعد ذلك، نعرّف مسارين للتطبيق. الأول يعرّف معالج حدث يُستخدم للتعامل مع طلبات HTTP GET المُرسَلة إلى جذر التطبيق /:

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

تقبل دالة معالج الحدث معاملين اثنين. المعامل الأول request يحتوي على كل معلومات طلب HTTP، والمعامل الثاني response يُستخدم لتحديد كيفية الاستجابة للطلب.

في شيفرتنا، تُستجاب للطلب باستخدام الدالة send من كائن response. استدعاء الدالة يجعل الخادم يستجيب لطلب HTTP بإرسال استجابة تحتوي على النص <h1>Hello World!</h1> الذي مُرِّر إلى الدالة send. وبما أن المعامل نص، يضبط Express تلقائياً قيمة ترويسة Content-Type على text/html. رمز حالة الاستجابة افتراضياً هو 200.

يمكننا التحقق من ذلك من تبويب Network في أدوات المطوّر:

تبويب Network في أدوات المطوّر

المسار الثاني يعرّف معالج حدث يتعامل مع طلبات HTTP GET المُرسَلة إلى مسار notes في التطبيق:

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

تُستجاب للطلب بالدالة json من كائن response. استدعاء الدالة سيرسل مصفوفة notes التي مُرِّرت إليها كنص بصيغة JSON. يضبط Express تلقائياً ترويسة Content-Type بالقيمة المناسبة application/json.

api/notes تعيد بيانات JSON المنسقة مرة أخرى

بعد ذلك، لنلقِ نظرة سريعة على البيانات المُرسَلة بصيغة JSON.

في الإصدار السابق حيث كنا نستخدم Node فقط، كان علينا تحويل البيانات إلى نص بصيغة JSON بواسطة الدالة JSON.stringify:

response.end(JSON.stringify(notes))

مع Express، لم يعد هذا مطلوباً، لأن هذا التحويل يحدث تلقائياً.

يجدر التنبيه إلى أن JSON صيغة بيانات. غير أنها غالباً ما تُمثَّل كنص وليست نفسها كائن JavaScript، مثل القيمة المُسنَدة إلى notes.

التجربة الموضحة أدناه توضح هذه النقطة:

طرفية node توضح أن json من نوع نص

أُجريت التجربة أعلاه في node-repl التفاعلي. يمكنك بدء node-repl التفاعلي بكتابة node في سطر الأوامر. تعدّ repl مفيدة بشكل خاص لاختبار كيفية عمل الأوامر أثناء كتابة شيفرة التطبيق. أوصي به بشدة!

التتبع التلقائي للتغييرات

إذا غيّرنا شيفرة التطبيق، نحتاج أولاً إلى إيقاف التطبيق من الطرفية (ctrl + c) ثم إعادة تشغيله حتى تسري التغييرات. تبدو إعادة التشغيل مرهقة مقارنة بسير العمل السلس في React، حيث يتحدث المتصفح تلقائياً عند تغير الشيفرة.

يمكنك جعل الخادم يتتبع تغييراتنا بتشغيله بخيار --watch:

node --watch index.js

الآن، ستؤدي التغييرات في شيفرة التطبيق إلى إعادة تشغيل الخادم تلقائياً. لاحظ أنه رغم إعادة تشغيل الخادم تلقائياً، فلا تزال بحاجة إلى تحديث المتصفح. على عكس React، لا نملك، ولا يمكننا أن نملك، وظيفة إعادة التحميل الفوري (hot reload) التي تحدّث المتصفح في هذا السيناريو (حيث نعيد بيانات JSON).

لنعرّف سكربت npm مخصصاً في ملف package.json لتشغيل خادم التطوير:

{
  // ..
  "scripts": {
    "start": "node index.js",
    "dev": "node --watch index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ..
}

يمكننا الآن تشغيل الخادم في وضع التطوير بالأمر

npm run dev

على عكس تشغيل سكربتات start أو test، يجب أن يتضمن الأمر run.

REST

لنوسّع تطبيقنا بحيث يوفر نفس واجهة HTTP API بأسلوب REST مثل json-server.

قُدِّم Representational State Transfer، المعروف بـ REST، عام 2000 في أطروحة روي فيلدنغ. REST أسلوب معماري مخصص لبناء تطبيقات ويب قابلة للتوسع.

لن نتعمق في تعريف فيلدنغ لـ REST أو نقضي وقتاً في التأمل فيما هو RESTful وما ليس كذلك. بدلاً من ذلك، نتبنى رؤية أضيق بالاهتمام فقط بالكيفية التي تُفهم بها واجهات RESTful APIs عادةً في تطبيقات الويب. فالتعريف الأصلي لـ REST لا يقتصر أصلاً على تطبيقات الويب.

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

من التقاليد المتبعة لإنشاء عناوين فريدة دمج اسم نوع المورد مع المعرّف الفريد للمورد.

لنفترض أن عنوان URL الجذر لخدمتنا هو www.example.com/api.

إذا عرّفنا نوع مورد الملاحظة على أنه notes، فإن عنوان مورد الملاحظة ذي المعرّف 10 يكون www.example.com/api/notes/10.

أما عنوان URL للمجموعة الكاملة لجميع موارد الملاحظات فهو www.example.com/api/notes.

يمكننا تنفيذ عمليات مختلفة على الموارد. تُحدَّد العملية المراد تنفيذها بـ فعل HTTP:

URL الفعل الوظيفة
notes/10 GET يجلب مورداً واحداً
notes GET يجلب جميع الموارد في المجموعة
notes POST ينشئ مورداً جديداً بناءً على بيانات الطلب
notes/10 DELETE يحذف المورد المُحدَّد
notes/10 PUT يستبدل المورد المُحدَّد بالكامل ببيانات الطلب
notes/10 PATCH يستبدل جزءاً من المورد المُحدَّد ببيانات الطلب

بهذه الطريقة نتمكن من تعريف ما يشير إليه REST تقريباً بـ الواجهة الموحدة، أي طريقة متسقة لتعريف الواجهات تتيح للأنظمة التعاون.

تندرج هذه الطريقة في تفسير REST تحت المستوى الثاني من نضج REST في نموذج نضج ريتشاردسون. ووفقاً للتعريف الذي قدمه روي فيلدنغ، لم نعرّف REST API. في الواقع، إن الغالبية العظمى من واجهات «REST» المزعومة في العالم لا تستوفي المعايير الأصلية التي حددها فيلدنغ في أطروحته.

في بعض المصادر (انظر مثلاً Richardson, Ruby: RESTful Web Services) سترى أن نموذجنا لواجهة CRUD المباشرة يُشار إليه كمثال على البنية الموجهة بالموارد بدلاً من REST. سنتجنب الغرق في الجدل حول المصطلحات ونعود بدلاً من ذلك إلى العمل على تطبيقنا.

جلب مورد واحد

لنوسّع تطبيقنا بحيث يوفر واجهة REST للعمل على الملاحظات الفردية. أولاً، لننشئ مساراً لجلب مورد واحد.

العنوان الفريد الذي سنستخدمه لملاحظة فردية هو من الشكل notes/10، حيث يشير الرقم في النهاية إلى رقم المعرّف الفريد للملاحظة.

يمكننا تعريف معاملات للمسارات في Express باستخدام صيغة النقطتين:

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

الآن سيتعامل app.get('/api/notes/:id', ...) مع جميع طلبات HTTP GET من الشكل /api/notes/SOMETHING، حيث SOMETHING أي نص عشوائي.

يمكن الوصول إلى المعامل id في مسار الطلب عبر كائن request:

const id = request.params.id

تُستخدم الدالة find المألوفة الآن للمصفوفات للعثور على الملاحظة ذات المعرّف المطابق للمعامل. ثم تُعاد الملاحظة إلى مُرسِل الطلب.

يمكننا الآن اختبار تطبيقنا بالذهاب إلى http://localhost:3001/api/notes/1 في متصفحنا:

api/notes/1 تعيد ملاحظة واحدة بصيغة JSON

لكن، هناك مشكلة أخرى في تطبيقنا.

إذا بحثنا عن ملاحظة بمعرّف غير موجود، يستجيب الخادم بـ:

أدوات الشبكة تظهر 200 وcontent-length 0

رمز حالة HTTP المُعاد هو 200، ما يعني أن الاستجابة نجحت. لا تُرسَل بيانات مع الاستجابة، لأن قيمة ترويسة content-length هي 0، ويمكن التحقق من الأمر نفسه من المتصفح.

سبب هذا السلوك هو أن المتغير note يُضبط على undefined إذا لم تُوجد ملاحظة مطابقة. تحتاج هذه الحالة إلى معالجة أفضل على الخادم. إذا لم تُوجد ملاحظة، ينبغي أن يستجيب الخادم برمز الحالة 404 not found بدلاً من 200.

لنجرِ التغيير التالي على شيفرتنا:

app.get('/api/notes/:id', (request, response) => {
  const id = request.params.id
  const note = notes.find(note => note.id === id)
  
  // highlight-start
  if (note) {
    response.json(note)
  } else {
    response.status(404).end()
  }
  // highlight-end
})

بما أنه لا تُرفَق بيانات بالاستجابة، نستخدم الدالة status لضبط الحالة والدالة end للاستجابة للطلب دون إرسال أي بيانات.

يستفيد شرط if من كون جميع كائنات JavaScript صادقة القيمة (truthy)، أي أنها تُقيَّم إلى true في عملية مقارنة. أما undefined فهي كاذبة القيمة (falsy)، أي أنها تُقيَّم إلى false.

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

على أي حال، يمكن إعطاء تلميح عن سبب إرسال خطأ 404 عبر تجاوز رسالة NOT FOUND الافتراضية.

حذف الموارد

بعد ذلك، لننفّذ مساراً لحذف الموارد. يحدث الحذف بإرسال طلب HTTP DELETE إلى عنوان URL الخاص بالمورد:

app.delete('/api/notes/:id', (request, response) => {
  const id = request.params.id
  notes = notes.filter(note => note.id !== id)

  response.status(204).end()
})

إذا نجح حذف المورد، أي أن الملاحظة موجودة وأُزيلت، نستجيب للطلب برمز الحالة 204 no content ولا نعيد أي بيانات مع الاستجابة.

لا يوجد إجماع على رمز الحالة الذي ينبغي إعادته لطلب DELETE إذا لم يكن المورد موجوداً. الخياران الوحيدان هما 204 و404. للتبسيط، سيستجيب تطبيقنا بـ 204 في كلتا الحالتين.

Postman

إذن كيف نختبر عملية الحذف؟ طلبات HTTP GET سهلة الإرسال من المتصفح. يمكننا كتابة بعض JavaScript لاختبار الحذف، لكن كتابة شيفرة اختبار ليست دائماً أفضل حل في كل الحالات.

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

لنثبّت عميل Postman لسطح المكتب من هنا ونجربه:

لقطة شاشة Postman على api/notes/2 ملاحظة: يتوفر Postman أيضاً على VS Code ويمكن تنزيله من تبويب الإضافات على اليسار -> ابحث عن Postman -> النتيجة الأولى (ناشر موثَّق) -> تثبيت سترى بعد ذلك أيقونة إضافية مضافة في شريط النشاط أسفل تبويب الإضافات. وبعد تسجيل الدخول، يمكنك اتباع الخطوات أدناه

استخدام Postman سهل جداً في هذه الحالة. يكفي تعريف عنوان URL ثم اختيار نوع الطلب الصحيح (DELETE).

يبدو أن خادم الواجهة الخلفية يستجيب بشكل صحيح. بإرسال طلب HTTP GET إلى http://localhost:3001/api/notes نرى أن الملاحظة ذات المعرّف 2 لم تعد في القائمة، ما يشير إلى نجاح الحذف.

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

عميل REST في Visual Studio Code

إذا كنت تستخدم Visual Studio Code، يمكنك استخدام إضافة REST client في VS Code بدلاً من Postman.

بمجرد تثبيت الإضافة، يكون استخدامها بسيطاً جداً. ننشئ مجلداً في جذر التطبيق باسم requests. نحفظ جميع طلبات عميل REST في المجلد كملفات تنتهي بامتداد .rest.

لننشئ ملفاً جديداً get_all_notes.rest ونعرّف فيه الطلب الذي يجلب جميع الملاحظات.

ملف rest لجلب جميع الملاحظات مع طلب get على notes

بالنقر على نص Send Request، سينفذ عميل REST طلب HTTP وتُفتح استجابة الخادم في المحرر.

استجابة من VS Code لطلب get

عميل HTTP في WebStorm

إذا كنت تستخدم IntelliJ WebStorm بدلاً من ذلك، يمكنك اتباع إجراء مشابه مع عميل HTTP المدمج فيه. أنشئ ملفاً جديداً بامتداد .rest وسيعرض المحرر خياراتك لإنشاء طلباتك وتشغيلها. يمكنك معرفة المزيد باتباع هذا الدليل.

استقبال البيانات

بعد ذلك، لنجعل إضافة ملاحظات جديدة إلى الخادم ممكنة. تتم إضافة ملاحظة بإرسال طلب HTTP POST إلى العنوان http://localhost:3001/api/notes، وإرسال كل معلومات الملاحظة الجديدة في جسم الطلب بصيغة JSON.

للوصول إلى البيانات بسهولة، نحتاج إلى مساعدة محلل JSON في Express الذي يمكننا استخدامه بالأمر app.use(express.json()).

لنفعّل محلل JSON وننفّذ معالجاً أولياً للتعامل مع طلبات HTTP POST:

const express = require('express')
const app = express()

app.use(express.json())

//...

// highlight-start
app.post('/api/notes', (request, response) => {
  const note = request.body
  console.log(note)

  response.json(note)
})
// highlight-end

يمكن لدالة معالج الحدث الوصول إلى البيانات من خاصية body في كائن request.

بدون محلل JSON، ستكون خاصية body غير معرَّفة. يأخذ محلل JSON بيانات JSON من الطلب، ويحوّلها إلى كائن JavaScript ثم يرفقها بخاصية body في كائن request قبل استدعاء معالج المسار.

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

قبل أن ننفّذ بقية منطق التطبيق، لنتحقق بـ Postman من أن البيانات يستقبلها الخادم فعلاً. بالإضافة إلى تعريف عنوان URL ونوع الطلب في Postman، علينا أيضاً تعريف البيانات المُرسَلة في body:

طلب post في Postman على api/notes مع محتوى post

يطبع التطبيق البيانات التي أرسلناها في الطلب إلى الطرفية:

طرفية تطبع المحتوى المقدم في Postman

ملاحظة: عند برمجة الواجهة الخلفية، أبقِ الطرفية التي تشغّل التطبيق ظاهرة طوال الوقت. سيعيد خادم التطوير التشغيل إذا أُجريت تغييرات على الشيفرة، لذا بمراقبة الطرفية ستلاحظ فوراً ما إذا كان هناك خطأ في شيفرة التطبيق:

خطأ في الطرفية حول SyntaxError

وبالمثل، من المفيد تفقد الطرفية للتأكد من أن الواجهة الخلفية تتصرف كما نتوقع في مواقف مختلفة، مثل إرسال بيانات بطلب HTTP POST. وبالطبع، من الجيد إضافة الكثير من أوامر console.log إلى الشيفرة أثناء تطوير التطبيق.

من الأسباب المحتملة للمشاكل ضبط ترويسة Content-Type بشكل غير صحيح في الطلبات. يمكن أن يحدث هذا مع Postman إذا لم يُعرَّف نوع الجسم بشكل صحيح:

Postman مع text كنوع محتوى

ترويسة Content-Type مضبوطة على text/plain:

Postman يظهر الترويسات وcontent-type كـ text/plain

يبدو أن الخادم يستقبل كائناً فارغاً فقط:

مخرجات الطرفية تظهر أقواساً معقوفة فارغة

لن يتمكن الخادم من تحليل البيانات بشكل صحيح دون القيمة الصحيحة في الترويسة. بل لن يحاول تخمين صيغة البيانات لأن هناك عدداً هائلاً من Content-Types المحتملة.

إذا كنت تستخدم VS Code، فعليك تثبيت عميل REST من الفصل السابق الآن، إن لم تكن قد فعلت ذلك بعد. يمكن إرسال طلب POST بعميل REST هكذا:

نموذج طلب post في VS Code مع بيانات JSON

أنشأنا ملفاً جديداً create_note.rest للطلب. الطلب منسّق وفقاً لـالتعليمات في التوثيق.

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

GET http://localhost:3001/api/notes/

###
POST http://localhost:3001/api/notes/ HTTP/1.1
content-type: application/json

{
    "name": "sample",
    "time": "Wed, 21 Oct 2015 18:27:50 GMT"
}

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

ملاحظة جانبية مهمة

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

قد تحدث مشاكل مع عميل VS REST إذا أضفت عن طريق الخطأ سطراً فارغاً بين السطر الأول والسطر الذي يحدد ترويسات HTTP. في هذه الحالة، يفسر عميل REST ذلك بأن جميع الترويسات تُركت فارغة، ما يؤدي إلى أن خادم الواجهة الخلفية لا يعرف أن البيانات التي استلمها بصيغة JSON.

ستتمكن من اكتشاف ترويسة Content-Type المفقودة إذا قمت في مرحلة ما من شيفرتك بطباعة جميع ترويسات الطلب بالأمر console.log(request.headers).

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

app.post('/api/notes', (request, response) => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => Number(n.id))) 
    : 0

  const note = request.body
  note.id = String(maxId + 1)

  notes = notes.concat(note)

  response.json(note)
})

نحتاج إلى معرّف فريد للملاحظة. أولاً، نجد أكبر رقم معرّف في القائمة الحالية ونسنده إلى المتغير maxId. ثم يُعرَّف معرّف الملاحظة الجديدة كـ maxId + 1 على هيئة نص. لا يُوصى بهذه الطريقة، لكننا سنتعايش معها الآن إذ سنستبدلها قريباً بما يكفي.

لا يزال في الإصدار الحالي مشكلة أن طلب HTTP POST يمكن استخدامه لإضافة كائنات بخصائص عشوائية. لنحسّن التطبيق بتحديد أن خاصية content لا يجوز أن تكون فارغة. وستُمنَح خاصية important قيمة افتراضية هي false. وستُهمَل جميع الخصائص الأخرى:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => Number(n.id)))
    : 0
  return String(maxId + 1)
}

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

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

  const note = {
    content: body.content,
    important: body.important || false,
    id: generateId(),
  }

  notes = notes.concat(note)

  response.json(note)
})

استُخلص منطق توليد رقم المعرّف الجديد للملاحظات في دالة منفصلة generateId.

إذا كانت البيانات المستلمة تفتقد قيمة خاصية content، فسيستجيب الخادم للطلب برمز الحالة 400 bad request:

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

لاحظ أن استدعاء return جوهري، وإلا فستُنفَّذ الشيفرة حتى نهايتها وتُحفظ الملاحظة المشوّهة في التطبيق.

إذا كانت خاصية content تحمل قيمة، فستُبنى الملاحظة على البيانات المستلمة. إذا كانت خاصية important مفقودة، فسنجعل قيمتها الافتراضية false. وتُولَّد القيمة الافتراضية حالياً بطريقة تبدو غريبة نوعاً ما:

important: body.important || false,

إذا كانت البيانات المحفوظة في المتغير body تملك خاصية important وقيمتها صادقة (truthy)، فسيُقيَّم التعبير إلى تلك القيمة. وإذا لم تكن الخاصية موجودة، فستكون قيمتها undefined، وهي كاذبة (falsy)، وبالتالي سيُقيَّم التعبير إلى false المعرفة على الجانب الأيمن من الخطين العموديين.

لكي نكون دقيقين، عندما تكون خاصية important قيمتها false، فإن تعبير body.important || false سيعيد في الواقع القيمة false من الجانب الأيمن. وإذا كانت للخاصية أي قيمة صادقة، فستُعاد تلك القيمة نفسها.

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

لقطة شاشة GitHub لفرع 3-1

إذا استنسخت المشروع، فشغّل الأمر npm install قبل بدء التطبيق بـ npm start أو npm run dev.

أمر آخر قبل أن ننتقل إلى التمارين. تبدو دالة توليد المعرّفات حالياً هكذا:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => Number(n.id)))
    : 0
  return String(maxId + 1)
}

يحتوي جسم الدالة على سطر يبدو مثيراً للفضول نوعاً ما:

Math.max(...notes.map(n => Number(n.id)))

ما الذي يحدث بالضبط في ذلك السطر من الشيفرة؟ notes.map(n => Number(n.id)) ينشئ مصفوفة جديدة تحتوي على جميع معرّفات الملاحظات على هيئة أعداد. وتعيد Math.max القيمة العظمى للأعداد المُمرَّرة إليها. غير أن notes.map(n => Number(n.id)) هي مصفوفة لذا لا يمكن تمريرها مباشرة إلى Math.max. ويمكن تحويل المصفوفة إلى أعداد منفردة باستخدام صيغة النشر «النقاط الثلاث» ....

تمارين 3.1.-3.6.

ملاحظة: بما أن الأمر لا يتعلق بالواجهة الأمامية و React، فإن التطبيق لا يُنشأ بـ Vite، بل بأمر npm init كما ورد سابقاً في هذا الجزء من المادة.

لا تضف مجلد node_modules إلى التحكم بالإصدارات. لا ينشئ الأمر npm init ملف .gitignore تلقائياً، لذا أنشئ واحداً في جذر مشروعك وأضف إليه السطر node_modules. بهذه الطريقة لن يتتبع Git ذلك المجلد في التحكم بالإصدارات.

توصية قوية: عندما تعمل على شيفرة الواجهة الخلفية، أبقِ دائماً عينك على ما يجري في الطرفية التي تشغّل تطبيقك.

3.1: الواجهة الخلفية لدليل الهاتف، الخطوة 1

نفّذ تطبيق Node يعيد قائمة مضمّنة في الشيفرة بمدخلات دليل الهاتف من العنوان http://localhost:3001/api/persons.

البيانات:

[
    { 
      "id": "1",
      "name": "Arto Hellas", 
      "number": "040-123456"
    },
    { 
      "id": "2",
      "name": "Ada Lovelace", 
      "number": "39-44-5323523"
    },
    { 
      "id": "3",
      "name": "Dan Abramov", 
      "number": "12-43-234345"
    },
    { 
      "id": "4",
      "name": "Mary Poppendieck", 
      "number": "39-23-6423122"
    }
]

المخرجات في المتصفح بعد طلب GET:

بيانات JSON لأربعة أشخاص في المتصفح من api/persons

لاحظ أن الشرطة المائلة في المسار api/persons ليست محرفاً خاصاً، بل هي كمحرف أي آخر في النص.

يجب تشغيل التطبيق بالأمر npm start.

يجب أن يوفر التطبيق أيضاً أمر npm run dev يشغّل التطبيق ويعيد تشغيل الخادم كلما أُجريت تغييرات وحُفظت في أحد ملفات الشيفرة المصدرية.

3.2: الواجهة الخلفية لدليل الهاتف، الخطوة 2

نفّذ صفحة على العنوان http://localhost:3001/info تبدو تقريباً هكذا:

لقطة شاشة للتمرين 3.2

يجب أن تعرض الصفحة الوقت الذي استُلم فيه الطلب وعدد المدخلات في دليل الهاتف لحظة معالجة الطلب.

3.3: الواجهة الخلفية لدليل الهاتف، الخطوة 3

نفّذ الوظيفة اللازمة لعرض معلومات مدخل واحد من دليل الهاتف. يجب أن يكون عنوان url للحصول على بيانات الشخص ذي المعرّف 5 هو http://localhost:3001/api/persons/5

إذا لم يُعثر على مدخل بالمعرّف المعطى، فعلى الخادم أن يستجيب برمز الحالة المناسب.

3.4: الواجهة الخلفية لدليل الهاتف، الخطوة 4

نفّذ وظيفة تتيح حذف مدخل واحد من دليل الهاتف بإرسال طلب HTTP DELETE إلى عنوان URL الفريد لذلك المدخل.

اختبر أن وظيفتك تعمل باستخدام Postman أو عميل REST في Visual Studio Code.

3.5: الواجهة الخلفية لدليل الهاتف، الخطوة 5

وسّع الواجهة الخلفية بحيث يمكن إضافة مدخلات جديدة إلى دليل الهاتف بإرسال طلبات HTTP POST إلى العنوان http://localhost:3001/api/persons.

ولّد معرّفاً جديداً لمدخل دليل الهاتف بالدالة Math.random. استخدم نطاقاً كبيراً بما يكفي لقيمك العشوائية بحيث يكون احتمال إنشاء معرّفات مكررة صغيراً.

3.6: الواجهة الخلفية لدليل الهاتف، الخطوة 6

نفّذ معالجة الأخطاء عند إنشاء مدخلات جديدة. لا يُسمح للطلب بالنجاح إذا:

  • كان الاسم أو الرقم مفقوداً
  • كان الاسم موجوداً بالفعل في دليل الهاتف

استجب لطلبات كهذه برمز الحالة المناسب، وأرسل أيضاً معلومات تشرح سبب الخطأ، مثل:

{ error: 'name must be unique' }

عن أنواع طلبات HTTP

يتحدث معيار HTTP عن خاصيتين متعلقتين بأنواع الطلبات، هما السلامة وتساوي الأثر (idempotency).

ينبغي أن يكون طلب HTTP GET آمناً:

على وجه الخصوص، استُقرَّ التقليد على أن طريقتي GET وHEAD ينبغي ألا يكون لهما دلالة تنفيذ إجراء غير الاسترجاع. وينبغي اعتبار هاتين الطريقتين «آمنتين».

تعني السلامة أنه يجب ألا يسبب الطلب المنفَّذ أي آثار جانبية على الخادم. ونعني بالآثار الجانبية أنه يجب ألا تتغير حالة قاعدة البيانات نتيجة للطلب، ويجب ألا تعيد الاستجابة إلا بيانات موجودة أصلاً على الخادم.

لا شيء يمكنه ضمان أن طلب GET آمن، فهذه مجرد توصية محددة في معيار HTTP. وبالتزامنا بمبادئ RESTful في واجهتنا، تُستخدم طلبات GET دائماً بطريقة تجعلها آمنة.

يعرّف معيار HTTP أيضاً نوع الطلب HEAD، الذي ينبغي أن يكون آمناً. عملياً، ينبغي أن يعمل HEAD تماماً مثل GET لكنه لا يعيد شيئاً سوى رمز الحالة وترويسات الاستجابة. لن يُعاد جسم الاستجابة عند إرسال طلب HEAD.

ينبغي أن تكون جميع طلبات HTTP باستثناء POST متساوية الأثر:

يمكن أن تمتلك الطرق أيضاً خاصية «تساوي الأثر» بمعنى أن (بصرف النظر عن مشاكل الأخطاء أو انتهاء الصلاحية) الآثار الجانبية لـ N > 0 من الطلبات المتطابقة تكون نفسها كتلك الخاصة بطلب واحد. وتشترك الطرق GET وHEAD وPUT وDELETE في هذه الخاصية

هذا يعني أنه إذا ولّد طلب ما آثاراً جانبية، فينبغي أن تكون النتيجة نفسها بصرف النظر عن عدد مرات إرسال الطلب.

إذا أرسلنا طلب HTTP PUT إلى العنوان /api/notes/10 وأرسلنا مع الطلب البيانات { content: "no side effects!", important: true }، فالنتيجة نفسها بصرف النظر عن عدد مرات إرسال الطلب.

ومثل السلامة في طلب GET، فإن تساوي الأثر مجرد توصية في معيار HTTP وليس شيئاً يمكن ضمانه بمجرد الاعتماد على نوع الطلب. غير أنه عندما تلتزم واجهتنا بمبادئ RESTful، تُستخدم طلبات GET وHEAD وPUT وDELETE بطريقة تجعلها متساوية الأثر.

POST هو نوع طلب HTTP الوحيد الذي ليس آمناً ولا متساوي الأثر. إذا أرسلنا 5 طلبات HTTP POST مختلفة إلى /api/notes بجسم {content: "many same", important: true}، فستحمل الملاحظات الخمس الناتجة على الخادم المحتوى نفسه جميعاً.

الوسيط

إن محلل JSON في Express الذي استخدمناه سابقاً هو وسيط.

الوسيط دوال يمكن استخدامها للتعامل مع كائني request و_response_.

يأخذ محلل JSON الذي استخدمناه سابقاً البيانات الخام من الطلبات المخزنة في كائن request، ويحللها إلى كائن JavaScript ويسندها إلى كائن request كخاصية جديدة body.

عملياً، يمكنك استخدام عدة وسائط في الوقت نفسه. وعندما يكون لديك أكثر من واحد، فتُنفَّذ واحداً تلو الآخر بالترتيب الذي أُدرجت به في شيفرة التطبيق.

لننفّذ وسيطنا الخاص الذي يطبع معلومات عن كل طلب يُرسَل إلى الخادم.

الوسيط دالة تستقبل ثلاثة معاملات:

const requestLogger = (request, response, next) => {
  console.log('Method:', request.method)
  console.log('Path:  ', request.path)
  console.log('Body:  ', request.body)
  console.log('---')
  next()
}

في نهاية جسم الدالة، تُستدعى الدالة next التي مُرِّرت كمعامل. وتسلم دالة next التحكم إلى الوسيط التالي.

يُستخدم الوسيط هكذا:

app.use(express.json())
app.use(requestLogger)

تذكر أن دوال الوسيط تُستدعى بالترتيب الذي يصادفها به محرك JavaScript. لاحظ أن json-parser مُدرَج قبل requestLogger، وإلا فلن تتم تهيئة request.body عند تنفيذ المسجِّل!

يجب استخدام دوال الوسيط قبل المسارات عندما نريد أن تنفذها معالجات أحداث المسار. وأحياناً نريد استخدام دوال الوسيط بعد المسارات. نفعل ذلك عندما نريد ألا تُستدعى دوال الوسيط إلا إذا لم يعالج أي معالج مسار طلب HTTP.

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

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

app.use(unknownEndpoint)

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

تمارين 3.7.-3.8.

3.7: الواجهة الخلفية لدليل الهاتف، الخطوة 7

أضف وسيط morgan إلى تطبيقك للتسجيل. واضبطه ليسجّل الرسائل في طرفيتك وفق الإعداد tiny.

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

يُثبَّت Morgan تماماً مثل جميع المكتبات الأخرى بالأمر npm install. ويتم أخذه في الاستخدام بالطريقة نفسها التي تُضبط بها أي وسيط آخر باستخدام الأمر app.use.

3.8*: الواجهة الخلفية لدليل الهاتف، الخطوة 8

اضبط morgan بحيث يعرض أيضاً البيانات المُرسَلة في طلبات HTTP POST:

طرفية تظهر إرسال بيانات post

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

قد يكون هذا التمرين صعباً نوعاً ما، رغم أن الحل لا يتطلب الكثير من الشيفرة.

يمكن إنجاز هذا التمرين ببضع طرق مختلفة. أحد الحلول الممكنة يستخدم هاتين التقنيتين: