کارگاه APIهای وب بدون سرور

۱. مرور کلی

هدف این آزمایشگاه کد، کسب تجربه در زمینه سرویس‌های «بدون سرور» ارائه شده توسط پلتفرم ابری گوگل است:

  • توابع ابری — برای استقرار واحدهای کوچک منطق کسب‌وکار در قالب توابع، که به رویدادهای مختلف (پیام‌های Pub/Sub، فایل‌های جدید در Cloud Storage، درخواست‌های HTTP و موارد دیگر) واکنش نشان می‌دهند.
  • موتور برنامه - برای استقرار و ارائه برنامه‌های وب، APIهای وب، بک‌اندهای موبایل، دارایی‌های استاتیک، با قابلیت‌های افزایش و کاهش مقیاس سریع،
  • Cloud Run — برای استقرار و مقیاس‌بندی کانتینرها، که می‌توانند شامل هر زبان، زمان اجرا یا کتابخانه‌ای باشند.

و کشف چگونگی بهره‌گیری از آن سرویس‌های بدون سرور برای استقرار و مقیاس‌پذیری APIهای وب و REST، در عین حال مشاهده برخی از اصول طراحی RESTful خوب در طول مسیر.

در این کارگاه، یک جستجوگر قفسه کتاب خواهیم ساخت که شامل موارد زیر است:

  • یک تابع ابری: برای وارد کردن مجموعه داده‌های اولیه کتاب‌های موجود در کتابخانه ما، در پایگاه داده اسناد Cloud Firestore ،
  • یک کانتینر Cloud Run: که یک REST API را روی محتوای پایگاه داده ما نمایش می‌دهد،
  • یک رابط کاربری وب App Engine: برای مرور فهرست کتاب‌ها، با فراخوانی REST API ما.

در پایان این کدنویسی، ظاهر رابط کاربری وب به این شکل خواهد بود:

705e014da0ca5e90.png

آنچه یاد خواهید گرفت

  • توابع ابری
  • فروشگاه ابری فایر استور
  • اجرای ابری
  • موتور برنامه

۲. تنظیمات و الزامات

تنظیم محیط خودتنظیم

  1. وارد کنسول گوگل کلود شوید و یک پروژه جدید ایجاد کنید یا از یک پروژه موجود دوباره استفاده کنید. اگر از قبل حساب جیمیل یا گوگل ورک اسپیس ندارید، باید یکی ایجاد کنید .

۲۹۵۰۰۴۸۲۱bab6a87.png

37d264871000675d.png

۹۶d86d3d5655cdbe.png

  • نام پروژه، نام نمایشی برای شرکت‌کنندگان این پروژه است. این یک رشته کاراکتری است که توسط APIهای گوگل استفاده نمی‌شود. شما همیشه می‌توانید آن را به‌روزرسانی کنید.
  • شناسه پروژه در تمام پروژه‌های گوگل کلود منحصر به فرد است و تغییرناپذیر است (پس از تنظیم، قابل تغییر نیست). کنسول کلود به طور خودکار یک رشته منحصر به فرد تولید می‌کند؛ معمولاً برای شما مهم نیست که چه باشد. در اکثر آزمایشگاه‌های کد، باید شناسه پروژه خود را (که معمولاً با عنوان PROJECT_ID شناخته می‌شود) ارجاع دهید. اگر شناسه تولید شده را دوست ندارید، می‌توانید یک شناسه تصادفی دیگر ایجاد کنید. به عنوان یک جایگزین، می‌توانید شناسه خودتان را امتحان کنید و ببینید که آیا در دسترس است یا خیر. پس از این مرحله قابل تغییر نیست و در طول پروژه باقی می‌ماند.
  • برای اطلاع شما، یک مقدار سوم، شماره پروژه ، وجود دارد که برخی از APIها از آن استفاده می‌کنند. برای کسب اطلاعات بیشتر در مورد هر سه این مقادیر، به مستندات مراجعه کنید.
  1. در مرحله بعد، برای استفاده از منابع/API های ابری، باید پرداخت صورتحساب را در کنسول ابری فعال کنید . اجرای این آزمایشگاه کد هزینه زیادی نخواهد داشت، اگر اصلاً هزینه‌ای داشته باشد. برای خاموش کردن منابع به منظور جلوگیری از پرداخت صورتحساب پس از این آموزش، می‌توانید منابعی را که ایجاد کرده‌اید یا پروژه را حذف کنید. کاربران جدید Google Cloud واجد شرایط برنامه آزمایشی رایگان ۳۰۰ دلاری هستند.

