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

مشروع بناء موقع لمشاركة المهارات باستعمال Node.js


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

لا أعلم بعد النبوة أفضل من بث العلم.

ـــ عبد الله بن المبارك.

chapter_picture_21.jpg

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

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

التصميم

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

skillsharing.png

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

الاستطلاع المفتوح

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

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

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

واجهة HTTP

ينبغي النظر أولًا قبل تصميم الخادم أو العميل إلى النقطة التي يتلاقى فيها كل منهما، وهي واجهة HTTP التي يتواصلان من خلالها، حيث سنستخدم JSON على أساس صيغة لطلبنا وعلى أساس متن للاستجابة أيضًا، كما سنستفيد من توابع HTTP وترويساته كما في خادم الملفات من المقال السابق المشار إليه سلفًا، وبما أنّ الواجهة تتمحور حول مسار ‎/talks، فستُستخدَم المسارات التي لا تبدأ بـ ‎/talks لخدمة الملفات الساكنة، وهي شيفرة HTML وجافاسكربت لنظام جانب العميل، فإذا أرسلنا طلب GET إلى /talks فسيعيد مستند JSON يشبه ما يلي:

[{"title": "Unituning",
  "presenter": "Jamal",
  "summary": "Modifying your cycle for extra style",
  "comments": []}]

تُنشأ الكلمة الجديدة بإنشاء طلب PUT إلى رابط مثل ‎/talks/Unituning، حيث يكون الجزء الذي بعد الشرطة الثانية هو عنوان الكلمة، ويجب احتواء متن طلب PUT على كائن JSON يستخدِم الخاصيتين presenter وsummary، وبما أنّ عناوين الكلمات تحتوي على مسافات ومحارف قد لا تظهر كما يجب لها في الرابط، فيجب ترميز سلاسل العناوين النصية بدالة encodeURIComponent عند بناء مثل تلك الروابط.

console.log("/talks/" + encodeURIComponent("How to Idle"));
// → /talks/How%20to%20Idle

قد يبدو طلب إنشاء كلمة عن الوقوف بالدراجة كما يلي:

PUT /talks/How%20to%20Idle HTTP/1.1
Content-Type: application/json
Content-Length: 92

{"presenter": "Hasan",
 "summary": "Standing still on a unicycle"}

تدعم مثل تلك الروابط طلبات GET لجلب تمثيل JSON لكلمة ما وطلبات DELETE لحذف الكلمة، كما تضاف التعليقات إلى الكلمة باستخدام طلب POST إلى رابط مثل ‎/talks/Unituning/comments مع متن JSON يحتوي على الخاصيتين author وmessage.

POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72

{"author": "Iman",
 "message": "Will you talk about raising a cycle?"}

قد تحتوي طلبات GET إلى ‎/talks على ترويسات إضافية تخبر الخادم بتأخير الإجابة إذا لم تتوفر معلومات جديدة، وذلك من أجل دعم الاستطلاع المفتوح، كما سنستخدِم زوجًا من الترويسات صُممتا أساسًا من أجل إدارة التخزين المؤقت وهما ETag وIf-None-Match، وقد تدرِج الخوادم ترويسة ETag -التي تشير إلى وسم الكتلة Entity Tag- في الاستجابة، بحيث تكون قيمتها سلسلةً نصيةً تعرِّف الإصدار الحالي للمورِد، وقد تنشئ العملاء طلبًا إضافيًا عندما تطلب هذا المورد مرةً ثانيةً من خلال إدراج ترويسة If-None-Match التي تحمل قيمتها السلسلة نفسها؛ أما إذا لم يتغير المورد، فسيستجيب الخادم برمز الحالة 304 والذي يعني "غير معدَّل not modified"، ليخبر العميل أنّ إصداره المخزَّن لا زال هو الإصدار الحالي؛ أما إذا لم يطابق الوسم، فسيستجيب الاستجابة العادية.

