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

بيئة Node.js: استخدام جافاسكربت خارج المتصفح


أسامة دمراني
اقتباس

سأل أحد الطلاب: "استخدم المبرمجون قديمًا حواسيب بدائية ولم يستخدموا لغات برمجة، لكنهم أنتجوا برامج رائعة رغم هذا فلماذا كل هذا التعقيد الذي نراه اليوم في البرمجة ولغات البرمجة؟".

رد فو-تزو: استخدم البنّاءون الحطب والطين قديمًا، لكنهم بنوا رغم ذلك أكواخًا رائعةً.

_ يوان-ما، كتاب البرمجة.

chapter_picture_20.jpg

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

لن تعمل أمثلة الشيفرات التي ستكون في هذا المقال في المتصفح على عكس المقالات السابقة، فهي ليست جافاسكربت خام وليست مكتوبةً للمتصفح وإنما مكتوبة من أجل Node، فإذا أردت تشغيل هذه الشيفرات فستحتاج إلى تثبيت Node.js الإصدار 10.1 أو الأحدث بالذهاب إلى الموقع https://nodejs.org واتباع إرشادات التثبيت لنظام تشغيلك، كما ستجد هناك توثيقًا أكثر تفصيلًا عن Node.js.

تقديم إلى Node

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

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

أمر node

توفِّر Node.js عند تثبيتها على النظام برنامجًا اسمه node يُستخدَم لتشغيل ملفات جافاسكربت، فلنقل مثلًا أنه لدينا ملفًا اسمه hello.js يحتوي الشيفرة التالية:

let message = "Hello world";
console.log(message);

نستطيع تشغيل node من سطر الأوامر كما يلي لتنفيذ البرنامج:

$ node hello.js
Hello world

ينفِّذ التابع console.log شيئًا شبيهًا بما يفعله في المتصفح أي سيطبع جزءًا من النص، لكن سيذهب النص في Node إلى مجرى الخرج القياسي للعملية بدلًا من منصة جافاسكربت التي في المتصفح، وهذا يعني أننا سنرى القيم المسجَّلة في طرفيتنا؛ أما إذا شغّلت node دون إعطائها ملفًا، فستزودك بمحث prompt تستطيع كتابة شيفرة جافاسكربت فيه وترى نتيجتها مباشرةً.

$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$

تكون الرابطة process متاحةً عمومًا في Node شأنها في ذلك شأن الرابطة console، حيث توفِّر طرقًا مختلفةً لفحص وتعديل البرنامج الحالي؛ أما التابع exit فينهي العملية ويمكن إعطائه رمز حالة خروج تخبر البرنامج الذي بدأ node -وهي صدفية سطر الأوامر في هذه الحالة- هل اكتمل البرنامج بنجاح - أي الرمز صفر- أم قابل خطأ -أي رمز آخر-.

تستطيع قراءة process.argv لإيجاد الوسائط التي أُعطيت للسكربت الخاصة بك والتي هي مصفوفة من سلاسل نصية، لاحظ أنها تتضمن أمر node نفسه واسم السكربت الخاص بك، وبالتالي تبدأ الوسائط الحقيقية عند الفهرس 2، فإذا احتوى showargv.js على التعليمة ‎console.log(process.argv)‎، فستستطيع تشغيلها كما يلي:

$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]

توجد جميع رابطات جافاسكربت العامة مثل Array وMath وJSON في بيئة Node على عكس الوظائف المتعلِّقة بالمتصفح مثل document وprompt.

الوحدات Modules

تضيف Node بعض الرابطات الإضافية في النطاق العام global scope على الرابطات التي ذكرناها قبل قليل، فإذا أردت الوصول إلى الوظائف المضمَّنة، فيمكنك طلب ذلك من نظام الوحدات module system، وقد ذكرنا نظام وحدات CommonJS المبني على دالة require في مقال الوحدات Modules في جافاسكريبت المشار إليه أعلاه؛ أما هذا النظام فقد دُمِج في Node ويُستخدَم لتحميل أيّ شيء بدءًا من الوحدات المضمَّنة إلى الحزم المحمَّلة إلى الملفات التي هي جزء من برنامجك.

يجب أن تحل Node السلسلة النصية المعطاة إلى ملف حقيقي يمكن تحميله عند استدعاء require، كما تُحَل أسماء المسارات التي تبدأ بـ / و‎./‎ و‎../‎ نسبيًا إلى مسار الوحدة الحالية؛ أما . فتشير إلى المجلد الحالي وتشير ‎../‎ إلى مجلد واحد للأعلى وتشير / إلى جذر نظام الملفات، فإذا طلبنا ‎"./graph"‎ من الملف ‎/tmp/robot/robot.js‎، فستحاول Node تحميل الملف ‎/tmp/robot/graph.js.

