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

1. نظرة عامة

الهدف من هذا الدرس التطبيقي حول الترميز هو اكتساب خبرة في خدمات "الحوسبة بدون خادم" التي تقدّمها Google Cloud Platform:

  • Cloud Functions: لنشر وحدات صغيرة من منطق النشاط التجاري في شكل دوال تتفاعل مع أحداث مختلفة (رسائل Pub/Sub وملفات جديدة في Cloud Storage وطلبات HTTP وغير ذلك)
  • App Engine: لنشر تطبيقات الويب وواجهات برمجة تطبيقات الويب والأنظمة الخلفية للأجهزة الجوّالة والأصول الثابتة وتقديمها، مع إمكانات التوسّع والتصغير السريع
  • Cloud Run: لنشر الحاويات وتوسيع نطاقها، ويمكن أن تحتوي على أي لغة أو وقت تشغيل أو مكتبة

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

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

  • دالة Cloud Function: لاستيراد مجموعة البيانات الأولية للكتب المتوفرة في مكتبتنا، في قاعدة بيانات المستندات Cloud Firestore
  • حاوية Cloud Run: ستعرض واجهة REST API على محتوى قاعدة البيانات.
  • واجهة أمامية على الويب في App Engine: لتصفُّح قائمة الكتب من خلال طلب واجهة REST API.

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

705e014da0ca5e90.png

ما ستتعلمه

  • وظائف السحابة الإلكترونية
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. الإعداد والمتطلبات

إعداد البيئة بالسرعة التي تناسبك

  1. سجِّل الدخول إلى Google Cloud Console وأنشِئ مشروعًا جديدًا أو أعِد استخدام مشروع حالي. إذا لم يكن لديك حساب على Gmail أو Google Workspace، عليك إنشاء حساب.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • اسم المشروع هو الاسم المعروض للمشاركين في هذا المشروع. وهي سلسلة أحرف لا تستخدمها Google APIs. ويمكنك تعديلها في أي وقت.
  • رقم تعريف المشروع هو معرّف فريد في جميع مشاريع Google Cloud ولا يمكن تغييره بعد ضبطه. تنشئ Cloud Console تلقائيًا سلسلة فريدة، ولا يهمّك عادةً ما هي. في معظم دروس البرمجة، عليك الرجوع إلى رقم تعريف مشروعك (يُشار إليه عادةً باسم PROJECT_ID). إذا لم يعجبك رقم التعريف الذي تم إنشاؤه، يمكنك إنشاء رقم تعريف عشوائي آخر. يمكنك بدلاً من ذلك تجربة اسم من اختيارك ومعرفة ما إذا كان متاحًا. لا يمكن تغيير هذا الخيار بعد هذه الخطوة وسيظل ساريًا طوال مدة المشروع.
  • للعلم، هناك قيمة ثالثة، وهي رقم المشروع، تستخدمها بعض واجهات برمجة التطبيقات. يمكنك الاطّلاع على مزيد من المعلومات عن كل هذه القيم الثلاث في المستندات.
  1. بعد ذلك، عليك تفعيل الفوترة في Cloud Console لاستخدام موارد/واجهات برمجة تطبيقات Cloud. لن تكلفك تجربة هذا الدرس التطبيقي حول الترميز الكثير، إن وُجدت أي تكلفة على الإطلاق. لإيقاف الموارد وتجنُّب تحمّل تكاليف فوترة تتجاوز هذا البرنامج التعليمي، يمكنك حذف الموارد التي أنشأتها أو حذف المشروع. يمكن لمستخدمي Google Cloud الجدد الاستفادة من برنامج الفترة التجريبية المجانية بقيمة 300 دولار أمريكي.

بدء Cloud Shell

على الرغم من إمكانية تشغيل Google Cloud عن بُعد من الكمبيوتر المحمول، ستستخدم في هذا الدرس التطبيقي حول الترميز Google Cloud Shell، وهي بيئة سطر أوامر تعمل في السحابة الإلكترونية.

من Google Cloud Console، انقر على رمز Cloud Shell في شريط الأدوات أعلى يسار الصفحة:

84688aa223b1c3a2.png

لن يستغرق توفير البيئة والاتصال بها سوى بضع لحظات. عند الانتهاء، من المفترض أن يظهر لك ما يلي:

320e18fedb7fbe0.png

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

3- إعداد البيئة وتفعيل واجهات برمجة التطبيقات على السحابة الإلكترونية

لاستخدام الخدمات المختلفة التي سنحتاج إليها خلال هذا المشروع، سنفعّل بعض واجهات برمجة التطبيقات. سننفّذ ذلك من خلال تشغيل الأمر التالي في Cloud Shell:

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

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

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

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

$ export REGION=europe-west3

بما أنّنا سنخزّن البيانات في قاعدة بيانات Cloud Firestore، علينا إنشاء قاعدة البيانات:

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

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

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

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

4. الحصول على الشفرة‏

يمكنك الحصول على الرمز من مستودع Github التالي:

$ git clone https://github.com/glaforge/serverless-web-apis