نحتاج إلى مثل ذلك النظام لأننا نريد تمكين العميل من إخبار الخادم بإصدار قائمة الكلمات التي لديه، وألا يستجيب الخادم إلا عند تغير تلك القائمة، لكن ينبغي على الخادم تأخير الإجابة وعدم الإعادة نهائيًا إلا عند توفر شيء جديد أو مرور مهلة زمنية محددة بدلًا من إعادة 304 مباشرةً، وعليه فمن أجل تمييز طلبات الاستطلاع المفتوح عن الطلبات الشرطية العادية، فإننا نعطيها ترويسةً أخرى هي Prefer: wait=90 التي تخبر الخادم باستعداد العميل لانتظار الاستجابة مدةً قدرها 90 ثانية، كما سيحتفظ الخادم برقم إصدار version number يحدِّثه في كل مرة تتغير فيها كلمة ما، وسيستخدم ذلك على أساس قيمة لوسم ETag، ويمكن للعملاء إنشاء طلبات مثل هذا ليتم إشعارها عند حدوث تغيير في الكلمة:

GET /talks HTTP/1.1
If-None-Match: "4"
Prefer: wait=90

(time passes)

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "5"
Content-Length: 295

[....]

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

الخادم

لنبدأ ببناء جانب الخادم من البرنامج، حيث ستعمل الشيفرة في هذا القسم على Node.js.

التوجيه Routing

سيستخدم خادمنا createServer من أجل بدء خادم HTTP، ويجب علينا التفريق في الدالة التي تعالج طلبًا جديدًا بين أنواع الطلبات المختلفة التي ندعمها وفقًا للتابع والمسار، وصحيح أنه يمكن تنفيذ ذلك بسلسلة طويلة من تعليمات if، إلا أنّ طريقة التوجيه أفضل، فالموجّه هو مكون يساعد في إرسال طلب إلى الدالة التي تستطيع معالجته، فنستطيع إخباره أنّ طلبات PUT مثلًا التي يطابق مسارها التعبير النمطي ‎/^\/talks\/([^\/]+)$/‎ -يشير إلى ‎/talks/‎ متبوعًا بعنوان الكلمة-، يمكن معالجتها بدالة ما، كما يساعد على استخراج أجزاء مفيدة من المسار -عنوان الكلمة في حالتنا- مغلَّفًا بين أقواس في التعبير النمطي ثم يمررها إلى الدالة المعالجة.

هناك عدة حزم موجهات جيدة على NPM، لكننا سنكتب واحدةً بأنفسنا لتوضيح الفكرة، وتوضِّح الشيفرة التالية router.js الذي سنطلبه من وحدة الخادم الخاص بنا عن طريق require لاحقًا:

const {parse} = require("url");

module.exports = class Router {
  constructor() {
    this.routes = [];
  }
  add(method, url, handler) {
    this.routes.push({method, url, handler});
  }
  resolve(context, request) {
    let path = parse(request.url).pathname;

    for (let {method, url, handler} of this.routes) {
      let match = url.exec(path);
      if (!match || request.method != method) continue;
      let urlParts = match.slice(1).map(decodeURIComponent);
      return handler(context, ...urlParts, request);
    }
    return null;
  }
};

تصدِّر الوحدة صنف Router، كما يسمح كائن الموجّه بتسجيل معالِجات جديدة باستخدام التابع add، ويمكن حل الطلبات باستخدام التابع resolve الخاص به، حيث سيعيد هذا التابع استجابةً عند العثور على معالج، وإذا لم يعثر فسيعيد قيمةً غير معرَّفة null، ويجرب طريقًا واحدًا في كل مرة بالترتيب الذي عرِّفَت به تلك الطرق إلى أن يعثر على تطابق، كما تُستدعَى الدوال المعالجة بقيمة context التي ستكون نسخة الخادم في حالتنا وسلاسل المطابقة لأيّ مجموعة تعرّفها في تعبيرنا النمطي وكائن الطلب، كما يجب فك تشفير روابط السلاسل النصية بما أنّ الرابط الخام قد يحتوي على رموز من تنسيق ‎%20.

تقديم الملفات

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

تصدِّر حزمة ecstatic دالةً يمكن استدعاؤها مع كائن تهيئة configuration object لإنتاج دالة معالجة طلبات، وسنستخدِم الخيار root لنخبر الخادم بالمكان الذي يجب أعليه البحث فيه عن الملفات، كما تقبل الدالة المعالِجة المعاملَين request وresponse ويمكن تمريرهما مباشرةً إلى createServer لإنشاء خادم لا يقدم لنا إلا الملفات فقط، كما نريد التحقق أولًا من الطلبات التي يجب معالجتها معالجةً خاصةً، لذا نغلفها في دالة أخرى.

const {createServer} = require("http");
const Router = require("./router");
const ecstatic = require("ecstatic");