يمكن إهمال الامتداد ‎.js‎، كما ستضيفه Node تلقائيًا إذا وُجد ملف بذلك الاسم، فإذا أشار المسار المطلوب إلى مجلد، فستحاول Node تحميل الملف الذي يكون اسمه index.js في ذلك المجلد، وإذا أعطينا سلسلةً نصيةً لا تبدو مسارًا نسبيًا أو مطلقًا إلى الدالة require، فستفترض أنها تشير إما إلى وحدة مضمَّنة أو وحدة مثبَّتة في المجلد node_modules، كما تعطينا ‎require("fs")‎ مثلًا وحدة نظام الملفات المضمَّن في Node؛ أما require("robot")‎‎ فستحاول تحميل المكتبة الموجودة في ‎node_modules/robot/‎، ويمكن تثبيت مثل تلك المكتبات باستخدام NPM الذي سنعود إليه بعد قليل.

لنعدّ الآن مشروعًا صغيرًا يتكون من ملفين، الأول اسمه main.js بحيث يعرّف سكربت يمكن استدعاؤها من سطر الأوامر لعكس سلسلة نصية.

const {reverse} = require("./reverse");

// Index 2 holds the first actual command line argument
let argument = process.argv[2];

console.log(reverse(argument));

يعرِّف الملف reverse.js مكتبةً لعكس السلاسل النصية التي يمكن استخدامها بواسطة أداة سطر الأوامر هذه وكذلك بواسطة السكربتات الأخرى التي تحتاج إلى وصول مباشر إلى دالة عكس سلاسل نصية.

exports.reverse = function(string) {
  return Array.from(string).reverse().join("");
};

تذكَّر أنّ إضافة الخصائص إلى exports يضيفها إلى واجهة الوحدة، وبما أنّ Node.js تعامل الملفات على أساس وحدات CommonJS، فيمكن أن تأخذ main.js دالة reverse المصدَّرة من reverse.js، ونستطيع الآن استدعاء أداتنا كما يلي:

$ node main.js JavaScript
tpircSavaJ

التثبيت باستخدام NPM

تعرَّضنا إلى مستودع NPM في مقال الوحدات Modules في جافاسكريبت وهو مستودع لوحدات جافاسكربت، وقد كُتب الكثير منها من أجل Node، فإذا ثبَّت Node على حاسوبك، فستستطيع الحصول على أمر npm الذي يمكن استخدامه للتفاعل مع ذلك المستوع، والغرض الأساسي من NPM هو تحميل الحِزم، حيث نستطيع استخدامه لجلب وتثبيت حزمة ini التي رأيناها في من قبل على حاسوبنا:

$ npm install ini
npm WARN enoent ENOENT: no such file or directory,
         open '/tmp/package.json'
+ ini@1.3.5
added 1 package in 0.552s

$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }

يجب أن ينشئ NPM مجلدًا اسمه node_modules بعد تشغيل npm install، حيث سيكون مجلد ini بداخل ذلك المجلد محتويًا على المكتبة التي يمكن فتحها والاطلاع على شيفرتها، وتُحمَّل تلك المكتبة عند استدعاء ‎require("ini")‎، كما نستطيع استدعاء الخاصية parse الخاصة بها لتحليل ملف الإعدادات.

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

ملفات الحزم

تستطيع رؤية تحذير في مثال npm install أنّ الملف package.json غير موجود، كما يُنصح بإنشاء مثل هذا الملف لكل مشروع إما يدويًا أو عبر تشغيل npm init، حيث يحتوي على بعض معلومات المشروع مثل اسمه وإصداره ويسرد اعتمادياته، ولنضرب مثلًا هنا بمحاكاة الروبوت من مقال مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت التي عدلنا عليها عند تعرضنا للوحدات في مقال الوحدات Modules في جافاسكريبت، إذ قد يبدو ملف package.json الخاص بها كما يلي:

{
  "author": "Marijn Haverbeke",
  "name": "eloquent-javascript-robot",
  "description": "Simulation of a package-delivery robot",
  "version": "1.0.0",
  "main": "run.js",
  "dependencies": {
    "dijkstrajs": "^1.0.1",
    "random-item": "^1.0.0"
  },
  "license": "ISC"
}

عند تشغيل npm install دون تسمية الحزمة المراد تثبيتها، فسيثبِّت NPM الاعتماديات التي يسردها package.json؛ أما إذا ثبَّتت حزمةً ما ليست موجودة على أساس اعتمادية، فسيضيفها NPM إلى package.json.

الإصدارات

يسرد ملف package.json كلًا من إصدار البرنامج وإصدارات اعتمادياته، والإصدارات هي أسلوب للتعامل مع التطور المنفصل للحِزم، فقد لا تعمل الشيفرة التي كُتبت لحزمة في وقت ما مع الإصدار الجديد والمعدل من تلك الحزمة، حيث يشترط NPM أن تتبع الحِزم الخاصة به نظامًا اسمه الإصدار الدلالي semantic versioning الذي يرمّز بعض المعلومات عن الإصدارات المتوافقة التي لا تعطل الواجهة القديمة في رقم الإصدار version number، حيث يتكون الإصدار الدلالي من ثلاثة أعداد مفصولة بنقاط مثل 2.3.0، وكل مرة تضاف فيها ميزة جديدة فإننا نزيد العدد الأوسط، وكل مرة تُعطل فيها التوافقية بحيث لا تعمل الشيفرة الحالية التي تستخدِم الحزمة مع إصدارها الجديد فإننا نغير العدد الأول من اليسار.