تتم كتابة الرمز البرمجي للتطبيق باستخدام Node.JS.

ستتوفّر لك بنية المجلدات التالية ذات الصلة بهذا الدرس التطبيقي:

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

في ما يلي المجلدات ذات الصلة:

  • data: يحتوي هذا المجلد على عينة بيانات لقائمة تضم 100 كتاب.
  • function-import: ستوفّر هذه الدالة نقطة نهاية لاستيراد بيانات نموذجية.
  • run-crud: سيعرض هذا الحاوية واجهة برمجة تطبيقات على الويب للوصول إلى بيانات الكتب المخزّنة في Cloud Firestore.
  • appengine-frontend — سيعرض تطبيق الويب هذا على App Engine واجهة أمامية بسيطة للقراءة فقط لتصفّح قائمة الكتب.

5- بيانات مكتبة الكتب النموذجية

في مجلد البيانات، لدينا ملف books.json يحتوي على قائمة بمئة كتاب، ربما يستحق القراءة. مستند JSON هذا هو عبارة عن مصفوفة تحتوي على عناصر JSON. لنلقِ نظرة على شكل البيانات التي سنستوعبها من خلال Cloud Function:

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

تحتوي جميع إدخالات الكتب في هذه المصفوفة على المعلومات التالية:

  • isbn: رمز ISBN-13 الذي يحدّد الكتاب.
  • author: تمثّل هذه السمة اسم مؤلِّف الكتاب.
  • language: هي اللغة المنطوقة التي كُتب بها الكتاب.
  • pages: عدد صفحات الكتاب
  • استبدِل title بعنوان الكتاب.
  • year: تمثّل هذه السمة السنة التي تم فيها نشر الكتاب.

6. نقطة نهاية دالة لاستيراد بيانات عيّنة من الكتب

في هذا القسم الأول، سننفّذ نقطة النهاية التي سيتم استخدامها لاستيراد بيانات الكتب النموذجية. سنستخدم Cloud Functions لهذا الغرض.

استكشاف الرمز

لنبدأ بالاطّلاع على الملف package.json:

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

في التبعيات في وقت التشغيل، نحتاج فقط إلى وحدة @google-cloud/firestore NPM للوصول إلى قاعدة البيانات وتخزين بيانات الكتب. في الخلفية، يوفّر وقت تشغيل Cloud Functions أيضًا إطار عمل الويب Express، لذلك لسنا بحاجة إلى تعريفه كعنصر تابع.

في تبعيات التطوير، نحدّد إطار عمل الدوال (@google-cloud/functions-framework)، وهو إطار وقت التشغيل المستخدَم لاستدعاء الدوال. وهو إطار عمل مفتوح المصدر يمكنك استخدامه أيضًا على جهازك (في حالتنا، داخل Cloud Shell) لتشغيل الدوال بدون الحاجة إلى نشرها في كل مرة تجري فيها تغييرًا، ما يؤدي إلى تحسين حلقة ملاحظات التطوير.

لتثبيت الموارد الاعتمادية، استخدِم الأمر install:

$ npm install

يستخدم النص البرمجي start إطار عمل Functions Framework لمنحك أمرًا يمكنك استخدامه لتشغيل الدالة محليًا باستخدام التعليمات التالية:

$ npm start

يمكنك استخدام curl أو ربما معاينة الويب في Cloud Shell لطلبات HTTP GET للتفاعل مع الدالة.

لنلقِ نظرة الآن على ملف index.js الذي يحتوي على منطق دالة استيراد بيانات الكتب:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

ننشئ مثيلاً لوحدة Firestore، ونشير إلى مجموعة الكتب (على غرار جدول في قواعد البيانات العلائقية).

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

نحن بصدد تصدير دالة JavaScript parseBooks. هذه هي الدالة التي سنحدّدها عند نشرها لاحقًا.

تتحقّق التعليمات التالية مما يلي:

  • نحن نقبل فقط طلبات HTTP POST، وإلا سنعرض رمز الحالة 405 للإشارة إلى أنّه لا يُسمح باستخدام طرق HTTP الأخرى.
  • نحن نقبل فقط حمولات application/json، وإلا سنرسل رمز الحالة 406 للإشارة إلى أنّ هذا التنسيق غير مقبول للحمولة.
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

بعد ذلك، يمكننا استرداد حمولة JSON من خلال body للطلب. نحن بصدد إعداد عملية مجمّعة في Firestore لتخزين جميع الكتب بشكل مجمّع. نكرّر مصفوفة JSON التي تتألف من تفاصيل الكتاب، وننتقل إلى الحقول isbn وtitle وauthor وlanguage وpages وyear. سيكون رمز ISBN الخاص بالكتاب هو المفتاح الأساسي أو المعرّف.

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

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

تشغيل دالة الاستيراد واختبارها

قبل تشغيل الرمز، سنثبّت التبعيات باستخدام:

$ npm install

لتشغيل الدالة محليًا، سنستخدم أمر النص البرمجي start الذي حدّدناه في package.json، وذلك بفضل Functions Framework:

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