const router = new Router();
const defaultHeaders = {"Content-Type": "text/plain"};

class SkillShareServer {
  constructor(talks) {
    this.talks = talks;
    this.version = 0;
    this.waiting = [];

    let fileServer = ecstatic({root: "./public"});
    this.server = createServer((request, response) => {
      let resolved = router.resolve(this, request);
      if (resolved) {
        resolved.catch(error => {
          if (error.status != null) return error;
          return {body: String(error), status: 500};
        }).then(({body,
                  status = 200,
                  headers = defaultHeaders}) => {
          response.writeHead(status, headers);
          response.end(body);
        });
      } else {
        fileServer(request, response);
      }
    });
  }
  start(port) {
    this.server.listen(port);
  }
  stop() {
    this.server.close();
  }
}

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

الكلمات على أساس موارد

تخزَّن الكلمات المقترحة في الخاصية talks للخادم، وهو كائن تكون أسماء خصائصه عناوين الكلمات، كما ستُكشف على أساس موارد HTTP تحت ‎/talks/[title]‎، لذا نحتاج إلى إضافة معالجات إلى الموجه الخاص بنا تستخدِم التوابع المختلفة التي تستطيع العملاء استخدامها كي تعمل معها، كما يجب على معالج طلبات GET التي تطلب كلمة بعينها البحث عن تلك الكلمة، ويستجيب ببيانات JSON لها أو باستجابة خطأ 404.

const talkPath = /^\/talks\/([^\/]+)$/;

router.add("GET", talkPath, async (server, title) => {
  if (title in server.talks) {
    return {body: JSON.stringify(server.talks[title]),
            headers: {"Content-Type": "application/json"}};
  } else {
    return {status: 404, body: `No talk '${title}' found`};
  }
});

تُحذَف الكلمة بحذفها من الكائن talks.

router.add("DELETE", talkPath, async (server, title) => {
  if (title in server.talks) {
    delete server.talks[title];
    server.updated();
  }
  return {status: 204};
});

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

function readStream(stream) {
  return new Promise((resolve, reject) => {
    let data = "";
    stream.on("error", reject);
    stream.on("data", chunk => data += chunk.toString());
    stream.on("end", () => resolve(data));
  });
}

أحد المعالِجات التي تحتاج إلى قراءة متون الطلبات هو PUT المستخدَم في إنشاء كلمات جديدة، ويجب عليه التحقق من إذا كانت البيانات المعطاة لها الخصائص presenter وsummary والتي تكون سلاسل نصية، فقد تكون أيّ بيانات قادمة من خارج النظام غير منطقية، ولا نريد إفساد نموذج بياناتنا الداخلية أو تعطيله إذا أتت طلبات سيئة bad requests، وإذا بدت البيانات صالحةً، فسيخزِّن المعالِج كائنًا يمثِّل الكلمة الجديدة في كائن talks، وهذا سيكتب فوق كلمة موجودة سلفًا في العنوان نفسه ويستدعي updated مرةً أخرى.

router.add("PUT", talkPath,
           async (server, title, request) => {
  let requestBody = await readStream(request);
  let talk;
  try { talk = JSON.parse(requestBody); }
  catch (_) { return {status: 400, body: "Invalid JSON"}; }

  if (!talk ||
      typeof talk.presenter != "string" ||
      typeof talk.summary != "string") {
    return {status: 400, body: "Bad talk data"};
  }
  server.talks[title] = {title,
                         presenter: talk.presenter,
                         summary: talk.summary,
                         comments: []};
  server.updated();
  return {status: 204};
});

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

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
           async (server, title, request) => {
  let requestBody = await readStream(request);
  let comment;
  try { comment = JSON.parse(requestBody); }
  catch (_) { return {status: 400, body: "Invalid JSON"}; }

  if (!comment ||
      typeof comment.author != "string" ||
      typeof comment.message != "string") {
    return {status: 400, body: "Bad comment data"};
  } else if (title in server.talks) {
    server.talks[title].comments.push(comment);
    server.updated();
    return {status: 204};
  } else {
    return {status: 404, body: `No talk '${title}' found`};
  }
});

إذا حاولنا إضافة تعليق إلى كلمة غير موجودة فسنحصل على الخطأ 404.

دعم الاستطلاع المفتوح