شروع پوسته ابری

اگرچه می‌توان از راه دور و از طریق لپ‌تاپ، گوگل کلود را مدیریت کرد، اما در این آزمایشگاه کد، از گوگل کلود شل ، یک محیط خط فرمان که در فضای ابری اجرا می‌شود، استفاده خواهید کرد.

از کنسول گوگل کلود ، روی آیکون Cloud Shell در نوار ابزار بالا سمت راست کلیک کنید:

۸۴۶۸۸aa۲۲۳b۱c۳a۲.png

آماده‌سازی و اتصال به محیط فقط چند لحظه طول می‌کشد. وقتی تمام شد، باید چیزی شبیه به این را ببینید:

320e18fedb7fbe0.png

این ماشین مجازی با تمام ابزارهای توسعه‌ای که نیاز دارید، مجهز شده است. این ماشین مجازی یک دایرکتوری خانگی پایدار ۵ گیگابایتی ارائه می‌دهد و روی فضای ابری گوگل اجرا می‌شود که عملکرد شبکه و احراز هویت را تا حد زیادی بهبود می‌بخشد. تمام کارهای شما در این آزمایشگاه کد را می‌توان در یک مرورگر انجام داد. نیازی به نصب چیزی ندارید.

۳. محیط را آماده کنید و APIهای ابری را فعال کنید

برای استفاده از سرویس‌های مختلفی که در طول این پروژه به آنها نیاز خواهیم داشت، چند API را فعال خواهیم کرد. این کار را با اجرای دستور زیر در 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 

این سه فهرست مربوط به جستجوهایی هستند که ما بر اساس نویسنده یا زبان انجام خواهیم داد، ضمن اینکه ترتیب در مجموعه را از طریق یک فیلد به‌روزرسانی‌شده حفظ می‌کنیم.

۴. کد را دریافت کنید

کد را از مخزن گیت‌هاب زیر دریافت کنید:

$ 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 — این پوشه شامل داده‌های نمونه‌ای از فهرستی از ۱۰۰ کتاب است.
  • function-import — این تابع یک نقطه پایانی برای وارد کردن داده‌های نمونه ارائه می‌دهد.
  • run-crud — این کانتینر یک API وب را برای دسترسی به داده‌های کتاب ذخیره شده در Cloud Firestore در معرض نمایش قرار می‌دهد.
  • appengine-frontend — این برنامه وب App Engine یک رابط کاربری ساده فقط خواندنی برای مرور لیست کتاب‌ها نمایش می‌دهد.

۵. نمونه داده‌های کتابخانه کتاب

در پوشه داده، یک فایل books.json داریم که شامل فهرستی از صد کتاب است که احتمالاً ارزش خواندن دارد. این سند JSON آرایه‌ای است که شامل اشیاء JSON می‌شود. بیایید نگاهی به شکل داده‌هایی که از طریق یک تابع ابری دریافت خواهیم کرد، بیندازیم:

[
  {
    "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 — سالی که کتاب منتشر شده است.

۶. یک نقطه پایانی تابع برای وارد کردن داده‌های نمونه کتاب

در این بخش اول، نقطه پایانی را که برای وارد کردن داده‌های نمونه کتاب استفاده خواهد شد، پیاده‌سازی خواهیم کرد. برای این منظور از توابع ابری استفاده خواهیم کرد.

کد را کاوش کنید

بیایید با بررسی فایل 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"
    }
}