يوضِّح محرف الإقحام ^ الذي يكون أمام رقم إصدار الاعتمادية في package.json أنّ أيّ نسخة متوافقة مع الرقم المعطى يمكن تثبيتها، وعلى ذلك تعني ‎"^2.3.0"‎ أنّ أيّ نسخة أكبر من أو يساوي 2.3.0 وأقل من 3.0.0 مسموح بها، كما يُستخدَم أمر npm أيضًا لنشر حزم جديدة أو إصدارات جديدة من الحزم، فإذا شغّلنا npm publish في مجلد فيه ملف package.json، فسينشر حزمةً بالاسم والإصدار الموجودَين في ملف JSON إلى السجل registry، ويستطيع أيّ أحد نشر حزم في NPM لكن شرط أن يكون اسم الحزمة غير مستخدَم من قبل.

ليس ثمة شيء فريد في وظيفته بما أنّ برنامج npm جزء برمجي يتواصل مع نظام مفتوح هو سجل الحزم، ويمكن تثبيت برنامج آخر مثل yarn من سجل NPM ليؤدي وظيفة npm نفسها باستخدام واجهة مختلفة قليلًا وكذلك استراتيجية تثبيت مختلفة، لكن هذه السلسلة لا تتعمق في استخدام NPM، وإنما ننصحك بالرجوع إلى npmjs.org لمزيد من التوثيق والبحث عن الحزم.

وحدة نظام الملفات

إحدى الوحدات المضمَّنة والمستخدَمة بكثرة في Node هي وحدة fs التي تشير إلى نظام الملفات file system، إذ تصدِّر الدوال من أجل العمل مع الملفات والمجلدات، حيث تقرأ مثلًا الدالة readFile ملفًا وتستدعي رد نداء بمحتويات الملف كما يلي:

let {readFile} = require("fs");
readFile("file.txt", "utf8", (error, text) => {
  if (error) throw error;
  console.log("The file contains:", text);
});

يشير الوسيط الثاني لدالة readFile إلى ترميز المحارف المستخدَم لفك تشفير الملف إلى سلسلة نصية، ورغم وجود عدة طرق لتشفير النصوص إلى بيانات ثنائية إلا أنّ أغلب النظم الحديثة تستخدِم UTF-8، فإذا لم يكن لديك سبب يجعلك تفضل ترميزًا آخر غير هذا فإنك تستخدمه، وعليه تمرر "utf8" عند قراءة ملف نصي، فإذا لم تمرر ترميزًا، فستفترض Node أنك تريد البيانات الثنائية وستعطيك الكائن Buffer بدلًا من سلسلة نصية، وهو كائن شبيه بالمصفوفة يحتوي أعدادًا تمثل البايتات (قطع بيانات بحجم 8 بت) التي في الملف.

const {readFile} = require("fs");
readFile("file.txt", (error, buffer) => {
  if (error) throw error;
  console.log("The file contained", buffer.length, "bytes.",
              "The first byte is:", buffer[0]);
});

تُستخدَم الدالة writeFile لكتابة ملف إلى القرص الصلب كما يلي:

const {writeFile} = require("fs");
writeFile("graffiti.txt", "Node was here", err => {
  if (err) console.log(`Failed to write file: ${err}`);
  else console.log("File written.");
});

ليس من الضروري هنا تحديد الترميز، إذ ستفترض الدالة عند إعطائها سلسلة نصية أنّ عليها كتابتها على أساس نص باستخدام ترميزها الافتراضي للمحارف -أي UTF-8- ما لم يكن كائن Buffer.

تحتوي الوحدة fs على عدة دوال أخرى مفيدة مثل readdir التي ستعيد الملفات الموجودة في مجلد على أساس مصفوفة من السلاسل النصية، وstat التي ستجلب معلومات عن ملف ما وrename التي ستعيد تسمية الملف وunlink التي ستحذِف الملفات وهكذا، ولمزيد من التفاصيل انظر توثيق Node، كما تأخذ أغلب الدوال السابقة دالة رد نداء على أساس آخر معامِل لها وتستدعيها إما مع خطأ -أي أول وسيط- أو مع نتيجة ناجحة -أي ثاني وسيط-، ورأينا في مقال البرمجة غير المتزامنة في جافاسكريبت وجود تبعات لمثل هذا التنسيق من البرمجة لعل أكبرها أن عملية معالجة الأخطاء نفسها تصبح طويلة وعرضة للخطأ.

لا زال تكامل الوعود مع Node قيد التطوير وقت كتابة هذه الكلمات رغم أنها أُدخلت في جافاسكربت منذ مدة لا بأس بها، فلدينا الكائن promises المصدَّر من حزمة fs منذ الإصدار 10.1 والذي يحتوي على أغلب الدوال الموجودة في fs لكنه يستخدِم الوعود بدلًا من دوال رد النداء.

const {readFile} = require("fs").promises;
readFile("file.txt", "utf8")
  .then(text => console.log("The file contains:", text));

قد لا نحتاج أحيانًا إلى اللاتزامنية، بل قد تعيق عملنا، ولحسن حظنا أنّ أغلب الدوال التي في fs نسخة تزامنية لها الاسم نفسه مع لاحقة Sync مضافة إلى آخرها، فسيكون اسم النسخة التزامنية من دالة readFile مثلًا readFileSync.