يُعَدّ الجزء المتعلق بمعالجة الاستطلاع المفتوح في هذا الخادم أمرًا مثيرًا، فقد يكون الطلب GET الآتي إلى ‎/talks طلبًا عاديًا أو طلب استطلاع مفتوح، وسيكون لدينا أماكن عدة يجب علينا فيها إرسال مصفوفة من الكلمات talks إلى العميل، لذا سنعرِّف تابعًا مساعدًا يبني مثل تلك المصفوفة ويدرِج ترويسة ETag في الاستجابة.

SkillShareServer.prototype.talkResponse = function() {
  let talks = [];
  for (let title of Object.keys(this.talks)) {
    talks.push(this.talks[title]);
  }
  return {
    body: JSON.stringify(talks),
    headers: {"Content-Type": "application/json",
              "ETag": `"${this.version}"`,
              "Cache-Control": "no-store"}
  };
};

يجب على المعالج النظر في ترويسات الطلب ليرى إذا كانت الترويستان If-None-Match وPrefer موجودتين أم لا، كما تخزِّن Node الترويسات التي تكون أسماؤها حساسةً لحالة الأحرف بأسماء ذات أحرف صغيرة.

router.add("GET", /^\/talks$/, async (server, request) => {
  let tag = /"(.*)"/.exec(request.headers["if-none-match"]);
  let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]);
  if (!tag || tag[1] != server.version) {
    return server.talkResponse();
  } else if (!wait) {
    return {status: 304};
  } else {
    return server.waitForChanges(Number(wait[1]));
  }
});

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

SkillShareServer.prototype.waitForChanges = function(time) {
  return new Promise(resolve => {
    this.waiting.push(resolve);
    setTimeout(() => {
      if (!this.waiting.includes(resolve)) return;
      this.waiting = this.waiting.filter(r => r != resolve);
      resolve({status: 304});
    }, time * 1000);
  });
};

يزيد تسجيل التغيير بالتابع updated قيمة الإصدار التي هي قيمة الخاصية version ويوقظ جميع الطلبات المنتظِرة.

SkillShareServer.prototype.updated = function() {
  this.version++;
  let response = this.talkResponse();
  this.waiting.forEach(resolve => resolve(response));
  this.waiting = [];
};

هكذا تكون شيفرة الخادم قد تمت، فإذا أنشأنا نسخةً من SkillShareServer وبدأناها عند المنفَذ 8000، فسيخدم خادم HTTP الناتج الملفات من المجلد الفرعي public مع واجهة لإدارة الكلمات تحت رابط ‎/talks.

new SkillShareServer(Object.create(null)).start(8000);

العميل

يتكون جانب العميل من موقع لمشاركة المهارات من ثلاثة ملفات هي صفحة HTML صغيرة وورقة تنسيقات style sheet وملف جافاسكربت.

HTML

يُعَدّ تقديم ملف اسمه index.html إحدى الطرق المستخدَمة بكثرة في خوادم الويب عند إنشاء طلب مباشرة إلى مسار موافق لمجلد ما، وتدعم وحدة خادم الملفات التي نستخدمها exstatic تلك الطريقة، فإذا أنشئ طلب إلى المسار / فسيبحث الخادم عن الملف ‎./public/index.html، حيث يكون ‎./public الجذر الذي أعطيناه إليه، ثم يعيد ذلك الملف إذا وجده، وعلى ذلك فإذا أردنا لصفحة أن تظهر عندما يوجَّه متصفح ما إلى خادمنا، فيجب علينا وضعها في public/index.html، حيث يكون ملف index الخاص بنا كما يلي:

<!doctype html>
<meta charset="utf-8">
<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">

<h1>Skill Sharing</h1>

<script src="skillsharing_client.js"></script>

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

الإجراءات

تتكون حالة التطبيق من قائمة من الكلمات واسم المستخدم، كما سنخزِّن ذلك في الكائن {talks,user}، ولا نريد السماح لواجهة المستخدِم بتعديل الحالة أو إرسال طلبات HTTP، بل قد تطلق إجراءات تصف ما الذي يحاول المستخدِم فعله، في حين تأخذ دالة handleAction مثل هذا الإجراء وتجعله يحدُث، كما تعالَج تغيرات الحالة في الدالة نفسها بما أنّ تحديثات حالتنا بسيطة جدًا.