در وابستگی‌های زمان اجرا، ما فقط به ماژول NPM @google-cloud/firestore برای دسترسی به پایگاه داده و ذخیره داده‌های کتاب خود نیاز داریم. در باطن، Cloud Functions runtime چارچوب وب Express را نیز ارائه می‌دهد، بنابراین نیازی به اعلام آن به عنوان یک وابستگی نداریم.

در وابستگی‌های توسعه، ما Functions Framework ( @google-cloud/functions-framework ) را تعریف می‌کنیم، که چارچوب زمان اجرا برای فراخوانی توابع شما است. این یک چارچوب متن‌باز است که می‌توانید آن را به صورت محلی روی دستگاه خود (در مورد ما، درون Cloud Shell) نیز استفاده کنید تا توابع را بدون نیاز به استقرار در هر بار ایجاد تغییر، اجرا کنید و در نتیجه حلقه بازخورد توسعه را بهبود بخشید.

برای نصب وابستگی‌ها، از دستور install استفاده کنید:

$ npm install

اسکریپت start از چارچوب توابع استفاده می‌کند تا دستوری را در اختیار شما قرار دهد که می‌توانید با استفاده از آن، تابع را به صورت محلی با دستورالعمل زیر اجرا کنید:

$ 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;
    }
    ... 
})

ما در حال اکسپورت تابع جاوا اسکریپت 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()
        });
    }

سپس، می‌توانیم از طریق body درخواست، محتوای JSON را بازیابی کنیم. ما در حال آماده‌سازی یک عملیات دسته‌ای 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 script که در package.json تعریف کردیم، استفاده خواهیم کرد:

$ 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 ذخیره شده‌اند:

۴۰۹۹۸۲۵۶۸cebdbf8.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 (تابع جاوا اسکریپت صادر شده) به عنوان نقطه ورود استفاده می‌کنیم.

بعد از چند دقیقه یا کمتر، تابع در فضای ابری مستقر می‌شود. در رابط کاربری کنسول ابری، باید تابع را به صورت زیر مشاهده کنید:

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 را برای نمایش این مجموعه داده توسعه دهیم.

۷. قرارداد REST API

اگرچه ما یک قرارداد API را مثلاً با استفاده از مشخصات Open API تعریف نمی‌کنیم، اما قصد داریم نگاهی به نقاط پایانی مختلف REST API خود بیندازیم.

این API اشیاء JSON را که شامل موارد زیر است، در کتاب خود مبادله می‌کند:

  • isbn (اختیاری) - یک String ۱۳ کاراکتری که نشان دهنده یک کد ISBN معتبر است،
  • author - یک String غیر تهی که نام نویسنده کتاب را نشان می‌دهد،
  • language - یک String غیر تهی که شامل زبانی است که کتاب با آن نوشته شده است،
  • pages - یک Integer مثبت برای تعداد صفحات کتاب،
  • title - یک String غیر خالی با عنوان کتاب،
  • year — یک مقدار Integer برای سال انتشار کتاب.

نمونه بار کتاب:

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

دریافت /کتاب‌ها

فهرست تمام کتاب‌ها را دریافت کنید، که احتمالاً بر اساس نویسنده و/یا زبان فیلتر شده‌اند و بر اساس پنجره‌هایی با ۱۰ نتیجه به طور همزمان صفحه‌بندی شده‌اند.

بار مفید بدنه: هیچکدام

پارامترهای پرس و جو:

  • author (اختیاری) — فهرست کتاب‌ها را بر اساس نویسنده فیلتر می‌کند،
  • language (اختیاری) — فهرست کتاب‌ها را بر اساس زبان فیلتر می‌کند،
  • page (اختیاری، پیش‌فرض = ۰) — رتبه صفحه نتایجی که باید برگردانده شود را نشان می‌دهد.

خروجی: یک آرایه JSON از اشیاء کتاب.

کدهای وضعیت:

  • 200 — وقتی درخواست با موفقیت فهرست کتاب‌ها را دریافت می‌کند،
  • 400 - اگر خطایی رخ دهد.

POST /books و POST /books/{isbn}