const {readFileSync} = require("fs");
console.log("The file contains:",
            readFileSync("file.txt", "utf8"));

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

وحدة HTTP

لدينا وحدة مركزية أخرى توفِّر وظيفة تشغيل خوادم HTTP وإنشاء طلبات HTTP كذلك واسمها http، وإذا أردنا تشغيل خادم HTTP فسيكون ذلك عبر السكربت التالية:

const {createServer} = require("http");
let server = createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/html"});
  response.write(`
    <h1>Hello!</h1>
    <p>You asked for <code>${request.url}</code></p>`);
  response.end();
});
server.listen(8000);
console.log("Listening! (port 8000)");

فإذا شغّلت هذه السكربت على الحاسوب، فستوجِّه المتصفح إلى http://localhost:8000/hello لإنشاء طلب إلى خادمك، وسيستجيب بصفحة HTML صغيرة، كما تُستدعى الدالة الممررة على أساس وسيط إلى createServer في كل مرة يتصل عميل بالخادم، كذلك تُعَدّ الرابطتان request وresponse كائنين يمثلان البيانات الواردة والصادرة، حيث يحتوي الأول على معلومات عن الطلب مثل خاصية url الخاصة به والتي تخبرنا بالرابط URL الذي أُنشئ الطلب إليه، لذلك عندما نفتح تلك الصفحة في المتصفح فإنها ترسل طلبًا إلى حاسوبك، وهذا يشغّل دالة الخادم ويرجع استجابةً نراها في المتصفح.

أما لإرجاع شيء من طرفنا فستستدعي عدة توابع على الكائن response، أولها هو التابع writeHead الذي يكتب ترويسات الاستجابة -انظر مقال HTTP والاستمارات في جافاسكربت-، وتعطيه شيفرة الحالة -أي 200 التي تعني OK في هذه الحالة-، وكائنًا يحتوي على قيم الترويسة، ثم يعيِّن المثال ترويسة Content-Type لتخبر العميل أننا نرسل مستند HTML إليه، ثم يُرسَل متن الاستجابة الفعلية -أي المستند نفسه- باستخدام response.write، ويُسمح لنا باستدعاء هذا التابع عدة مرات إذا أردنا إرسال الاستجابة جزءًا جزءًا، كما في حالة بث البيانات إلى العميل كلما صارت متاحةً على سبيل المثال، ثم تشير response.end إلى نهاية الاستجابة.

يتسبب استدعاء server.listen في جعل الخادم ينتظر اتصالًا على المنفَذ 8000 وهذا هو السبب الذي يجبرنا على الاتصال بـ localhost:8000 من أجل التواصل مع هذا الخادم بدلًا من localhost فقط والتي ستستخدِم المنفَذ 80، وتظل العملية في وضع انتظار عند تشغيل هذه السكربت، ولن تخرج node تلقائيًا عندما تصل إلى نهاية السكربت بما أنها تظل في وضع استماع إلى الإحداث وانتظارها والتي هي اتصالات الشبكة في هذه الحالة، كما نضغط control+c من أجل إغلاقها، وكان هذا الخادم مثالًا فقط، وإلا فإنّ خادم الويب الحقيقي يفعل أكثر من ذلك، فهو ينظر في تابع الطلب -أي الخاصية method- ليرى الإجراء الذي يحاول العميل تنفيذه، وينظر في رابط الطلب كي يعرف المورد الذي ينفَّذ عليه ذلك الإجراء، وسنرى لاحقًا في هذا المقال خادمًا أكثر تقدمًا وتعقيدًا.

يمكننا استخدام الدالة request في وحدة http من أجل التصرف على أساس عميل HTTP.

const {request} = require("http");
let requestStream = request({
  hostname: "eloquentjavascript.net",
  path: "/20_node.html",
  method: "GET",
  headers: {Accept: "text/html"}
}, response => {
  console.log("Server responded with status code",
              response.statusCode);
});
requestStream.end();

يهيئ الوسيط الأول للدالة request الطلب ليخبر Node بالخادم الذي يجب التواصل معه والمسار الذي تطلبه من ذلك الخادم وأيّ تابع يجب استخدامه وهكذا؛ أما الوسيط الثاني فيكون الدالة التي يجب أن تستدعَى عندما تأتي الاستجابة، وتعطى كائنًا يسمح لنا بفحص الاستجابة، لمعرفة رمز حالتها مثلًا، كما يسمح الكائن الذي تعيده request ببث البيانات في الطلب باستخدام التابع write كما في كائن response الذي رأيناه في الخادم وتنهي الطلب بالتابع end، ولا يستخدم المثال write لأن طلبات GET يجب ألا تحتوي على بيانات في متونها.

لدينا دالة request مشابهةً في وحدة https، حيث يمكن استخدامها لإنشاء طلبات إلى روابط ‎https:‎، ولا شك أنّ إنشاء الطلبات باستخدام Node الخام أمر طويل مسهب، لهذا توجد حزم تغليف سهلة الاستخدام متاحة في NPM مثل node-fetch التي توفر واجهة fetch مبنيةً على الوعود التي عرفناها من المتصفح.