لإرسال طلب HTTP POST إلى الدالة المحلية، يمكنك تنفيذ ما يلي:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

عند تشغيل هذا الأمر، ستظهر لك النتيجة التالية التي تؤكّد أنّ الدالة تعمل على الجهاز المحلي:

{"status":"OK"}

يمكنك أيضًا الانتقال إلى واجهة مستخدم Cloud Console للتأكّد من أنّ البيانات مخزَّنة في Firestore:

409982568cebdbf8.png

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

نشر الدالة في السحابة الإلكترونية

لنشر الدالة في Cloud Functions، سنستخدم الأمر التالي في الدليل function-import:

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

ننشر الدالة بالاسم الرمزي bulk-import. يتم تشغيل هذه الدالة من خلال طلبات HTTP. نستخدم وقت تشغيل Node.JS 20. ننشر الدالة بشكل علني (من المفترض أن نؤمّن نقطة النهاية هذه). نحدّد المنطقة التي نريد أن تقيم فيها الدالة. ونشير إلى المصادر في الدليل المحلي ونستخدم parseBooks (دالة JavaScript التي تم تصديرها) كنقطة دخول.

بعد دقيقتَين أو أقل، يتم نشر الدالة في السحابة الإلكترونية. في واجهة مستخدم Cloud Console، من المفترض أن تظهر الدالة:

c910875d4dc0aaa8.png

في ناتج النشر، من المفترض أن تتمكّن من رؤية عنوان URL الخاص بالدالة، والذي يتّبع اصطلاح تسمية معيّنًا (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME})، وبالطبع، يمكنك أيضًا العثور على عنوان URL هذا لتشغيل HTTP في واجهة مستخدم Cloud Console، في علامة التبويب "المشغّل":

380ffc46eb56441e.png

يمكنك أيضًا استرداد عنوان URL من خلال سطر الأوامر باستخدام gcloud:

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

لنخزِّنها في متغيّر البيئة BULK_IMPORT_URL، حتى نتمكّن من إعادة استخدامها لاختبار الدالة التي تم نشرها.

اختبار الدالة التي تم نشرها

باستخدام أمر curl مشابه للأمر الذي استخدمناه سابقًا لاختبار تشغيل الدالة محليًا، سنختبر الدالة التي تم نشرها. التغيير الوحيد الذي سيتم إجراؤه هو عنوان URL:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

مرة أخرى، في حال نجاح العملية، من المفترض أن تعرض النتيجة التالية:

{"status":"OK"}

بعد نشر دالة الاستيراد وإعدادها، وبعد تحميل عينة البيانات، حان الوقت لتطوير واجهة REST API التي تعرض مجموعة البيانات هذه.

7. عقد REST API

على الرغم من أنّنا لا نحدّد عقد واجهة برمجة التطبيقات باستخدام، على سبيل المثال، مواصفات Open API، سنلقي نظرة على نقاط النهاية المختلفة لواجهة REST API.

تتبادل واجهة برمجة التطبيقات عناصر JSON الخاصة بالكتب، والتي تتألف من:

  • isbn (اختياري): يتكوّن من 13 حرفًا String يمثّل رمز ISBN صالحًا.
  • author: تمثّل هذه السمة قيمة String غير فارغة تشير إلى اسم مؤلّف الكتاب.
  • languageString غير فارغ يحتوي على اللغة التي كُتب بها الكتاب
  • pages: عدد موجب Integer لعدد صفحات الكتاب
  • titleString غير فارغ يتضمّن عنوان الكتاب
  • year: قيمة Integer لسنة نشر الكتاب

مثال على حمولة كتاب:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

GET /books

يمكنك الحصول على قائمة بجميع الكتب، مع إمكانية فلترتها حسب المؤلّف و/أو اللغة، وتقسيمها إلى صفحات تضم 10 نتائج في كل مرة.

حمولة الجسم: لا شيء.

معلَمات طلب البحث:

  • author (اختياري): لفلترة قائمة الكتب حسب المؤلف
  • language (اختياري): يفلتر قائمة الكتب حسب اللغة.
  • page (اختياري، القيمة التلقائية = 0) — يشير إلى ترتيب صفحة النتائج المطلوب عرضها.

تعرض هذه الدالة مصفوفة JSON تتضمّن عناصر الكتب.

رموز الحالة:

  • 200 — عندما ينجح الطلب في استرداد قائمة الكتب،
  • 400: في حال حدوث خطأ

POST /books وPOST /books/{isbn}

إرسال حمولة كتاب جديدة، إما باستخدام مَعلمة مسار isbn (في هذه الحالة، لا يلزم تضمين الرمز isbn في حمولة الكتاب) أو بدونها (في هذه الحالة، يجب تضمين الرمز isbn في حمولة الكتاب)

حمولة نص الطلب: عنصر كتاب

مَعلمات طلب البحث: لا يوجد

لا تعرض هذه الدالة أي قيمة.