یک فایل کتاب جدید ارسال کنید، یا با پارامتر مسیر isbn (که در این صورت کد isbn در فایل کتاب لازم نیست) یا بدون آن (که در این صورت کد isbn باید در فایل کتاب موجود باشد)

بار مفید بدنه: یک شیء کتاب.

پارامترهای پرس و جو: هیچکدام

بازده: هیچ چیز.

کدهای وضعیت:

  • 201 — وقتی کتاب با موفقیت ذخیره می‌شود،
  • 406 — اگر کد isbn نامعتبر باشد،
  • 400 - اگر خطایی رخ دهد.

دریافت /books/{isbn}

کتابی را از کتابخانه بازیابی می‌کند که با کد isbn آن مشخص شده و به عنوان پارامتر مسیر ارسال می‌شود.

بار مفید بدنه: هیچکدام

پارامترهای پرس و جو: هیچکدام

مقدار بازگشتی: یک شیء JSON مربوط به کتاب، یا یک شیء خطا در صورت عدم وجود کتاب.

کدهای وضعیت:

  • 200 — اگر کتاب در پایگاه داده یافت شود،
  • 400 - اگر خطایی رخ دهد،
  • 404 — اگر کتاب پیدا نشد،
  • 406 — اگر کد isbn نامعتبر باشد.

قرار دادن /books/{isbn}

یک کتاب موجود را که با isbn آن که به عنوان پارامتر مسیر ارسال شده است، شناسایی می‌شود، به‌روزرسانی می‌کند.

بار مفید بدنه: یک شیء کتاب. فقط فیلدهایی که نیاز به به‌روزرسانی دارند می‌توانند ارسال شوند، بقیه اختیاری هستند.

پارامترهای پرس و جو: هیچکدام

بازده: کتاب به‌روز شده.

کدهای وضعیت:

  • 200 — وقتی کتاب با موفقیت به‌روزرسانی شود،
  • 400 - اگر خطایی رخ دهد،
  • 406 — اگر کد isbn نامعتبر باشد.

حذف /books/{isbn}

یک کتاب موجود را که با isbn آن که به عنوان پارامتر مسیر ارسال شده است، شناسایی می‌شود، حذف می‌کند.

بار مفید بدنه: هیچکدام

پارامترهای پرس و جو: هیچکدام

بازده: هیچ چیز.

کدهای وضعیت:

  • 204 — وقتی کتاب با موفقیت حذف می‌شود،
  • 400 - اگر خطایی رخ دهد.

۸. یک REST API را در یک کانتینر مستقر و در معرض نمایش قرار دهید

کد را کاوش کنید

داکرفایل

بیایید با بررسی Dockerfile شروع کنیم، که مسئول کانتینرایز کردن کد برنامه ما خواهد بود:

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

ما از یک ایمیج "slim" نسخه 20 نود جی اس استفاده می‌کنیم. ما در دایرکتوری /usr/src/app کار می‌کنیم. ما فایل package.json (جزئیات در زیر) را که وابستگی‌های ما را تعریف می‌کند، کپی می‌کنیم. ما وابستگی‌ها را با npm install نصب می‌کنیم و کد منبع را کپی می‌کنیم. در نهایت، با دستور node index.js نحوه اجرای این برنامه را مشخص می‌کنیم.

بسته.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 نیز همینطور بود.

برنامه وب API ما به موارد زیر بستگی دارد:

  • ماژول Firestore NPM برای دسترسی به داده‌های کتاب در پایگاه داده،
  • کتابخانه cors برای مدیریت درخواست‌های CORS (اشتراک‌گذاری منابع بین‌منبعی)، زیرا REST API ما از کد کلاینتِ رابط کاربری برنامه وب App Engine ما فراخوانی خواهد شد.
  • چارچوب اکسپرس، که چارچوب وب ما برای طراحی API ما خواهد بود،
  • و سپس ماژول 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 که با API ما رد و بدل می‌شوند، استفاده می‌کنیم.