function handleAction(state, action) {
  if (action.type == "setUser") {
    localStorage.setItem("userName", action.user);
    return Object.assign({}, state, {user: action.user});
  } else if (action.type == "setTalks") {
    return Object.assign({}, state, {talks: action.talks});
  } else if (action.type == "newTalk") {
    fetchOK(talkURL(action.title), {
      method: "PUT",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({
        presenter: state.user,
        summary: action.summary
      })
    }).catch(reportError);
  } else if (action.type == "deleteTalk") {
    fetchOK(talkURL(action.talk), {method: "DELETE"})
      .catch(reportError);
  } else if (action.type == "newComment") {
    fetchOK(talkURL(action.talk) + "/comments", {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({
        author: state.user,
        message: action.message
      })
    }).catch(reportError);
  }
  return state;
}

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

function fetchOK(url, options) {
  return fetch(url, options).then(response => {
    if (response.status < 400) return response;
    else throw new Error(response.statusText);
  });
}

تُستخدَم الدالة المساعدة التالية لبناء رابط لكلمة لها عنوان محدَّد.

function talkURL(title) {
  return "talks/" + encodeURIComponent(title);
}

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

function reportError(error) {
  alert(String(error));
}

إخراج المكونات Rendering Components

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

function renderUserField(name, dispatch) {
  return elt("label", {}, "Your name: ", elt("input", {
    type: "text",
    value: name,
    onchange(event) {
      dispatch({type: "setUser", user: event.target.value});
    }
  }));
}

الدالة elt المستخدَمة لبناء عناصر DOM هي نفسها التي استخدمناها في مقال إنجاز مشروع محرر رسوم نقطية باستخدام جافاسكربت المشار إليه أعلاه، وتُستخدَم دالة شبيهة بها لإخراج الكلمات، حيث تتضمن قائمةً من التعليقات واستمارةً من أجل إضافة تعليق جديد.

function renderTalk(talk, dispatch) {
  return elt(
    "section", {className: "talk"},
    elt("h2", null, talk.title, " ", elt("button", {
      type: "button",
      onclick() {
        dispatch({type: "deleteTalk", talk: talk.title});
      }
    }, "Delete")),
    elt("div", null, "by ",
        elt("strong", null, talk.presenter)),
    elt("p", null, talk.summary),
    ...talk.comments.map(renderComment),
    elt("form", {
      onsubmit(event) {
        event.preventDefault();
        let form = event.target;
        dispatch({type: "newComment",
                  talk: talk.title,
                  message: form.elements.comment.value});
        form.reset();
      }
    }, elt("input", {type: "text", name: "comment"}), " ",
       elt("button", {type: "submit"}, "Add comment")));
}

معالِج الحدث "submit" يستدعي form.reset لمسح محتوى الاستمارة بعد إنشاء الإجراء "newcomment"، وعند إنشاء أجزاء متوسطة التعقيد من DOM، فسيبدو هذا التنسيق من البرمجة فوضويًا، وهناك امتداد جافاسكربت واسع الاستخدام رغم أنه ليس قياسيًا ويسمى JSX، حيث يسمح لنا بكتابة HTML في السكربتات الخاصة بك مباشرةً مما يحسِّن من مظهر الشيفرة، لكن يجب علينا تشغيل برنامج ما قبل تشغيل الشيفرة نفسها ليحوّل شيفرة HTML الوهمية تلك إلى استدعاءات لدوال جافاسكربت مثل تلك التي نستخدمها ها هنا؛ أما التعليقات فستكون أبسط في الإخراج.

function renderComment(comment) {
  return elt("p", {className: "comment"},
             elt("strong", null, comment.author),
             ": ", comment.message);
}

أخيرًا، تُخرَج الاستمارة التي يستطيع المستخدِم استخدامها في إنشاء الكلمة كما يلي:

function renderTalkForm(dispatch) {
  let title = elt("input", {type: "text"});
  let summary = elt("input", {type: "text"});
  return elt("form", {
    onsubmit(event) {
      event.preventDefault();
      dispatch({type: "newTalk",
                title: title.value,
                summary: summary.value});
      event.target.reset();
    }
  }, elt("h3", null, "Submit a Talk"),
     elt("label", null, "Title: ", title),
     elt("label", null, "Summary: ", summary),
     elt("button", {type: "submit"}, "Submit"));
}

الاستطلاع