رموز الحالة:

  • 201: عند تخزين الكتاب بنجاح
  • 406: إذا كان الرمز isbn غير صالح،
  • 400: في حال حدوث خطأ

GET /books/{isbn}

يسترد هذا الإجراء كتابًا من المكتبة، ويتم تحديد الكتاب من خلال الرمز isbn الذي يتم تمريره كمَعلمة مسار.

حمولة الجسم: لا شيء.

مَعلمات طلب البحث: لا يوجد

تعرض هذه الطريقة كائن JSON خاصًا بكتاب، أو كائن خطأ إذا لم يكن الكتاب متوفّرًا.

رموز الحالة:

  • 200 — إذا تم العثور على الكتاب في قاعدة البيانات
  • 400 — في حال حدوث خطأ
  • 404: إذا تعذّر العثور على الكتاب
  • 406: إذا كان الرمز isbn غير صالح

PUT /books/{isbn}

تعدّل هذه الطريقة كتابًا حاليًا، يتم تحديده من خلال isbn الذي يتم تمريره كمَعلمة مسار.

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

معلمات طلب البحث: لا يوجد

القيمة المعروضة: الكتاب المعدَّل.

رموز الحالة:

  • 200: عند تعديل الكتاب بنجاح
  • 400 — في حال حدوث خطأ
  • 406: إذا كان الرمز isbn غير صالح

DELETE /books/{isbn}

يحذف هذا الإجراء كتابًا حاليًا، يتم تحديده من خلال isbn الذي يتم تمريره كمَعلمة مسار.

حمولة الجسم: لا شيء.

مَعلمات طلب البحث: لا يوجد

لا تعرض هذه الدالة أي قيمة.

رموز الحالة:

  • 204: عند حذف الكتاب بنجاح
  • 400: في حال حدوث خطأ

8. نشر واجهة برمجة تطبيقات REST وإتاحتها في حاوية

استكشاف الرمز

Dockerfile

لنبدأ بالنظر إلى Dockerfile، الذي سيكون مسؤولاً عن إنشاء حاوية للرمز البرمجي لتطبيقنا:

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

نحن نستخدم صورة Node.JS 20 "مختصرة". نحن نعمل في الدليل /usr/src/app. نحن بصدد نسخ ملف package.json (التفاصيل أدناه) الذي يحدّد الملحقات، من بين أمور أخرى. نثبّت التبعيات باستخدام npm install، وننسخ رمز المصدر. أخيرًا، نشير إلى كيفية تشغيل هذا التطبيق باستخدام الأمر node index.js.

package.json

بعد ذلك، يمكننا إلقاء نظرة على الملف package.json:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

نحدّد أنّنا نريد استخدام Node.JS 14، كما كان الحال مع Dockerfile.

يعتمد تطبيق واجهة برمجة التطبيقات على الويب على ما يلي:

  • وحدة NPM في Firestore للوصول إلى بيانات الكتب في قاعدة البيانات
  • مكتبة cors للتعامل مع طلبات CORS، لأنّه سيتم استدعاء REST API من رمز العميل لواجهة تطبيق الويب الأمامية على App Engine
  • إطار عمل Express، وهو إطار عمل الويب الذي سنستخدمه لتصميم واجهة برمجة التطبيقات
  • ثم وحدة isbn3 التي تساعد في التحقّق من صحة رموز ISBN الخاصة بالكتب.

نحدّد أيضًا النص البرمجي start، الذي سيكون مفيدًا لبدء التطبيق محليًا لأغراض التطوير والاختبار.

index.js

لننتقل إلى الجزء الأساسي من الرمز، مع نظرة متعمقة على index.js:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

نطلب وحدة Firestore، ونشير إلى المجموعة books التي يتم فيها تخزين بيانات الكتب.

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

نستخدم Express، وهو إطار عمل الويب، لتنفيذ واجهة REST API. نحن نستخدم الوحدة body-parser لتحليل حمولات JSON التي يتم تبادلها مع واجهة برمجة التطبيقات.

وحدة querystring مفيدة لمعالجة عناوين URL، وهذا ما سيحدث عند إنشاء عناوين Link لأغراض تقسيم المحتوى على صفحات (سنتناول المزيد من التفاصيل حول هذا الموضوع لاحقًا).

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

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

سنستخدم وحدة isbn3 NPM لتحليل رموز ISBN والتحقّق من صحتها، وسنطوّر دالة مساعدة صغيرة تحلّل رموز ISBN، وستردّ برمز الحالة 406 في الردّ، إذا كانت رموز ISBN غير صالحة.

  • GET /books

لنلقِ نظرة على نقطة النهاية GET /books، جزءًا جزءًا:

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - -  - -  - -  - -  - -  - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

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

إذا حدث خطأ أثناء جلب الكتب، سنعرض خطأ مع رمز الحالة 400.

لنلقِ نظرة عن كثب على الجزء المقتطع من نقطة النهاية هذه:

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

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

ننفّذ طلب البحث، ونحصل على لقطة للبيانات، ونضع هذه النتائج في مصفوفة JavaScript سيتم عرضها في نهاية الدالة.