ماژول 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 را تجزیه می‌کند و در صورت نامعتبر بودن کدهای ISBN، کد وضعیت 406 را در پاسخ نمایش می‌دهد.

  • 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 تایی کتاب برمی‌گردانیم.

اگر در طول مسیر، هنگام دریافت کتاب‌ها، خطایی رخ دهد، خطایی با کد وضعیت ۴۰۰ برمی‌گردانیم.

بیایید بخش بریده‌شده از آن نقطه پایانی را بزرگنمایی کنیم:

        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 فیلتر کردیم، اما در این بخش، قصد داریم لیست کتاب‌ها را بر اساس آخرین تاریخ به‌روزرسانی مرتب کنیم (آخرین به‌روزرسانی اول می‌آید). و همچنین با تعریف یک محدودیت (تعداد عناصری که باید برگردانده شوند) و یک آفست (نقطه شروعی که از آن دسته بعدی کتاب‌ها برگردانده می‌شوند) نتیجه را صفحه‌بندی خواهیم کرد.

ما کوئری را اجرا می‌کنیم، تصویر لحظه‌ای داده‌ها را دریافت می‌کنیم و نتایج را در یک آرایه جاوا اسکریپت قرار می‌دهیم که در انتهای تابع بازگردانده می‌شود.

بیایید توضیحات این بخش پایانی را با نگاهی به یک تمرین خوب به پایان برسانیم: استفاده از سربرگ Link برای تعریف لینک‌های URI به صفحات اول، قبلی، بعدی یا آخر داده‌ها (در مورد ما، فقط previous و next را ارائه می‌دهیم).

        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 را در payload کتاب ارسال می‌کند، در حالی که دیگری آن را به عنوان پارامتر مسیر ارسال می‌کند. در هر صورت، هر دو تابع 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 تنظیم می‌کنیم). فیلدهای کتاب را از payload ارسال شده در بدنه درخواست بازیابی می‌کنیم. سپس جزئیات کتاب را در Firestore ذخیره می‌کنیم. در صورت موفقیت، 201 و در صورت شکست 400 برمی‌گردانیم.

هنگام بازگشت موفقیت‌آمیز، هدر مکان را نیز تنظیم می‌کنیم تا به کلاینت API نشان دهیم که منبع تازه ایجاد شده کجاست. هدر به شکل زیر خواهد بود:

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 Not Found را برمی‌گردانیم. داده‌های کتاب را بازیابی می‌کنیم و یک شیء 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} استفاده می‌کنیم که فیلدهای موجود را با مقادیر جدیدشان جایگزین می‌کند (در غیر این صورت، همه فیلدها حذف می‌شوند و فقط فیلدهای جدید در payload ذخیره می‌شوند و فیلدهای موجود از به‌روزرسانی قبلی یا ایجاد اولیه پاک می‌شوند).

ما همچنین هدر 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 اجرا می‌شود و به پورت ۸۰۸۰ گوش می‌دهد.

همچنین می‌توان با دستورات زیر یک کانتینر داکر ساخت و ایمیج کانتینر را نیز اجرا کرد:

$ docker build -t crud-web-api .

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

اجرا در داخل داکر همچنین راهی عالی برای اطمینان از عملکرد صحیح کانتینریزاسیون برنامه ما در حین ساخت آن در فضای ابری با استفاده از Cloud Build است.

تست API

صرف نظر از اینکه چگونه کد 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
  • بازیابی کتاب بر اساس شابک:
$ 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
  • فهرست کتاب‌ها (۱۰ کتاب اول) را بازیابی کنید:
$ 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 را برای اصلاح جستجوی خود ترکیب کنیم.

ساخت و استقرار API REST کانتینر شده

از آنجایی که خوشحالیم که 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

آخرین مرحله‌ای که در اینجا انجام خواهیم داد، بازیابی آدرس اینترنتی سرویس Cloud Run تازه مستقر شده، به لطف دستور زیر است:

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

در بخش بعدی به آدرس URL مربوط به Cloud Run REST API خود نیاز خواهیم داشت، زیرا کد frontend موتور برنامه ما با API تعامل خواهد داشت.