البث Stream

رأينا نسختين من البث القابل للكتابة writable stream في مثالَي HTTP، هما كائن الاستجابة الذي يستطيع الخادم كتابته، وكائن الطلب الذي أعادته request، حيث يُستخدَم البث القابل للكتابة كثيرًا في Node، فمثل تلك الكائنات لها تابع اسمه write يُمكن تمرير سلسلة نصية إليه، أو كائن Buffer لكتابة شيء في البث؛ أما التابع end فيغلق البث ويأخذ قيمةً -بصورة اختيارية- للكتابة في البث قبل الإغلاق، ويمكن إعطاء كلا التابعَين السابقًين رد نداء على أساس وسيط إضافي، حيث يستدعيانه عند انتهاء الكتابة أو الإغلاق.

يمكن إنشاء بث قابل للكتابة يشير إلى ملف باستخدام دالة createWriteStream من وحدة fs، ثم يمكنك استخدام التابع write على الكائن الناتج من أجل كتابة الملف جزءًا واحدًا في كل مرة بدلًا من كتابته على مرة واحدة كما في writeFile؛ أما البث القابل للقراءة ففيه تفصيل أكثر، فرابطة request التي مُرِّرت إلى الاستدعاء الخلفي لخادم HTTP قابلة للقراءة، وكذلك رابطة response الممررة إلى رد نداء العميل HTTP. حيث يقرأ الخادم الطلبات ثم يكتب الاستجابات، بينما يكتب العميل الطلب أولًا ثم يقرأ الاستجابة، وتتم القراءة من البث باستخدام معالجات الأحداث بدلًا من التوابع.

تملك الكائنات التي تطلق الأحداث في Node تابعًا اسمه on يشبه التابع addEventListener الموجود في المتصفح، كما يمكن إعطاؤه اسم حدث ثم دالة، وسيسجل تلك الدالة لتُستدعى كلما وقع ذلك الحدث، كذلك فإن البث القابل للقراءة له حدثان هما "data" و"end"، حيث يُطلَق الأول في كل مرة تأتي بيانات فيها؛ أما الثاني فيُستدعى كلما كان البث عند نهايته، وهذا النموذج مناسب لبث البيانات التي يمكن معالجتها فورًا حتى لو كان باقي المستند غير متاح بعد، كما يُقرأ الملف على أساس بث قابل للقراءة من خلال استخدام دالة createReadStream من وحدة fs، وتنشئ الشيفرة التالية خادمًا يقرأ متون الطلبات ويبثها مرةً أخرى إلى العميل على أساس نص حروفه من الحالة الكبيرة:

const {createServer} = require("http");
createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/plain"});
  request.on("data", chunk =>
    response.write(chunk.toString().toUpperCase()));
  request.on("end", () => response.end());
}).listen(8000);

ستكون القيمة chunk الممررة إلى معالج البيانات على هيئة Buffer ثنائي، ويمكن تحويل ذلك إلى سلسلة نصية بفك تشفيرها على أساس محارف UTF-8 مرمزة باستخدام التابع toString، منا ترسب الشيفرة التالية عند تشغيلها أثناء نشاط خادم الحروف الكبيرة طلبًا إلى ذلك الخادم وتكتب الاستجابة التي تحصل عليها:

const {request} = require("http");
request({
  hostname: "localhost",
  port: 8000,
  method: "POST"
}, response => {
  response.on("data", chunk =>
    process.stdout.write(chunk.toString()));
}).end("Hello server");
// → HELLO SERVER

يكتب المثال إلى process.stout والذي يُعَدّ خرجًا قياسيًا للعملية وبثًا قابلًا للكتابة، بدلًا من استخدام console.log، إذ لا نستطيع استخدام console.log لأنها تضيف محرف سطر جديد إضافي بعد كل جزء تكتبه من النص، وهذا ليس مناسبًا هنا بما أنّ الاستجابة قد تأتي في هيئة عدة كتل نصية.

خادم الملفات

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

فإذا شغّلنا الخادم من ‎/tmp/public/‎ أو على ويندوز من ‎C:\tmp\public\‎، فسيشير طلب ‎/file.txt‎ إلى ‎‎/tmp/public/file.txt أو ‎C:\tmp\public\file.txt‎ على ويندوز، كما سنبني البرنامج جزءًا جزءًا مستخدِمين الكائن methods لتخزين الدوال التي تعالج توابع HTTP المختلفة، وتكون معالجات التوابع دوال async تحصل على كائن الطلب على أساس وسيط وتعيد وعدًا يُحل إلى كائن يصف الاستجابة.

const {createServer} = require("http");

const methods = Object.create(null);

createServer((request, response) => {
  let handler = methods[request.method] || notAllowed;
  handler(request)
    .catch(error => {
      if (error.status != null) return error;
      return {body: String(error), status: 500};
    })
    .then(({body, status = 200, type = "text/plain"}) => {
       response.writeHead(status, {"Content-Type": type});
       if (body && body.pipe) body.pipe(response);
       else response.end(body);
    });
}).listen(8000);