لننهِ شرح نقطة النهاية هذه من خلال الاطّلاع على إحدى الممارسات الجيدة: استخدام العنوان Link لتحديد روابط معرف موارد منتظم (URI) إلى الصفحات الأولى أو السابقة أو التالية أو الأخيرة من البيانات (في حالتنا، سنوفّر فقط الصفحات السابقة والتالية).

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

قد تبدو هذه العملية معقّدة بعض الشيء في البداية، ولكن ما نفعله هو إضافة رابط السابق إذا لم نكن في الصفحة الأولى من البيانات. ونضيف رابط التالي إذا كانت صفحة البيانات ممتلئة (أي تحتوي على الحد الأقصى لعدد الكتب كما هو محدّد بالثابت PAGE_SIZE، على افتراض أنّ هناك صفحة أخرى ستظهر مع المزيد من البيانات). نستخدم بعد ذلك الدالة resource#links() في Express لإنشاء العنوان المناسب باستخدام بنية الجملة الصحيحة.

للعلم، سيبدو عنوان الرابط على النحو التالي:

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /books وPOST /books/:isbn

كلا نقطتَي النهاية متوفّرتان هنا لإنشاء كتاب جديد. يتم تمرير رمز ISBN في حمولة الكتاب، بينما يتم تمريره في الآخر كمَعلمة مسار. في كلتا الحالتين، سيتم استدعاء الدالة createBook():

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

نتأكّد من أنّ الرمز isbn صالح، وإلا سنخرج من الدالة (مع ضبط رمز الحالة 406). نستردّ حقول الكتاب من الحمولة التي تم تمريرها في نص الطلب. بعد ذلك، سنخزّن تفاصيل الكتاب في Firestore. يتم عرض 201 عند النجاح، و400 عند التعذّر.

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

Location: /books/9781234567898
  • GET /books/:isbn

لنستردّ كتابًا محدّدًا برقم ISBN من Firestore.

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

وكالعادة، نتحقّق من صحة رقم ISBN. نُجري طلب بحث في Firestore لاسترداد الكتاب. تكون السمة snapshot.exists مفيدة لمعرفة ما إذا تم العثور على كتاب بالفعل. وفي حال عدم توفّرها، نرسل رسالة خطأ ورمز الحالة 404 لم يُعثر عليه. نسترد بيانات الكتاب، وننشئ عنصر JSON يمثّل الكتاب، ليتم عرضه.

  • PUT /books/:isbn

نستخدم طريقة PUT لتعديل كتاب حالي.

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

نعدّل حقل updated التاريخ/الوقت لتذكُّر آخر مرة عدّلنا فيها هذا السجلّ. نستخدم استراتيجية {merge:true} التي تستبدل الحقول الحالية بقيمها الجديدة (وإلا ستتم إزالة جميع الحقول، وسيتم حفظ الحقول الجديدة فقط في الحمولة، ما يؤدي إلى محو الحقول الحالية من التعديل السابق أو الإنشاء الأولي).

نضبط أيضًا العنوان Location للإشارة إلى معرّف الموارد المنتظم (URI) الخاص بالكتاب.

  • DELETE /books/:isbn

حذف الكتب عملية بسيطة. ما عليك سوى استدعاء الطريقة delete() في مرجع المستند. نعرض رمز الحالة 204 لأنّنا لا نعرض أي محتوى.

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

بدء خادم Express / Node

أخيرًا وليس آخرًا، نبدأ الخادم، الذي يتلقّى بيانات عبر المنفذ 8080 تلقائيًا:

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

تشغيل التطبيق محليًا

لتشغيل التطبيق على الجهاز، علينا أولاً تثبيت التبعيات باستخدام الأمر التالي:

$ npm install

ويمكننا بعد ذلك البدء بما يلي:

$ npm start

سيبدأ الخادم في localhost وسيستجيب للمنفذ 8080 تلقائيًا.

يمكنك أيضًا إنشاء حاوية Docker وتشغيل صورة الحاوية باستخدام الأوامر التالية:

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

يُعدّ التشغيل داخل Docker أيضًا طريقة رائعة للتأكّد من أنّ عملية وضع التطبيق في حاوية ستتم بشكل جيد أثناء إنشائه في السحابة الإلكترونية باستخدام Cloud Build.

اختبار واجهة برمجة التطبيقات

بغض النظر عن طريقة تشغيل رمز REST API (مباشرةً من خلال Node أو من خلال صورة حاوية Docker)، يمكننا الآن تنفيذ بعض طلبات البحث عليه.

  • إنشاء كتاب جديد (رقم ISBN في حمولة نص الطلب):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • إنشاء كتاب جديد (رقم ISBN في مَعلمة مسار):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • احذف كتابًا (الكتاب الذي أنشأناه):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • استرداد كتاب حسب رقم ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • تعديل كتاب حالي من خلال تغيير عنوانه فقط:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • استرداد قائمة الكتب (أول 10 كتب):