۹. یک برنامه وب برای مرور کتابخانه میزبانی کنید

آخرین قطعه پازل برای افزودن جذابیت به این پروژه، ارائه یک رابط کاربری وب است که با REST API ما تعامل داشته باشد. برای این منظور، از Google App Engine به همراه مقداری کد جاوا اسکریپت سمت کلاینت استفاده خواهیم کرد که API را از طریق درخواست‌های AJAX (با استفاده از Fetch API سمت کلاینت) فراخوانی می‌کند.

برنامه ما، اگرچه روی موتور زمان اجرای Node.JS App Engine مستقر شده است، اما بیشتر از منابع استاتیک تشکیل شده است! کد backend زیادی وجود ندارد، زیرا بیشتر تعامل کاربر در مرورگر از طریق جاوا اسکریپت سمت کلاینت انجام می‌شود. ما از هیچ چارچوب جاوا اسکریپت frontend پیچیده‌ای استفاده نخواهیم کرد، فقط از مقداری جاوا اسکریپت "معمولی" به همراه چند کامپوننت وب برای رابط کاربری با استفاده از کتابخانه کامپوننت وب Shoelace استفاده خواهیم کرد:

  • یک کادر انتخاب برای انتخاب زبان کتاب:

6fb9f741000a2dc1.png

  • یک کامپوننت کارت برای نمایش جزئیات مربوط به یک کتاب خاص (شامل بارکدی برای نمایش ISBN کتاب، با استفاده از کتابخانه JsBarcode ):

3aa21a9e16e3244e.png

  • و یک دکمه برای بارگذاری کتاب‌های بیشتر از پایگاه داده:

3925ad81c91bbac9.png

وقتی همه آن اجزای بصری را با هم ترکیب می‌کنیم، صفحه وب حاصل برای مرور کتابخانه ما به شکل زیر خواهد بود:

18a5117150977d6.png

فایل پیکربندی app.yaml

بیایید با نگاهی به فایل پیکربندی app.yaml ، به بررسی کد بیس این برنامه App Engine بپردازیم. این فایلی است که مختص App Engine است و امکان پیکربندی مواردی مانند متغیرهای محیطی، "handlerهای" مختلف برنامه یا مشخص کردن اینکه برخی منابع، دارایی‌های ثابت هستند و توسط 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 است و می‌خواهیم از نسخه ۱۴ آن استفاده کنیم.

سپس یک متغیر محیطی تعریف می‌کنیم که به آدرس اینترنتی سرویس Cloud Run ما اشاره می‌کند. باید متغیر CHANGE_ME را با آدرس اینترنتی صحیح به‌روزرسانی کنیم (در ادامه نحوه تغییر این مورد را خواهید دید).

پس از آن، ما هندلرهای مختلفی را تعریف می‌کنیم. سه هندلر اول به محل کد سمت کلاینت HTML، CSS و جاوا اسکریپت، در زیر پوشه public/ و زیرپوشه‌های آن اشاره می‌کنند. هندلر چهارم نشان می‌دهد که آدرس ریشه برنامه App Engine ما باید به صفحه index.html اشاره کند. به این ترتیب، هنگام دسترسی به ریشه وب‌سایت، پسوند index.html را در آدرس URL نخواهیم دید. و هندلر آخر، هندلر پیش‌فرض است که تمام آدرس‌های اینترنتی دیگر ( /.* ) را به برنامه Node.JS ما (یعنی بخش "پویا" ی برنامه، برخلاف دارایی‌های استاتیکی که توضیح دادیم) هدایت می‌کند.

بیایید اکنون URL API وب سرویس Cloud Run را به‌روزرسانی کنیم.

در دایرکتوری appengine-frontend/ ، دستور زیر را برای به‌روزرسانی متغیر محیطی که به URL مربوط به REST API مبتنی بر 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 آدرس اینترنتی Cloud RUN REST API ما را برمی‌گرداند. به این ترتیب، کد جاوا اسکریپت سمت کلاینت می‌داند برای دریافت لیست کتاب‌ها کجا را فراخوانی کند.

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 را اجرا می‌کنیم و به طور پیش‌فرض به پورت ۸۰۸۰ گوش می‌دهیم.