async function notAllowed(request) {
  return {
    status: 405,
    body: `Method ${request.method} not allowed.`
  };
}

يبدأ هذا خادمًا لا يعيد إلا استجابات خطأ 405، وهو الرمز الذي يشير إلى رفض الخادم لمعالجة التابع المعطى.

يحوِّل استدعاء catch عند رفض وعد معالِج الطلب الخطأ إلى كائن استجابة إذا لم يكن هو كائن استجابة بالفعل، وذلك كي يستطيع الخادم إرسال استجابة خطأ مرةً أخرى لإخبار العميل أنه فشل في معالجة الطلب، كما يمكن إهمال حقل status في وصف الاستجابة وتكون حينئذ 200 افتراضيًا -وهي التي تعني OK-؛ أما نوع المحتوى في الخاصية type فيمكن إهماله كذلك، ويفترض حينها أنّ الاستجابة نص مجرد.

حين تكون قيمة body بثًا قابلًا للقراءة فسيحتوي على التابع pipe الذي يُستخدَم لإعادة توجيه كل المحتوى من بث قابل للقراءة إلى بث قابل للكتابة، وإلا فيُفترض أنه إما null -أي لا شيء- أو سلسلةً نصيةً أو مخزنًا مؤقتًا buffer، ويُمرَّر مباشرة إلى التابع end الخاص بالاستجابة، كما تستخدِم الدالة urlPath وحدة url المضمَّنة لتحليل الرابط من أجل معرفة مسار الملف المتوافق مع رابط الطلب، وهي تأخذ اسم المسار الذي يكون شيئًا مثل ‎"/file.txt"‎ وتفك تشفيره لتتخلص من رموز التهريب التي على شاكلة ‎%20‎ وتحله نسبة إلى المجلد العامل للبرنامج.

const {parse} = require("url");
const {resolve, sep} = require("path");

const baseDirectory = process.cwd();

function urlPath(url) {
  let {pathname} = parse(url);
  let path = resolve(decodeURIComponent(pathname).slice(1));
  if (path != baseDirectory &&
      !path.startsWith(baseDirectory + sep)) {
    throw {status: 403, body: "Forbidden"};
  }
  return path;
}

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

تُعَدّ مسارات الملفات سلاسل نصية في Node، حيث يلزمنا قدر لا بأس به من التفسير interpretation لربط مثل تلك السلسلة النصية بملف حقيقي، فقد تحتوي المسارات على ‎../‎ لتشير إلى المجلد الأب، وعليه يكون أحد المصادر البدهية للمشكلة هي طلبات إلى مسارات مثل ‎‎/../secret_file، ومن أجل تجنب مثل تلك المشاكل تستخدِم urlPath الدالة resolve من وحدة path التي تحل الروابط النسبية، ثم تتحقق من كون النتيجة تحت المجلد العامل دائمًا، كما يمكن استخدام الدالة process.cwd لإيجاد ذلك المجلد العامل، حيث تشير cwd إلى المجلد العامل الحالي أو current working directory.

أما رابطة sep من حزمة path فهي فاصلة مسار النظام، وهي شرطة مائلة خلفية على ويندوز وأمامية على أغلب نظم التشغيل الأخرى، فإذا لم يبدأ المسار بالمجلد الرئيسي فسترفع الدالة كائن استجابة خطأ باستخدام رمز حالة HTTP يشير إلى استحالة الوصول إلى المورد، وسنضبط التابع GET كي يعيد قائمةً من الملفات عند قراءة مجلد ويعيد محتوى الملف عند قراءة ملف عادي؛ أما السؤال الذي يطرح نفسه هنا هو نوع ترويسة Content-Type التي يجب تعيينها عند إعادة محتوى الملف، فبما أن تلك الملفات قد تكون أيّ شيء فلا يستطيع الخادم إعادة نوع المحتوى نفسه في كل مرة، ونستخدِم هنا NPM مرةً أخرى، إذ تعرف حزمة mime النوع الصحيح لعدد كبير من امتدادات الملفات، كما تسمى موضحات أنواع المحتوى مثل text/plain باسم mime، يثبّت الأمر npm أدناه إصدارًا محددًا من mime في المجلد الذي فيه سكربت الخادم:

$ npm install mime@2.2.0

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

const {createReadStream} = require("fs");
const {stat, readdir} = require("fs").promises;
const mime = require("mime");

methods.GET = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 404, body: "File not found"};
  }
  if (stats.isDirectory()) {
    return {body: (await readdir(path)).join("\n")};
  } else {
    return {body: createReadStream(path),
            type: mime.getType(path)};
  }
};

تكون stat لا تزامنيةً لأنها ستحتاج أن تتعامل مع القرص وستأخذ وقتًا لذلك، وبما أننا نستخدِم الوعود بدلًا من تنسيق رد النداء فيجب استيراده من promises بدلًا من fs مباشرةً، حيث ترفع stat كائن خطأ به الخاصية code لـ "ENOENT" إذا لم يكن الملف موجودًا، وإذا بدت هذه الرموز غريبةً عليك لأول وهلة فاعلم أنها متأثرة بأسلوب نظام يونكس، كما ستجد أنواع الخطأ في Node على مثل هذه الشاكلة.