$ curl http://localhost:8080/books
  • للعثور على الكتب التي كتبها مؤلف معيّن، اتّبِع الخطوات التالية:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • أدرِج الكتب المكتوبة باللغة الإنجليزية:
$ curl http://localhost:8080/books?language=English
  • حمِّل الصفحة الرابعة من الكتب:
$ curl http://localhost:8080/books?page=3

يمكننا أيضًا دمج مَعلمات طلب البحث author وlanguage وbooks لتحسين البحث.

إنشاء REST API المستند إلى الحاويات ونشره

بما أنّنا سعداء بأنّ واجهة REST API تعمل وفقًا للخطة، فقد حان الوقت لنشرها على السحابة الإلكترونية، وتحديدًا على Cloud Run.

سننفّذ ذلك على خطوتَين:

  • أولاً، من خلال إنشاء صورة الحاوية باستخدام Cloud Build، باستخدام الأمر التالي:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • بعد ذلك، يمكنك نشر الخدمة باستخدام الأمر الثاني التالي:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

باستخدام الأمر الأول، تنشئ Cloud Build صورة الحاوية وتستضيفها في Container Registry. ينشر الأمر التالي صورة الحاوية من السجلّ، وينشرها في منطقة السحابة الإلكترونية.

يمكننا التحقّق مرة أخرى في واجهة مستخدم Cloud Console من أنّ خدمة Cloud Run تظهر الآن في القائمة:

f62fbca02a8127c0.png

آخر خطوة سننفّذها هنا هي استرداد عنوان URL لخدمة Cloud Run التي تم نشرها حديثًا، وذلك بفضل الأمر التالي:

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

سنحتاج إلى عنوان URL لواجهة برمجة تطبيقات REST على Cloud Run في القسم التالي، لأنّ رمز الواجهة الأمامية على App Engine سيتفاعل مع واجهة برمجة التطبيقات.

9- استضافة تطبيق ويب لتصفُّح المكتبة

آخر خطوة لإضافة بعض التألّق إلى هذا المشروع هي توفير واجهة أمامية على الويب تتفاعل مع واجهة REST API. لهذا الغرض، سنستخدم Google App Engine، مع بعض رموز JavaScript البرمجية من جهة العميل التي ستطلب البيانات من واجهة برمجة التطبيقات من خلال طلبات AJAX (باستخدام واجهة برمجة التطبيقات Fetch من جهة العميل).

على الرغم من أنّ تطبيقنا يتم نشره على وقت تشغيل Node.JS App Engine، إلا أنّه يتألف في الغالب من موارد ثابتة. لا يتضمّن التطبيق الكثير من الرموز البرمجية للخادم الخلفي، لأنّ معظم تفاعلات المستخدمين ستتم في المتصفّح من خلال JavaScript من جهة العميل. لن نستخدم أي إطار عمل JavaScript متطوّر للواجهة الأمامية، بل سنستخدم بعض JavaScript "العادي"، مع بعض "مكوّنات الويب" للواجهة باستخدام مكتبة مكوّنات الويب Shoelace:

  • مربّع اختيار لتحديد لغة الكتاب:

6fb9f741000a2dc1.png

  • مكوّن بطاقة لعرض تفاصيل حول كتاب معيّن (بما في ذلك رمز شريطي لتمثيل رقم ISBN الخاص بالكتاب، باستخدام مكتبة JsBarcode):

3aa21a9e16e3244e.png

  • وزر لتحميل المزيد من الكتب من قاعدة البيانات:

3925ad81c91bbac9.png

عند دمج كل هذه المكوّنات المرئية معًا، ستظهر صفحة الويب الناتجة لتصفّح مكتبتنا على النحو التالي:

18a5117150977d6.png

app.yaml ملف الإعداد

لنبدأ بالتعمّق في قاعدة الرموز البرمجية لتطبيق App Engine هذا من خلال إلقاء نظرة على ملف الإعدادات app.yaml. هذا ملف خاص بـ App Engine، ويسمح بإعداد عناصر مثل متغيّرات البيئة أو "المعالجات" المختلفة للتطبيق، أو تحديد أنّ بعض الموارد هي أصول ثابتة سيتم عرضها من خلال شبكة توصيل المحتوى (CDN) المضمّنة في App Engine.

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

نحدّد أنّ تطبيقنا هو تطبيق Node.JS، وأنّنا نريد استخدام الإصدار 14.

بعد ذلك، نحدّد متغيّر بيئة يشير إلى عنوان URL الخاص بخدمة Cloud Run. علينا تعديل العنصر النائب CHANGE_ME باستخدام عنوان URL الصحيح (اطّلِع على كيفية تغيير ذلك أدناه).