صفحه 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 (یک اسکریپت و یک stylesheet) را وارد می‌کنند.

خط بعدی کتابخانه JsBarcode را برای ایجاد بارکدهای کدهای ISBN کتاب وارد می‌کند.

خطوط آخر کد جاوا اسکریپت و استایل‌شیت 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 کافی است، تقریباً بررسی کد را تمام کرده‌ایم. یک بخش مهم دیگر باقی مانده است: کد جاوا اسکریپت سمت کلاینت app.js که با REST API ما تعامل دارد.

کد جاوا اسکریپت سمت کلاینت 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 = '';

ابتدا، به لطف کد گره App Engine که متغیر محیطی را که در ابتدا در app.yaml تنظیم کرده‌ایم، برمی‌گرداند، آدرس URL مربوط به REST API خود را دریافت می‌کنیم. به لطف متغیر محیطی، نقطه پایانی /webapi ، که از کد سمت کلاینت جاوا اسکریپت فراخوانی می‌شود، نیازی به کدنویسی ثابت URL API REST در کد فرانت‌اند خود نداشتیم.

ما همچنین یک متغیر 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 API REST، صفحه فعلی و انتخاب زبان را ارسال می‌کنیم.

بنابراین بیایید نگاهی به تابعی که کتاب‌ها را دریافت و اضافه می‌کند، بیندازیم:

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...] کلیک کنید.
  • یک زبان خاص را انتخاب کنید تا کتاب‌ها فقط به آن زبان نمایش داده شوند.
  • می‌توانید با علامت ضربدر کوچک در کادر انتخاب، انتخاب را پاک کنید تا به لیست تمام کتاب‌ها برگردید.

۱۰. تمیز کردن (اختیاری)

اگر قصد ندارید برنامه را نگه دارید، می‌توانید با حذف کل پروژه، منابع را پاکسازی کنید تا در هزینه‌ها صرفه‌جویی کنید و در کل یک شهروند ابری خوب باشید:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

۱۱. تبریک می‌گویم!

ما به لطف Cloud Functions، App Engine و Cloud Run مجموعه‌ای از سرویس‌ها را ایجاد کردیم تا نقاط پایانی و فرانت‌اند مختلف Web API را در دسترس قرار دهیم، کتابخانه‌ای از کتاب‌ها را ذخیره، به‌روزرسانی و مرور کنیم و در این مسیر از الگوهای طراحی خوبی برای توسعه REST API پیروی کنیم.

آنچه ما پوشش داده‌ایم

  • توابع ابری
  • فروشگاه ابری فایر استور
  • اجرای ابری
  • موتور برنامه

رفتن بیشتر

اگر می‌خواهید این مثال ملموس را بیشتر بررسی کنید و آن را گسترش دهید، در اینجا فهرستی از مواردی که ممکن است بخواهید بررسی کنید، آورده شده است:

  • از API Gateway برای ارائه یک نمای API مشترک به تابع وارد کردن داده و کانتینر REST API، برای افزودن ویژگی‌هایی مانند مدیریت کلیدهای API برای دسترسی به API یا تعریف محدودیت‌های نرخ برای مصرف‌کنندگان API، استفاده کنید.
  • ماژول گره Swagger-UI را در برنامه App Engine مستقر کنید تا یک محیط آزمایشی برای REST API مستندسازی و ارائه دهید.
  • در بخش فرانت‌اند، فراتر از قابلیت مرور موجود، صفحات اضافی برای ویرایش داده‌ها و ایجاد ورودی‌های جدید کتاب اضافه کنید. همچنین، از آنجایی که ما از پایگاه داده Cloud Firestore استفاده می‌کنیم، از ویژگی بلادرنگ آن برای به‌روزرسانی داده‌های کتاب نمایش داده شده همزمان با ایجاد تغییرات استفاده کنید.