نحتاج إلى قائمة الكلمات الحالية إذا أردنا بدء التطبيق، وبما أن التحميل الابتدائي متعلق للغاية بعملية الاستطلاع المفتوح إذ يجب استخدام ETag من الحمل عند الاستطلاع، فسنكتب دالةً تظل تستطلع الخادم لـ ‎/talks وتستدعي دالة رد نداء عند توفر مجموعة كلمات جديدة.

async function pollTalks(update) {
  let tag = undefined;
  for (;;) {
    let response;
    try {
      response = await fetchOK("/talks", {
        headers: tag && {"If-None-Match": tag,
                         "Prefer": "wait=90"}
      });
    } catch (e) {
      console.log("Request failed: " + e);
      await new Promise(resolve => setTimeout(resolve, 500));
      continue;
    }
    if (response.status == 304) continue;
    tag = response.headers.get("ETag");
    update(await response.json());
  }
}

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

إذا أعاد الخادم استجابة 304 فهذا يعني انتهاء المهلة الزمنية المحددة لطلب استطلاع مفتوح، لذا يجب أن تبدأ الدالة الطلب التالي، فإذا كانت الاستجابة هي 200 العادية، فسيُقرأ متنها على أنه JSON ويمرَّر إلى رد النداء، كما تخزَّن قيمة الترويسة ETag من أجل التكرار التالي.

التطبيق

يربط المكون التالي واجهة المستخدِم كلها بعضها ببعض:

class SkillShareApp {
  constructor(state, dispatch) {
    this.dispatch = dispatch;
    this.talkDOM = elt("div", {className: "talks"});
    this.dom = elt("div", null,
                   renderUserField(state.user, dispatch),
                   this.talkDOM,
                   renderTalkForm(dispatch));
    this.syncState(state);
  }

  syncState(state) {
    if (state.talks != this.talks) {
      this.talkDOM.textContent = "";
      for (let talk of state.talks) {
        this.talkDOM.appendChild(
          renderTalk(talk, this.dispatch));
      }
      this.talks = state.talks;
    }
  }
}

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

function runApp() {
  let user = localStorage.getItem("userName") || "Anon";
  let state, app;
  function dispatch(action) {
    state = handleAction(state, action);
    app.syncState(state);
  }

  pollTalks(talks => {
    if (!app) {
      state = {user, talks};
      app = new SkillShareApp(state, dispatch);
      document.body.appendChild(app.dom);
    } else {
      dispatch({type: "setTalks", talks});
    }
  }).catch(reportError);
}

runApp();

إذا شغلنا الخادم وفتحنا نافذتَي متصفح لـ http://localhost:8000 جنبًا إلى جنب، فسيمكنك رؤية كيف أنّ الإجراءات الذي تحدِثه في إحدى النافذتين تظهر مباشرةً في الأخرى.

تدريبات

ستتضمن التدريبات التالية تعديل النظام المعرّف في هذا المقال، ولكي تعمل عليها تأكد من تحميل الشيفرة أولًا من هذا الرابط وتكون قد ثبّتَّ Node لديك من موقعها الرسمي، وكذلك اعتماديات المشروع باستخدام الأمر npm install.

ثبات القرص

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

إرشادات الحل

أبسط حل لهذا هو ترميز كائن talks كله على أنه JSON وإلقائه في ملف بواسطة writeFile، وهناك تابع update بالفعل يُستدعى في كل مرة تتغير فيها بيانات الخادم، حيث يمكن توسيعه لكتابة البيانات الجديدة على القرص.

اختر اسم ملف وليكن ‎./talks,json، ويمكن للخادم أن يحاول في قراءة هذا الملف باستخدام readFile عند بدء عمله، وإذا نجح فيمكن للخادم أن يستخدِم محتويات الملف على أساس تاريخ بدء له؛ لكن احذر، فكائن talks بدأ على أساس كائن ليس له نموذج أولي كي يمكن استخدام العامل in بصورة موثوقة.

ستعيد JSON.parse كائنات عادية يكون نموذجها الأولي هو Object.prototype، فإذا استخدمت JSON على أساس صيغة ملفات لك، فيجب عليك نسخ خصائص الكائن المعاد بواسطة JSON.parse في كائن جديد ليس له نموذج أولي.

إعادة ضبط حقول التعليقات

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

إرشادات الحل

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

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

ترجمة -بتصرف- للفصل الحادي والعشرين من كتاب 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.


×
×
  • أضف...