بعد ذلك، نحدّد معالجات مختلفة. تشير الملفات الثلاثة الأولى إلى موقع رمز HTML وCSS وJavaScript من جهة العميل، ضمن المجلد public/ والمجلدات الفرعية. يشير الخيار الرابع إلى أنّ عنوان URL الجذر لتطبيق App Engine يجب أن يشير إلى الصفحة index.html. بهذه الطريقة، لن تظهر اللاحقة index.html في عنوان URL عند الوصول إلى جذر الموقع الإلكتروني. والأخير هو الإعداد التلقائي الذي سيوجه جميع عناوين URL الأخرى (/.*) إلى تطبيق Node.JS (أي الجزء "الديناميكي" من التطبيق، على عكس الأصول الثابتة التي وصفناها).

لنعدّل الآن عنوان URL لواجهة Web API الخاصة بخدمة Cloud Run.

في دليل appengine-frontend/، شغِّل الأمر التالي لتعديل متغيّر البيئة الذي يشير إلى عنوان URL لواجهة برمجة تطبيقات REST المستندة إلى Cloud Run:

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

أو غيِّر السلسلة CHANGE_ME يدويًا في app.yaml باستخدام عنوان URL الصحيح:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

package.json ملف Node.JS

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

نؤكّد مرة أخرى أنّنا نريد تشغيل هذا التطبيق باستخدام Node.JS 14. نعتمد على إطار عمل Express، بالإضافة إلى وحدة isbn3 NPM للتحقّق من صحة رموز ISBN الخاصة بالكتب.

في ملحقات التطوير، سنستخدم الوحدة nodemon لتتبُّع التغييرات في الملفات. على الرغم من أنّه يمكننا تشغيل تطبيقنا محليًا باستخدام npm start، وإجراء بعض التغييرات على الرمز، وإيقاف التطبيق باستخدام ^C، ثم إعادة تشغيله، إلا أنّ هذه العملية مملة بعض الشيء. بدلاً من ذلك، يمكننا استخدام الأمر التالي لإعادة تحميل التطبيق أو إعادة تشغيله تلقائيًا عند إجراء تغييرات:

$ npm run dev

index.js رمز Node.JS

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

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

نحن بحاجة إلى إطار عمل الويب Express. نحدّد أنّ الدليل العام يحتوي على مواد عرض ثابتة يمكن عرضها (على الأقل عند التشغيل محليًا في وضع التطوير) بواسطة برنامج static الوسيط. أخيرًا، نحتاج إلى body-parser لتحليل حمولات JSON.

لنلقِ نظرة على المسارين اللذين حدّدناهما:

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

سيتم إعادة توجيه أول نتيجة تطابق / إلى index.html في دليل public/html. بما أنّنا لا نعمل في وضع التطوير ضمن وقت تشغيل App Engine، لا يمكننا الاستفادة من ميزة توجيه عناوين URL في App Engine. لذلك، نعيد توجيه عنوان URL الجذر إلى ملف HTML.

ستعرض نقطة النهاية الثانية التي نحدّدها /webapi عنوان URL لواجهة برمجة تطبيقات REST في Cloud Run. بهذه الطريقة، سيعرف رمز JavaScript من جهة العميل المكان الذي يجب الاتصال به للحصول على قائمة الكتب.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

لإنهاء العملية، سنشغّل تطبيق الويب Express وسنستمع إلى المنفذ 8080 تلقائيًا.

index.html الصفحة

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

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

يستورد السطران الأولان مكتبة مكونات الويب في Shoelace (نص برمجي وورقة أنماط).

يستورد السطر التالي مكتبة JsBarcode لإنشاء الرموز الشريطية لرموز ISBN الخاصة بالكتب.

تستورد الأسطر الأخيرة رمز JavaScript الخاص بنا وورقة أنماط CSS، وكلاهما يقعان في الدليلَين الفرعيَين public/.

في body من صفحة HTML، نستخدم مكوّنات Shoelace مع علامات العناصر المخصّصة، مثل:

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

نستخدم أيضًا نماذج HTML وإمكانية ملء الخانات لتمثيل كتاب. سننشئ نُسخًا من هذا النموذج لملء قائمة الكتب، وسنستبدل القيم في الخانات بتفاصيل الكتب:

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

يكفي هذا القدر من HTML، فقد أوشكنا على الانتهاء من مراجعة الرمز. يتبقى جزء أخير مهم وهو رمز JavaScript app.js من جهة العميل الذي يتفاعل مع واجهة REST API.

رمز JavaScript من جهة العميل في app.js

نبدأ بمتتبِّع أحداث ذي مستوى أعلى ينتظر تحميل محتوى DOM:

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

بعد أن يصبح جاهزًا، يمكننا إعداد بعض الثوابت والمتغيرات الرئيسية:

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

أولاً، سنسترجع عنوان URL الخاص بواجهة REST API، وذلك بفضل رمز عقدة App Engine الذي يعرض متغيّر البيئة الذي ضبطناه في البداية في app.yaml. بفضل متغيّر البيئة، لم نضطر إلى تضمين عنوان URL لواجهة REST API في رمز الواجهة الأمامية، وذلك عند استدعاء نقطة النهاية /webapi من رمز JavaScript من جهة العميل.

نحدّد أيضًا المتغيّرين page وlanguage اللذين سنستخدمهما لتتبُّع تقسيم الصفحات وتصفية اللغة.

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

