الفصل 20
Node.js
سأل طالب: «كان المبرمجون القدامى يستخدمون آلات بسيطة فقط، ولم تكن لديهم لغات برمجة، ومع ذلك صنعوا برامج جميلة. فلماذا نستخدم آلات معقدة ولغات برمجة؟» فأجاب فو-تزو: «كان البناؤون القدامى يستخدمون العصي والطين فقط، ومع ذلك بنوا أكواخاً جميلة.»
— المعلم يوان-ما، كتاب البرمجة

حتى الآن، استخدمنا لغة JavaScript في بيئة واحدة: المتصفح. سيقدّم هذا الفصل والفصل التالي تعريفاً موجزاً بـ Node.js، وهو برنامج يتيح لك تطبيق مهاراتك في JavaScript خارج المتصفح. وبواسطته يمكنك بناء أي شيء، من أدوات سطر الأوامر الصغيرة إلى خوادم HTTP التي تشغّل مواقع الويب الديناميكية.
يهدف هذان الفصلان إلى تعليمك المفاهيم الرئيسية التي يستخدمها Node.js، وإعطائك معلومات كافية لكتابة برامج مفيدة له. وهما لا يحاولان تقديم معالجة كاملة أو حتى شاملة للمنصة.
بينما كان بإمكانك تشغيل الشيفرة في الفصول السابقة مباشرة على هذه الصفحات، لأنها كانت إما JavaScript خاماً أو مكتوبة للمتصفح، فإن أمثلة الشيفرة في هذا الفصل مكتوبة لـ Node ولن تعمل غالباً في المتصفح.
إذا أردت المتابعة وتشغيل الشيفرة في هذا الفصل، فستحتاج إلى تثبيت Node.js إصدار 18 أو أحدث. للقيام بذلك، اذهب إلى https://nodejs.org واتبع تعليمات التثبيت الخاصة بنظام تشغيلك. يمكنك أيضاً العثور هناك على مزيد من التوثيق لـ Node.js.
الخلفية
عند بناء أنظمة تتواصل عبر الشبكة، فإن طريقة إدارتك للمدخلات والمخرجات — أي قراءة البيانات وكتابتها من وإلى الشبكة والقرص الصلب — قد تُحدث فرقاً كبيراً في مدى سرعة استجابة النظام للمستخدم أو لطلبات الشبكة.
في مثل هذه البرامج، غالباً ما تكون البرمجة غير المتزامنة مفيدة. فهي تتيح للبرنامج إرسال البيانات واستقبالها من وإلى أجهزة متعددة في الوقت نفسه، دون إدارة معقدة للخيوط (threads) ومزامنتها.
لقد تصوّر Node في البداية بغرض جعل البرمجة غير المتزامنة سهلة ومريحة. وتناسب JavaScript نظاماً مثل Node جيداً. فهي واحدة من لغات البرمجة القليلة التي لا تملك طريقة مدمجة لإجراء المدخلات والمخرجات. وهكذا أمكن تركيب JavaScript على نهج Node الغريب نوعاً ما في برمجة الشبكة ونظام الملفات، دون أن ننتهي بواجهتين غير متسقتين. في عام 2009، حين كان يجري تصميم Node، كان الناس يبرمجون بالفعل بأسلوب دوال رد النداء (callbacks) في المتصفح، فكان المجتمع المحيط باللغة معتاداً على أسلوب البرمجة غير المتزامنة.
أمر node
عند تثبيت Node.js على نظام ما، فإنه يوفر برنامجاً يسمى node، يُستخدم لتشغيل ملفات JavaScript. لنفترض أن لديك ملفاً باسم hello.js يحتوي على هذه الشيفرة:
let message = "Hello world";
console.log(message);
يمكنك بعد ذلك تشغيل node من سطر الأوامر هكذا لتنفيذ البرنامج:
$ node hello.js
Hello world
تفعل طريقة console.log في Node شيئاً مشابهاً لما تفعله في المتصفح. فهي تطبع قطعة من النص. لكن في Node، سيذهب النص إلى مجرى المخرجات القياسي للعملية (standard output) بدلاً من وحدة تحكم JavaScript في المتصفح. وعند تشغيل node من سطر الأوامر، يعني ذلك أنك ترى القيم المسجَّلة في طرفيتك.
إذا شغّلت node دون إعطائه ملفاً، فسيوفر لك موجهاً (prompt) يمكنك أن تكتب فيه شيفرة JavaScript وترى النتيجة فوراً.
$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$
إن ارتباط process، تماماً مثل ارتباط console، متاح عالمياً في Node. وهو يوفر طرقاً متنوعة لفحص البرنامج الحالي والتلاعب به. تُنهي طريقة 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"]
جميع ارتباطات JavaScript العالمية القياسية، مثل Array وMath وJSON، موجودة أيضاً في بيئة Node. أما الوظائف المتعلقة بالمتصفح، مثل document أو prompt، فليست موجودة.
الوحدات
إلى جانب الارتباطات التي ذكرتها، مثل console وprocess، يضع Node ارتباطات إضافية قليلة في النطاق العالمي. إذا أردت الوصول إلى وظيفة مدمجة، فعليك أن تطلبها من نظام الوحدات.
بدأ Node باستخدام نظام وحدات CommonJS، القائم على دالة require، التي رأيناها في الفصل 10. وما زال يستخدم هذا النظام افتراضياً عند تحميل ملف .js.
لكن Node يدعم اليوم أيضاً نظام وحدات ES الأكثر حداثة. عندما ينتهي اسم ملف سكربت بـ .mjs، فإنه يُعتبر وحدة من هذا النوع، ويمكنك استخدام import وexport فيه (لكن ليس require). سنستخدم وحدات ES في هذا الفصل.
عند استيراد وحدة — سواء بـ require أو import — على Node أن يحل النص المعطى إلى ملف فعلي يمكنه تحميله. الأسماء التي تبدأ بـ / أو ./ أو ../ تُحل كملفات، نسبةً إلى مسار الوحدة الحالية. هنا، تعني . الدليل الحالي، وتعني ../ دليلاً واحداً للأعلى، وتعني / جذر نظام الملفات. إذا طلبت "./graph.mjs" من الملف /tmp/robot/robot.mjs، فسيحاول Node تحميل الملف /tmp/robot/graph.mjs.
عند استيراد نص لا يبدو مساراً نسبياً أو مطلقاً، يُفترض أنه يشير إما إلى وحدة مدمجة أو إلى وحدة مثبَّتة في دليل node_modules. مثلاً، الاستيراد من "node:fs" سيعطيك وحدة نظام الملفات المدمجة في Node. واستيراد "robot" قد يحاول تحميل المكتبة الموجودة في node_modules/robot/. ومن الشائع تثبيت مثل هذه المكتبات باستخدام NPM، وسنعود إليه بعد قليل.
لنُعِدّ مشروعاً صغيراً يتكون من ملفين. الأول، ويسمى main.mjs، يعرّف سكربتاً يمكن استدعاؤه من سطر الأوامر لعكس نص.
import {reverse} from "./reverse.mjs";
// الفهرس 2 يحمل أول معطى فعلي في سطر الأوامر
let argument = process.argv[2];
console.log(reverse(argument));
يعرّف الملف reverse.mjs مكتبة لعكس النصوص، يمكن استخدامها بواسطة أداة سطر الأوامر هذه وبواسطة سكربتات أخرى تحتاج وصولاً مباشراً إلى دالة تعكس النصوص.
export function reverse(string) {
return Array.from(string).reverse().join("");
}
تذكّر أن export تُستخدم للإعلان عن أن ارتباطاً ما جزء من واجهة الوحدة. وهذا يتيح لـ main.mjs استيراد الدالة واستخدامها.
يمكننا الآن استدعاء أداتنا هكذا:
$ node main.mjs JavaScript
tpircSavaJ
التثبيت باستخدام NPM
NPM، الذي قُدّم في الفصل 10، مستودع على الإنترنت لوحدات JavaScript، وكثير منها مكتوب خصيصاً لـ Node. عند تثبيت Node على حاسوبك، تحصل أيضاً على أمر npm، الذي يمكنك استخدامه للتفاعل مع هذا المستودع.
الاستخدام الرئيسي لـ NPM هو تنزيل الحزم. رأينا حزمة ini في الفصل 10. يمكننا استخدام NPM لجلب هذه الحزمة وتثبيتها على حاسوبنا.
$ npm install ini
added 1 package in 723ms
$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }
بعد تشغيل npm install، سيكون NPM قد أنشأ دليلاً يسمى node_modules. وداخل ذلك الدليل سيكون دليل ini الذي يحتوي على المكتبة. يمكنك فتحه والنظر في الشيفرة. عندما نستورد "ini"، تُحمَّل هذه المكتبة، ويمكننا استدعاء خاصيتها parse لتحليل ملف إعدادات.
افتراضياً، يثبّت NPM الحزم تحت الدليل الحالي بدلاً من مكان مركزي. إذا كنت معتاداً على مديري حزم آخرين، فقد يبدو هذا غير معتاد، لكن له مزايا — فهو يمنح كل تطبيق سيطرة كاملة على الحزم التي يثبتها، ويجعل إدارة الإصدارات والتنظيف عند إزالة تطبيق أسهل.
ملفات الحزم
بعد تشغيل npm install لتثبيت حزمة ما، ستجد ليس فقط دليل node_modules بل أيضاً ملفاً يسمى package.json في دليلك الحالي. يُستحسن أن يكون لديك ملف كهذا لكل مشروع. يمكنك إنشاؤه يدوياً أو تشغيل npm init. يحتوي هذا الملف على معلومات عن المشروع، مثل اسمه وإصداره، ويسرد تبعياته.
محاكاة الروبوت من الفصل 7، كما جُعلت وحدات في تمرين الفصل 10، قد يكون لها ملف package.json كهذا:
{
"author": "Marijn Haverbeke",
"name": "eloquent-javascript-robot",
"description": "Simulation of a package-delivery robot",
"version": "1.0.0",
"main": "run.mjs",
"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)، وهو يرمّز بعض المعلومات حول الإصدارات المتوافقة (التي لا تكسر الواجهة القديمة) في رقم الإصدار. يتكون الإصدار الدلالي من ثلاثة أرقام تفصل بينها نقاط، مثل 2.3.0. في كل مرة تُضاف وظيفة جديدة، يجب زيادة الرقم الأوسط. وفي كل مرة تُكسر التوافقية، بحيث قد لا تعمل الشيفرة الموجودة التي تستخدم الحزمة مع الإصدار الجديد، يجب زيادة الرقم الأول.
تشير علامة الإقحام (^) أمام رقم إصدار تبعية في package.json إلى أن أي إصدار متوافق مع الرقم المعطى يمكن تثبيته. مثلاً، "^2.3.0" تعني أن أي إصدار أكبر من أو يساوي 2.3.0 وأقل من 3.0.0 مسموح به.
يُستخدم أمر npm أيضاً لنشر حزم جديدة أو إصدارات جديدة من الحزم. إذا شغّلت npm publish في دليل يحتوي على ملف package.json، فستُنشر إلى السجل (registry) حزمة بالاسم والإصدار المدرجين في ملف JSON. يمكن لأي شخص نشر حزم على NPM — لكن فقط باسم حزمة غير مستخدم بعد، لأنه لن يكون جيداً أن يتمكن أشخاص عشوائيون من تحديث حزم موجودة.
لن يتعمق هذا الكتاب أكثر في تفاصيل استخدام NPM. راجع https://npmjs.com لمزيد من التوثيق وطريقة للبحث عن الحزم.
وحدة نظام الملفات
من أكثر الوحدات المدمجة استخداماً في Node وحدة node:fs، التي تعني نظام الملفات (filesystem). وهي تصدّر دوال للتعامل مع الملفات والأدلة.
مثلاً، الدالة المسماة readFile تقرأ ملفاً ثم تستدعي دالة رد نداء (callback) بمحتويات الملف.
import {readFile} from "node:fs";
readFile("file.txt", "utf8", (error, text) => {
if (error) throw error;
console.log("The file contains:", text);
});
يشير المعطى الثاني لـ readFile إلى ترميز المحارف (character encoding) المستخدم لفك ترميز الملف إلى نص. هناك عدة طرق يمكن بها ترميز النص إلى بيانات ثنائية، لكن معظم الأنظمة الحديثة تستخدم UTF-8. وما لم تكن لديك أسباب للاعتقاد بأن ترميزاً آخر مستخدم، فمرّر "utf8" عند قراءة ملف نصي. إذا لم تمرر ترميزاً، سيفترض Node أنك مهتم بالبيانات الثنائية وسيعطيك كائن Buffer بدلاً من نص. وهو كائن شبيه بالمصفوفة يحتوي على أعداد تمثل البايتات (قطع بيانات من 8 بتات) في الملفات.
import {readFile} from "node: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، لكتابة ملف إلى القرص.
import {writeFile} from "node:fs";
writeFile("graffiti.txt", "Node was here", err => {
if (err) console.log(`Failed to write file: ${err}`);
else console.log("File written.");
});
هنا لم يكن ضرورياً تحديد الترميز — سيفترض writeFile أنه عندما يُعطى نصاً ليكتبه، بدلاً من كائن Buffer، فعليه كتابته كنص باستخدام ترميز محارفه الافتراضي، وهو UTF-8.
تحتوي وحدة node:fs على دوال مفيدة أخرى كثيرة: readdir ستعطيك الملفات في دليل كمصفوفة من النصوص، وstat ستسترجع معلومات عن ملف، وrename ستغيّر اسم ملف، وunlink ستحذف ملفاً، وهكذا. انظر التوثيق على https://nodejs.org للتفاصيل.
معظم هذه الدوال تأخذ دالة رد نداء كوسيط أخير، وتستدعيها إما بخطأ (المعطى الأول) أو بنتيجة ناجحة (المعطى الثاني). وكما رأينا في الفصل 11، هناك مساوئ لهذا الأسلوب من البرمجة — وأكبرها أن معالجة الأخطاء تصبح مطوَّلة وعرضة للخطأ.
تصدّر وحدة node:fs/promises معظم الدوال نفسها التي تصدّرها وحدة node:fs القديمة، لكنها تستخدم الوعود (promises) بدلاً من دوال رد النداء.
import {readFile} from "node:fs/promises";
readFile("file.txt", "utf8")
.then(text => console.log("The file contains:", text));
أحياناً لا تحتاج إلى اللا تزامن ويكون فقط في طريقك. كثير من دوال node:fs لها أيضاً نسخة متزامنة، تحمل الاسم نفسه مع إضافة Sync في نهايته. مثلاً، النسخة المتزامنة من readFile تسمى readFileSync.
import {readFileSync} from "node:fs";
console.log("The file contains:",
readFileSync("file.txt", "utf8"));
لاحظ أنه أثناء تنفيذ عملية متزامنة كهذه، يتوقف برنامجك كلياً. وإذا كان ينبغي أن يستجيب للمستخدم أو لآلات أخرى على الشبكة، فقد يؤدي تعلقه بإجراء متزامن إلى تأخيرات مزعجة.
وحدة HTTP
هناك وحدة محورية أخرى تسمى node:http. وهي توفر وظائف لتشغيل خادم HTTP.
هذا كل ما يلزم لبدء خادم HTTP:
import {createServer} from "node: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، ستكتب ترويسات الاستجابة (انظر الفصل 18). وتعطيها رمز الحالة (200 وتعني «موافق» في هذه الحالة) وكائناً يحتوي على قيم الترويسات. يضبط المثال ترويسة Content-Type لإبلاغ العميل بأننا سنرسل مستند HTML.
بعد ذلك، يُرسل جسم الاستجابة الفعلي (المستند نفسه) بـ response.write. ويُسمح لك باستدعاء هذه الطريقة عدة مرات إذا أردت إرسال الاستجابة قطعة قطعة — مثلاً، لبث البيانات إلى العميل عند توفرها. وأخيراً، تشير response.end إلى نهاية الاستجابة.
يؤدي استدعاء server.listen إلى بدء الخادم في انتظار الاتصالات على المنفذ 8000. ولهذا عليك الاتصال بـ localhost:8000 للتحدث مع هذا الخادم، بدلاً من localhost فقط، الذي سيستخدم المنفذ الافتراضي 80.
عند تشغيل هذا السكربت، تظل العملية جالسة هناك وتنتظر. عندما يستمع سكربت إلى أحداث — في هذه الحالة، اتصالات الشبكة — لن يخرج node تلقائياً عند وصوله إلى نهاية السكربت. لإغلاقه، اضغط ctrl-C.
عادةً يفعل خادم ويب حقيقي أكثر مما في المثال — فهو ينظر إلى طريقة الطلب (خاصية method) ليرى ما الإجراء الذي يحاول العميل تنفيذه، وينظر إلى عنوان URL للطلب ليعرف على أي مورد يُنفَّذ هذا الإجراء. سنرى خادماً أكثر تطوراً لاحقاً في هذا الفصل.
توفر وحدة node:http أيضاً دالة request يمكن استخدامها لإرسال طلبات HTTP. لكن استخدامها أكثر تعقيداً بكثير من fetch، الذي رأيناه في الفصل 18. لحسن الحظ، fetch متاح أيضاً في Node كارتباط عالمي. وما لم ترد فعل شيء محدد جداً، مثل معالجة مستند الاستجابة قطعة قطعة مع وصول البيانات عبر الشبكة، أنصحك بالالتزام بـ fetch.
المجاري (Streams)
كائن الاستجابة الذي يمكن لخادم HTTP أن يكتب إليه مثال على كائن مجرى قابل للكتابة (writable stream)، وهو مفهوم واسع الاستخدام في Node. لهذه الكائنات طريقة write يمكن تمرير نص أو كائن Buffer إليها لكتابة شيء إلى المجرى. وطريقتها end تغلق المجرى ويمكن أن تأخذ اختيارياً قيمة تُكتب إلى المجرى قبل الإغلاق. ويمكن أيضاً إعطاء كلتا الطريقتين دالة رد نداء كمعطى إضافي، تستدعيها عند انتهاء الكتابة أو الإغلاق.
من الممكن إنشاء مجرى قابل للكتابة يشير إلى ملف باستخدام الدالة createWriteStream من وحدة node:fs. يمكنك حينها استخدام طريقة write على الكائن الناتج لكتابة الملف قطعة قطعة بدلاً من كتابته دفعة واحدة، كما في writeFile.
المجاري القابلة للقراءة (readable streams) أكثر تعقيداً قليلاً. معطى request لدالة رد نداء خادم HTTP مجرى قابل للقراءة. وتُجرى القراءة من مجرى باستخدام معالجات الأحداث بدلاً من الطرق.
الكائنات التي تصدر أحداثاً في Node لها طريقة تسمى on تشبه طريقة addEventListener في المتصفح. تعطيها اسم حدث ثم دالة، فتُسجّل تلك الدالة لتُستدعى كلما وقع الحدث المعطى.
للمجاري القابلة للقراءة حدثا "data" و"end". يُطلق الأول في كل مرة تصل فيها بيانات، ويُستدعى الثاني كلما وصل المجرى إلى نهايته. هذا النموذج أنسب لبث البيانات التي يمكن معالجتها فوراً، حتى عندما لا يكون المستند كاملاً متاحاً بعد. ويمكن قراءة ملف كمجرى قابل للقراءة باستخدام الدالة createReadStream من node:fs.
تُنشئ هذه الشيفرة خادماً يقرأ أجسام الطلبات ويبثها مرة أخرى إلى العميل كنص بأحرف كبيرة كلها:
import {createServer} from "node: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.
قطعة الشيفرة التالية، عند تشغيلها بينما خادم التحويل إلى أحرف كبيرة نشط، سترسل طلباً إلى ذلك الخادم وتكتب الاستجابة التي تتلقاها:
fetch("http://localhost:8000/", {
method: "POST",
body: "Hello server"
}).then(resp => resp.text()).then(console.log);
// → HELLO SERVER
خادم ملفات
لنجمع معرفتنا الجديدة بخوادم HTTP والتعامل مع نظام الملفات لإنشاء جسر بين الاثنين: خادم HTTP يتيح وصولاً عن بعد إلى نظام ملفات. لمثل هذا الخادم استخدامات من كل نوع — فهو يتيح لتطبيقات الويب تخزين البيانات ومشاركتها، أو يمكنه منح مجموعة من الناس وصولاً مشتركاً إلى مجموعة من الملفات.
عندما نتعامل مع الملفات كموارد HTTP، يمكن استخدام طرق HTTP وهي GET وPUT وDELETE لقراءة الملفات وكتابتها وحذفها على التوالي. وسنفسّر المسار في الطلب على أنه مسار الملف الذي يشير إليه الطلب.
من المحتمل ألا نريد مشاركة نظام الملفات كله، لذا سنفسّر هذه المسارات على أنها تبدأ من دليل عمل الخادم، وهو الدليل الذي شُغّل منه. إذا شغّلت الخادم من /tmp/public/ (أو C:\tmp\public\ على Windows)، فينبغي أن يشير طلب /file.txt إلى /tmp/public/file.txt (أو C:\tmp\public\file.txt).
سنبني البرنامج قطعة قطعة، باستخدام كائن يسمى methods لتخزين الدوال التي تعالج طرق HTTP المختلفة. معالجات الطرق دوال async تأخذ كائن الطلب كمعطى لها وتُرجع وعداً يُحلّ إلى كائن يصف الاستجابة.
import {createServer} from "node: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?.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 (موافق). ويمكن أيضاً ترك نوع المحتوى، في الخاصية type، وفي هذه الحالة يُفترض أن الاستجابة نص عادي.
عندما تكون قيمة body مجرى قابلاً للقراءة، سيكون له طريقة pipe يمكننا استخدامها لتمرير كل المحتوى من مجرى قابل للقراءة إلى مجرى قابل للكتابة. وإلا، فيُفترض أنه إما null (لا جسم)، أو نص، أو مخزن مؤقت، ويُمرَّر مباشرة إلى طريقة end للاستجابة.
لمعرفة مسار الملف الذي يقابل عنوان URL لطلب ما، تستخدم الدالة urlPath صنف URL المدمج (الموجود أيضاً في المتصفح) لتحليل عنوان URL. يتوقع هذا الباني عنوان URL كاملاً، لا الجزء الذي يبدأ بالشرطة المائلة فقط والذي نحصل عليه من request.url، لذا نعطيه اسم نطاق وهمياً ليكمل به. وتستخرج منه اسم المسار (pathname)، الذي سيكون شيئاً مثل "/file.txt"، وتفك ترميزه للتخلص من رموز التخطي (escape) من نمط %20، وتحلّه نسبةً إلى دليل عمل البرنامج.
import {resolve, sep} from "node:path";
const baseDirectory = process.cwd();
function urlPath(url) {
let {pathname} = new URL(url, "http://d");
let path = resolve(decodeURIComponent(pathname).slice(1));
if (path != baseDirectory &&
!path.startsWith(baseDirectory + sep)) {
throw {status: 403, body: "Forbidden"};
}
return path;
}
بمجرد أن تُعِدّ برنامجاً لقبول طلبات الشبكة، عليك أن تبدأ بالقلق بشأن الأمان. في هذه الحالة، إذا لم نكن حذرين، فمن المرجح أن نكشف نظام ملفاتنا كله للشبكة عن غير قصد.
مسارات الملفات نصوص في Node. ولتحويل نص كهذا إلى ملف فعلي، يجري قدر غير تافه من التفسير. فقد تتضمن المسارات، مثلاً، ../ للإشارة إلى دليل أب. وأحد المصادر الواضحة للمشاكل سيكون طلبات لمسارات مثل /../secret_file.
لتجنب مثل هذه المشاكل، تستخدم urlPath الدالة resolve من وحدة node:path، التي تحل المسارات النسبية. ثم تتحقق من أن النتيجة تحت دليل العمل. ويمكن استخدام الدالة process.cwd (حيث cwd تعني دليل العمل الحالي، current working directory) للعثور على دليل العمل هذا. وارتباط sep من حزمة node:path هو فاصل المسار في النظام — شرطة مائلة معكوسة على Windows وشرطة مائلة عادية على معظم الأنظمة الأخرى. وعندما لا يبدأ المسار بدليل الأساس، تطلق الدالة كائن استجابة خطأ، باستخدام رمز حالة HTTP الذي يشير إلى أن الوصول إلى المورد محظور.
سنُعِدّ طريقة GET لتُرجع قائمة ملفات عند قراءة دليل، وتُرجع محتوى الملف عند قراءة ملف عادي.
سؤال محيّر هو أي نوع من ترويسة Content-Type ينبغي أن نضبط عند إرجاع محتوى ملف. وبما أن هذه الملفات قد تكون أي شيء، لا يمكن لخادمنا ببساطة إرجاع نوع المحتوى نفسه لكلها. يمكن لـ NPM مساعدتنا هنا مجدداً. حزمة mime-types (مؤشرات نوع المحتوى مثل text/plain تسمى أيضاً أنواع MIME) تعرف النوع الصحيح لعدد كبير من امتدادات الملفات.
أمر npm التالي، في الدليل الذي يعيش فيه سكربت الخادم، يثبّت إصداراً محدداً من mime:
$ npm install mime-types@2.1.0
عندما لا يوجد ملف مطلوب، رمز حالة HTTP الصحيح للإرجاع هو 404. سنستخدم الدالة stat، التي تبحث عن معلومات حول ملف، لمعرفة ما إذا كان الملف موجوداً وما إذا كان دليلاً.
import {createReadStream} from "node:fs";
import {stat, readdir} from "node:fs/promises";
import {lookup} from "mime-types";
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: lookup(path)};
}
};
لأنها يجب أن تلمس القرص وبالتالي قد تستغرق وقتاً، فإن stat غير متزامنة. وبما أننا نستخدم الوعود بدلاً من أسلوب دوال رد النداء، فيجب استيرادها من node:fs/promises بدلاً من node:fs مباشرة.
عندما لا يوجد الملف، ستطلق stat كائن خطأ بخاصية code قيمتها "ENOENT". هذه الرموز الغامضة نوعاً ما، المستوحاة من Unix، هي طريقتك للتعرف على أنواع الأخطاء في Node.
كائن stats الذي تُرجعه stat يخبرنا بعدة أشياء عن ملف، مثل حجمه (خاصية size) وتاريخ تعديله (خاصية mtime). ونحن مهتمون هنا بمسألة ما إذا كان دليلاً أم ملفاً عادياً، وهو ما تخبرنا به طريقة isDirectory.
نستخدم readdir لقراءة مصفوفة الملفات في دليل وإرجاعها إلى العميل. أما الملفات العادية، فسننشئ مجرى قابلاً للقراءة بـ createReadStream ونرجعه كجسم، إلى جانب نوع المحتوى الذي تعطينا إياه حزمة mime لاسم الملف.
الشيفرة لمعالجة طلبات DELETE أبسط قليلاً.
import {rmdir, unlink} from "node: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:
import {createWriteStream} from "node: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" في كلا المجريين لرفض الوعد. وعندما ينتهي pipe، سيغلق مجرى المخرجات، ما يجعله يطلق حدث "finish". عند هذه النقطة يمكننا حل الوعد بنجاح (دون إرجاع شيء).
السكربت الكامل للخادم متاح على https://eloquentjavascript.net/code/file_server.mjs. يمكنك تنزيله، وبعد تثبيت تبعياته، تشغيله بـ Node لبدء خادم ملفاتك الخاص. وبالطبع، يمكنك تعديله وتوسيعه لحل تمارين هذا الفصل أو للتجربة.
أداة سطر الأوامر curl، المتاحة على نطاق واسع في الأنظمة الشبيهة بـ Unix (مثل macOS وLinux)، يمكن استخدامها لإرسال طلبات HTTP. الجلسة التالية تختبر خادمنا باختصار. يُستخدم الخيار -X لضبط طريقة الطلب، و-d لتضمين جسم الطلب.
$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d CONTENT http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
CONTENT
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found
يفشل الطلب الأول لـ file.txt لأن الملف غير موجود بعد. ينشئ طلب PUT الملف، وها هو ذا، الطلب التالي يسترجعه بنجاح. وبعد حذفه بطلب DELETE، يغيب الملف مجدداً.
الخلاصة
Node نظام صغير لطيف يتيح لنا تشغيل JavaScript في سياق خارج المتصفح. صُمم أصلاً لمهام الشبكة ليؤدي دور عقدة في شبكة، لكنه يناسب جميع أنواع مهام السكربتات. وإذا كانت كتابة JavaScript شيئاً تستمتع به، فقد تناسبك أتمتة المهام بـ Node جيداً.
يوفر NPM حزماً لكل ما يمكن أن يخطر ببالك (وكثير من الأشياء التي لن تخطر ببالك على الأرجح)، ويتيح لك جلب تلك الحزم وتثبيتها ببرنامج npm. ويأتي Node بعدد من الوحدات المدمجة، منها وحدة node:fs للتعامل مع نظام الملفات ووحدة node:http لتشغيل خوادم HTTP.
تُجرى كل المدخلات والمخرجات في Node بشكل غير متزامن، إلا إذا استخدمت صراحة نسخة متزامنة من دالة، مثل readFileSync. استخدم Node أصلاً دوال رد النداء للوظائف غير المتزامنة، لكن حزمة node:fs/promises توفر واجهة قائمة على الوعود لنظام الملفات.
التمارين
أداة بحث
في أنظمة Unix، هناك أداة سطر أوامر تسمى grep يمكن استخدامها للبحث سريعاً في الملفات عن تعبير نمطي.
اكتب سكربت Node يمكن تشغيله من سطر الأوامر ويعمل بشكل مشابه إلى حد ما لـ grep. يعامل معطاه الأول في سطر الأوامر كتعبير نمطي، ويعامل أي معطيات أخرى كملفات للبحث فيها. ويُخرج أسماء أي ملف يطابق محتواه التعبير النمطي.
عندما يعمل ذلك، وسّعه بحيث إذا كان أحد المعطيات دليلاً، بحث في كل الملفات في ذلك الدليل وأدلته الفرعية.
استخدم دوال نظام الملفات غير المتزامنة أو المتزامنة كما تراه مناسباً. قد يؤدي إعداد الأمور بحيث تُطلب إجراءات غير متزامنة متعددة في الوقت نفسه إلى تسريع الأمور قليلاً، لكن ليس بقدر كبير، لأن معظم أنظمة الملفات لا تستطيع قراءة أكثر من شيء واحد في كل مرة.
إظهار التلميح
معطاك الأول في سطر الأوامر، وهو التعبير النمطي، يمكن العثور عليه في process.argv[2]. وتأتي ملفات الإدخال بعده. يمكنك استخدام باني RegExp للانتقال من نص إلى كائن تعبير نمطي.
فعل هذا بشكل متزامن، بـ readFileSync، أبسط، لكن إذا استخدمت node:fs/promises للحصول على دوال تُرجع وعوداً وكتبت دالة async، فستبدو الشيفرة مشابهة.
لمعرفة ما إذا كان شيء ما دليلاً، يمكنك مجدداً استخدام stat (أو statSync) وطريقة isDirectory في كائن الإحصاءات.
استكشاف دليل عملية متفرعة. يمكنك فعلها إما باستخدام دالة تعاودية أو بالاحتفاظ بمصفوفة عمل (ملفات لا تزال بحاجة إلى استكشاف). وللعثور على الملفات في دليل، يمكنك استدعاء readdir أو readdirSync. لاحظ الكتابة الكبيرة الغريبة — تسمية دوال نظام الملفات في Node مبنية بشكل فضفاض على دوال Unix القياسية، مثل readdir، التي كلها بأحرف صغيرة، لكنها تضيف بعدها Sync بحرف كبير.
للانتقال من اسم ملف مقروء بـ readdir إلى اسم مسار كامل، عليك دمجه مع اسم الدليل، إما بوضع sep من node:path بينهما أو باستخدام الدالة join من الحزمة نفسها.
إنشاء دليل
رغم أن طريقة DELETE في خادم ملفاتنا قادرة على حذف الأدلة (باستخدام rmdir)، فإن الخادم لا يوفر حالياً أي طريقة لإنشاء دليل.
أضف دعماً لطريقة MKCOL («أنشئ مجموعة»، make collection)، التي ينبغي أن تنشئ دليلاً باستدعاء mkdir من وحدة node:fs. ليست MKCOL طريقة HTTP واسعة الاستخدام، لكنها موجودة لهذا الغرض نفسه في معيار WebDAV، الذي يحدد مجموعة من الاصطلاحات فوق HTTP تجعله مناسباً لإنشاء المستندات.
إظهار التلميح
يمكنك استخدام الدالة التي تنفذ طريقة DELETE كمخطط لطريقة MKCOL. عندما لا يوجد ملف، حاول إنشاء دليل بـ mkdir. وعندما يوجد دليل في ذلك المسار، يمكنك إرجاع استجابة 204 حتى تكون طلبات إنشاء الدليل عديمة الأثر. وإذا وُجد ملف ليس دليلاً هناك، فأرجع رمز خطأ. الرمز 400 («طلب سيئ») سيكون مناسباً.
مساحة عامة على الويب
بما أن خادم الملفات يقدّم أي نوع من الملفات ويتضمن حتى ترويسة Content-Type الصحيحة، يمكنك استخدامه لتقديم موقع ويب. وبما أن هذا الخادم يتيح للجميع حذف الملفات واستبدالها، فسيصنع هذا نوعاً مثيراً من المواقع: موقع يمكن تعديله وتحسينه وتخريبه من كل من يستغرق الوقت لإرسال طلب HTTP الصحيح.
اكتب صفحة HTML أساسية تتضمن ملف JavaScript بسيطاً. ضع الملفات في دليل يقدمه خادم الملفات وافتحها في متصفحك.
بعد ذلك، كتمرين متقدم أو حتى مشروع نهاية أسبوع، اجمع كل المعرفة التي اكتسبتها من هذا الكتاب لبناء واجهة أسهل استخداماً لتعديل الموقع — من داخل الموقع.
استخدم نموذج HTML لتحرير محتوى الملفات التي يتكون منها الموقع، بحيث يتيح للمستخدم تحديثها على الخادم باستخدام طلبات HTTP، كما هو موصوف في الفصل 18.
ابدأ بجعل ملف واحد فقط قابلاً للتحرير. ثم اجعل الأمر بحيث يستطيع المستخدم اختيار الملف الذي يريد تحريره. واستفد من كون خادم ملفاتنا يُرجع قوائم ملفات عند قراءة دليل.
لا تعمل مباشرة في الشيفرة التي يكشفها خادم الملفات، لأنه إن ارتكبت خطأ، فمن المرجح أن تلحق الضرر بالملفات هناك. بدلاً من ذلك، أبقِ عملك خارج الدليل المتاح للعامة وانسخه هناك عند الاختبار.
إظهار التلميح
يمكنك إنشاء عنصر <textarea> ليحتفظ بمحتوى الملف الجاري تحريره. طلب GET، باستخدام fetch، يمكنه استرجاع المحتوى الحالي للملف. يمكنك استخدام عناوين URL نسبية مثل index.html، بدلاً من http://localhost:8000/index.html، للإشارة إلى ملفات على الخادم نفسه الذي يعمل عليه السكربت.
ثم، عندما ينقر المستخدم زراً (يمكنك استخدام عنصر <form> وحدث "submit")، أرسل طلب PUT إلى عنوان URL نفسه، مع محتوى <textarea> كجسم للطلب، لحفظ الملف.
يمكنك بعد ذلك إضافة عنصر <select> يحتوي على كل الملفات في الدليل الأعلى للخادم بإضافة عناصر <option> تحتوي على الأسطر التي يُرجعها طلب GET إلى عنوان URL /. وعندما يختار المستخدم ملفاً آخر (حدث "change" على الحقل)، يجب أن يجلب السكربت ذلك الملف ويعرضه. وعند حفظ ملف، استخدم اسم الملف المحدد حالياً.