يخبرنا الكائن stats الذي تعيده stat بمعلومات عديدة عن الملف مثل حجمه -أي الخاصية size- وتاريخ التعديل عليه -أي الخاصية mtime- وهل هذا الملف مجلد أم ملف عادي من خلال التابع isDirectory، كما نستخدِم readdir لقراءة مصفوفة ملفات في المجلد ونعيدها إلى العميل؛ أما بالنسبة للملفات العادية فسننشئ بثًا قابلًا للقراءة باستخدام createReadStream ونعيده على أنه المتن مع نوع المحتوى الذي تعطينا إياه الحزمة mime لاسم الملف، كما تكون الشيفرة التي تعالج طلبات DELETE أبسط قليلًا.

const {rmdir, unlink} = require("fs").promises;

methods.DELETE = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 204};
  }
  if (stats.isDirectory()) await rmdir(path);
  else await unlink(path);
  return {status: 204};
};

إذا لم تحتوي استجابة HTTP على أيّ بيانات فيمكن استخدام رمز الحالة 204 ("لا محتوى") لتوضيح ذلك، وهو الخيار المنطقي هنا بما أنّ الاستجابة للحذف لا تحتاج أيّ معلومات أكثر من تأكيد نجاح العملية، لكن لماذا نحصل على رمز حالة يفيد النجاح عند محاولة حذف ملف غير موجود أصلًا؟ أليس من المنطقي أن نحصل على خطأ؟

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

const {createWriteStream} = require("fs");

function pipeStream(from, to) {
  return new Promise((resolve, reject) => {
    from.on("error", reject);
    to.on("error", reject);
    to.on("finish", resolve);
    from.pipe(to);
  });
}

methods.PUT = async function(request) {
  let path = urlPath(request.url);
  await pipeStream(request, createWriteStream(path));
  return {status: 204};
};

لسنا في حاجة إلى التحقق من وجود الملف هذه المرة، فإذا كان موجودًا فسنكتب فوقه، ونستخدِم pipe هنا مرةً أخرى لنقل البيانات من البث القابل للقراءة إلى بث قابل للكتابة، وفي حالتنا هذه من الطلب إلى الملف، لكن بما أنّ pipe ليست مكتوبةً لتعيد وعدًا، فعلينا كتابة مغلِّف هو pipeStream الذي ينشئ وعدًا حول ناتج استدعاء pipe، كما سيعيد createWriteStream بثًا إذا حدث خطأ أثناء فتح الملف لكن سيطلق ذلك البث حدث "error"، وقد يفشل البث من الطلب كما في حالة انقطاع الشبكة، لذا فإننا نوصل الحدثين "error" لكلا البثين كي يرفضا الوعد.

سيغلق بث الخرج الذي يتسبب في إطلاق الحدث "finish" عند انتهاء pipe، وهي النقطة التي يمكننا حل الوعد فيها بنجاح -أي لا نعيد شيئًا-، كما يمكن العثور على السكربت الكاملة للخادم في https://eloquentjavascript.net/code/file_server.js وهي متاحة للتحميل، وتستطيع بدء خادم الملفات الخاص بك بتحميلها وتثبيت اعتمادياتها ثم تشغيلها مع Node، كما تستطيع تعديلها وتوسيعها لحل تدريبات هذا المقالأو للتجربة، وتُستخدم أداة سطر الأوامر  curl لإنشاء طلبات HTTP، وهي أداة متاحة في الأنظمة الشبيهة بنظام يونكس UNIX مثل ماك ولينكس وما شابههما، كما تختبر الشيفرة التالية خادمنا، حيث تستخدِم الخيار ‎-X لتعيين تابع الطلب و‎-d لإدراج متن الطلب.

$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d hello http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
hello
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found

يفشل الطلب الأول إلى file.txt لأنّ الملف غير موجود بعد، لكن الطلب الثاني ينجح في جلب الملف بعد إنشاء طلب PUT لذلك الملف، ثم بعد ذلك يُفقد الملف مرةً أخرى بسبب طلب DELETE الذي يحذفه.

خاتمة

تسمح منصة Node لنا بتشغيل جافاسكربت في سياق خارج المتصفح، وقد صُممت أساسًا من أجل مهام الشبكات لتلعب دور عقدة -كما يشير الاسم Node- داخل شبكة ما، لكنها تتكامل جيدًا مع مهام السكربتات باختلاف أنواعها، وستستمتع بأتمتة المهام بها إذا كنت تحب جافاسكربت، كما يوفِّر NPM حزمًا لكل شيء تقريبًا ويسمح لنا بجلب وتثبيت تلك الحزم باستخدام البرنامج npm، كما تأتي Node بعدد من الوحدات المضمَّنة مثل وحدة fs التي تعمل مع نظام الملفات ووحدة http التي تشغّل خوادم HTTP وتنشئ طلبات HTTP أيضًا.

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

تدريبات

أداة بحث

توجد أداة سطر أوامر في UNIX للبحث السريع في الملفات عن تعبير نمطي وهي أداة grep.

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

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

إرشادات الحل