نضيف معالج أحداث إلى الزر لتحميل الكتب. عند النقر عليه، سيتم استدعاء الدالة appendMoreBooks().

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

وينطبق الأمر نفسه على مربّع الاختيار، حيث نضيف معالج أحداث ليتم إعلامنا بالتغييرات في اختيار اللغة. وكما هو الحال مع الزر، نستدعي أيضًا الدالة appendMoreBooks()، ونمرّر عنوان URL لواجهة REST API والصفحة الحالية واختيار اللغة.

لنلقِ نظرة على الدالة التي تسترد الكتب وتلحقها:

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

في ما سبق، كنا ننشئ عنوان URL الدقيق الذي سيتم استخدامه لطلب بيانات من واجهة REST API. هناك ثلاث مَعلمات طلب بحث يمكننا تحديدها عادةً، ولكن في واجهة المستخدم هذه، نحدّد مَعلمتَين فقط:

  • page: عدد صحيح يشير إلى الصفحة الحالية لتقسيم الكتب إلى صفحات
  • language: سلسلة لغة للفلترة حسب اللغة المكتوبة

بعد ذلك، نستخدم Fetch API لاسترداد مصفوفة JSON التي تحتوي على تفاصيل الكتاب.

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

استنادًا إلى ما إذا كان العنوان Link متوفّرًا في الردّ، سنعرض الزر [More books...] أو نخفيه، لأنّ العنوان Link هو تلميح يخبرنا ما إذا كان هناك المزيد من الكتب التي لم يتم تحميلها بعد (سيكون هناك عنوان URL next في العنوان Link).

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

في القسم أعلاه من الدالة، لكل كتاب يتم عرضه من خلال واجهة REST API، سنستنسخ النموذج باستخدام بعض مكوّنات الويب التي تمثّل كتابًا، وسنملأ خانات النموذج بتفاصيل الكتاب.

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

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

تشغيل التطبيق واختباره على الجهاز

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

نثبّت وحدات NPM التي يحتاجها تطبيقنا باستخدام الأمر التالي:

$ npm install

ويمكننا تشغيل التطبيق باستخدام الأمر المعتاد:

$ npm start

أو مع إعادة التحميل التلقائي للتغييرات بفضل nodemon، باستخدام:

$ npm run dev

يتم تشغيل التطبيق على الجهاز، ويمكننا الوصول إليه من المتصفّح على الرابط http://localhost:8080.

نشر تطبيق App Engine

بعد التأكّد من أنّ تطبيقنا يعمل بشكل جيد على الجهاز المحلي، حان الوقت لنشره على App Engine.

لتنفيذ التطبيق، لننفِّذ الأمر التالي:

$ gcloud app deploy -q

بعد دقيقة تقريبًا، من المفترض أن يتم نشر التطبيق.

سيكون التطبيق متاحًا على عنوان URL بالشكل التالي: https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

استكشاف واجهة المستخدم لتطبيق الويب على App Engine

يمكنك الآن:

  • انقر على الزر [More books...] لتحميل المزيد من الكتب.
  • اختَر لغة معيّنة للاطّلاع على الكتب بهذه اللغة فقط.
  • يمكنك إلغاء الاختيار باستخدام علامة التقاطع الصغيرة في مربّع الاختيار للرجوع إلى قائمة جميع الكتب.

10. إخلاء مساحة (اختياري)

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

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. تهانينا!

لقد أنشأنا مجموعة من الخدمات، بفضل Cloud Functions وApp Engine وCloud Run، لعرض نقاط نهاية مختلفة لواجهة برمجة تطبيقات الويب وواجهة أمامية للويب، وذلك لتخزين مكتبة من الكتب وتعديلها وتصفّحها، مع اتّباع بعض أنماط التصميم الجيدة لتطوير REST API على طول الطريق.

المواضيع التي تناولناها

  • وظائف السحابة الإلكترونية
  • Cloud Firestore
  • Cloud Run
  • App Engine

مزيد من المعلومات

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

  • يمكنك الاستفادة من API Gateway لتوفير واجهة برمجة تطبيقات مشتركة لوظيفة استيراد البيانات وحاوية REST API، أو لإضافة ميزات مثل التعامل مع مفاتيح واجهة برمجة التطبيقات للوصول إلى واجهة برمجة التطبيقات، أو تحديد حدود المعدّل لمستخدمي واجهة برمجة التطبيقات.
  • يمكنك نشر وحدة عقدة Swagger-UI في تطبيق App Engine لتوثيق واجهة REST API وتوفير ساحة لعب تجريبية لها.
  • في الواجهة الأمامية، بالإضافة إلى إمكانية التصفّح الحالية، أضِف شاشات إضافية لتعديل البيانات وإنشاء إدخالات كتب جديدة. بما أنّنا نستخدم قاعدة بيانات Cloud Firestore، يمكننا الاستفادة من ميزة الوقت الفعلي لتعديل بيانات الكتب المعروضة عند إجراء تغييرات.