ستجد الوسيط الأول لك -وهو التعبير النمطي- في ‎process.argv[2]‎، ثم تأتي ملفات الدخل بعد ذلك، ويمكنك استخدام الباني RegExp للتحويل من سلسلة نصية إلى كائن تعبير نمطي، ولا شك أنّ تنفيذ هذه السكربت تزامنيًا باستخدام readFileSync سيكون أبسط وأسهل، لكن إذا استخدمت fs.promises من أجل الحصول على دوال تعيد وعودًا وكتبت دالة async، فلن تبدو الشيفرة غريبةً أو مختلفةً، كما يمكنك استخدام stat أو statSync والتابع isDirectory الخاص بكائن stat لمعرفة هل العنصر المبحوث عنه مجلد أم لا.

تُعَدّ عملية تصفح مجلد عمليةً متفرعةً، حيث يمكنك تنفيذها باستخدام دالة تعاودية أو بالاحتفاظ بمصفوفة عمل -أي ملفات يجب تصفحها-.، كما تستطيع استدعاء readdir أو readdirSync للبحث عن ملفات في مجلد ما، وعليك ملاحظة أنّ أسلوب التسمية في دوال Node يختلف عن جافاسكربت وهو أقرب إلى أسلوب دوال يونكس القياسية، كما في readdir التي تكون كل الحروف فيها من الحالة الصغيرة، ثم نضيف Sync بحرف S كبير، وإذا أردت الذهاب من ملف قرأته readdir إلى الاسم الكامل للسمار، فيجب جمعه إلى اسم المجلد بوضع محرف شرطة مائلة / بينهما.

إنشاء المجلد

رغم استطاعة التابع DELETE الذي في خادم ملفاتنا حذف المجلدات باستخدام rmdir إلا أنّ الخادم لا يدعم حاليًا أي طريقة لإنشاء مجلد، لذا أضف دعمًا للتابع MKCOL -الذي يعني أنشئ تجميعةً Make Collection-، والذي سينشئ مجلدًا باستدعاء mkdir من وحدة fs.

لا يُستخدم MKCOL -وهو تابع HTTP- كثيرًا لكنه موجود لمثل هذا الغرض تحديدًا في معيار WebDAV الذي يحدِّد مجموعةً من الأساليب فوق HTTP لتجعله مناسبًا لإنشاء المستندات.

إرشادات الحل

يمكنك استخدام الدالة التي تستخدِم التابع DELETE على أساس نموذج للتابع MKCOL، وحاول إنشاء مجلد باستخدام mkdir إذا لم يُعثر على ملف؛ أما إذا وجد مجلد في ذلك المسار فأعد الاستجابة 204 كي تكون طلبات إنشاء المجلدات راسخةً idempotent، فإذا وجد ملف لا يكون مجلدًا هنا فأعد رسالة خطأ، وسيكون رمز الخطأ 400 -أي "طلب سيء bad request"- هو المناسب.

مساحة عامة على الويب

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

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

استخدم استمارة HTML لتعديل محتوى الملفات التي تكوّن الموقع بما يسمح للمستخدِم بتحديثها على الخادم من خلال استخدام طلبات HTTP كما ذكرنا في مقال HTTP والاستمارات في جافاسكربت، وابدء بجعل ملف واحد فقط قابلًا للتعديل ثم أكثر من ملف بحيث يستطيع المستخدِم اختيار أيّ ملف يمكن تعديله، واستفد من كون خادم الملفات يعيد قائمةً من الملفات عند قراءة مجلد ما، كما لا تعمل في الشيفرة المعروضة لخادم الملفات مباشرةً بما أنك قد تخطئ فتدمِّر الملفات التي هناك، بل اجعل عملك خارج المجلد العام وانسخه عند الاختبار.

إرشادات الحل

تستطيع إنشاء عنصر <textarea> لحفظ محتوى الملف الذي يُعدَّل، ويمكن جلب محتوى الملف الحالي باستخدام GET الذي يستخدِم fetch، كما تستطيع استخدام الروابط النسبية مثل index.html بدلًا من http://localhost:8000/index.html للإشارة إلى الملفات التي على الخادم نفسه الذي عليه السكربت العاملة، وإذا نقر المستخدِم على زر ما -حيث يمكنك استخدام العنصر <form> والحدث "submit" لذلك- فأنشئ طلب PUT إلى الرابط نفسه بمحتوى <textarea> على أساس متن للطلب من أجل حفظ الملف.

يمكنك بعد ذلك إضافة العنصر <select> الذي يحتوي على جميع الملفات في المجلد الأعلى للخادم بإضافة عناصر <option> التي تحتوي الأسطر المعادة بواسطة طلب GET إلى الرابط /، وإذا اختار المستخدِم ملفًا آخرًا -أي الحدث "change" على ذلك الملف-، فيجب على السكربت جلب ذلك الملف وعرضه، ومن أجل حفظ ملف ما استخدِم اسم الملف المحدد حاليًا.

ترجمة -بتصرف- للفصل العشرين من كتاب Elequent Javascript لصاحبه Marijn Haverbeke.

اقرأ أيضًا


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

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

لا توجد أية تعليقات بعد



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

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

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

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   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.


×
×
  • أضف...