סדנת ממשקי API ללא שרת (serverless)

1. סקירה כללית

המטרה של Codelab היא לצבור ניסיון עם "serverless" השירותים שמציעה Google Cloud Platform:

  • Cloud Functions – לפרוס יחידות קטנות של לוגיקה עסקית בצורת פונקציות, שמגיבות לאירועים שונים (הודעות Pub/Sub, קבצים חדשים ב-Cloud Storage, בקשות HTTP ועוד)
  • App Engine — לפריסה ולהצגה של אפליקציות אינטרנט, ממשקי API לאינטרנט, קצוות עורפיים לנייד, נכסים סטטיים, עם יכולות הגדלה והקטנה מהירה,
  • Cloud Run – לפריסה והתאמה לעומס של קונטיינרים, שיכולים להכיל כל שפה, זמן ריצה או ספרייה.

ולגלות איך לנצל את היתרונות של שירותים ללא שרת (serverless) כדי לפרוס ממשקי API של אתרים ו-REST ולהתאים אותם לעומס, ובמקביל ליישם כמה עקרונות טובים של עיצוב RESTful לאורך הדרך.

בסדנה הזאת ניצור סייר ספרים שמורכב מ:

  • פונקציה של Cloud Functions: כדי לייבא את מערך הנתונים הראשוני של הספרים שזמינים בספרייה שלנו, במסד הנתונים של המסמכים של Cloud Firestore,
  • קונטיינר של Cloud Run: שיחשוף API ל-REST מעל התוכן של מסד הנתונים שלנו,
  • חזית אינטרנט של App Engine: כדי לעיין ברשימת הספרים באמצעות קריאה ל-API ל-REST.

כך ייראה ממשק הקצה בסוף ה-Codelab הזה:

705e014da0ca5e90.png

מה תלמדו

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. הגדרה ודרישות

הגדרת סביבה בקצב עצמאי

  1. נכנסים למסוף Google Cloud ויוצרים פרויקט חדש או עושים שימוש חוזר בפרויקט קיים. אם אין לכם עדיין חשבון Gmail או חשבון Google Workspace, עליכם ליצור חשבון.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • Project name הוא השם המוצג של המשתתפים בפרויקט. זו מחרוזת תווים שלא משמשת את Google APIs. תמיד אפשר לעדכן.
  • Project ID הוא ייחודי בכל הפרויקטים ב-Google Cloud ואי אפשר לשנות אותו (אי אפשר לשנות אותו אחרי שמגדירים אותו). מסוף Cloud יוצר מחרוזת ייחודית באופן אוטומטי; בדרך כלל לא מעניין אותך מה זה. ברוב ה-codelabs תצטרכו להפנות למזהה הפרויקט שלכם (בדרך כלל מזוהה כ-PROJECT_ID). אם המזהה שנוצר לא מוצא חן בעיניכם, אתם יכולים ליצור מזהה אקראי אחר. לחלופין, אפשר לנסות שם משלך ולראות אם הוא זמין. לא ניתן לשנות אותו אחרי השלב הזה, והוא נשאר למשך הפרויקט.
  • לידיעתך, יש ערך שלישי, Project Number, שבו משתמשים בחלק מממשקי ה-API. מידע נוסף על כל שלושת הערכים האלה זמין במסמכי התיעוד.
  1. בשלב הבא צריך להפעיל את החיוב במסוף Cloud כדי להשתמש במשאבים או בממשקי API של Cloud. מעבר ב-Codelab הזה לא יעלה הרבה כסף, אם בכלל. כדי להשבית משאבים ולא לצבור חיובים מעבר למדריך הזה, אתם יכולים למחוק את המשאבים שיצרתם או למחוק את הפרויקט. משתמשים חדשים ב-Google Cloud זכאים להשתתף בתוכנית תקופת ניסיון בחינם בשווי 1,200 ש"ח.

הפעלת Cloud Shell

אומנם אפשר להפעיל את Google Cloud מרחוק מהמחשב הנייד, אבל ב-Codelab הזה משתמשים ב-Google Cloud Shell, סביבת שורת הפקודה שפועלת ב-Cloud.

במסוף Google Cloud, לוחצים על הסמל של Cloud Shell בסרגל הכלים שבפינה השמאלית העליונה:

84688a223b1c3a2.png

נדרשים רק כמה דקות כדי להקצות את הסביבה ולהתחבר אליה. בסיום התהליך, אתם אמורים לראות משהו כזה:

320e18ifiedb7fbe0.png

למכונה הווירטואלית הזו נטען כל כלי הפיתוח הדרושים. יש בה ספריית בית בנפח מתמיד של 5GB והיא פועלת ב-Google Cloud, מה שמשפר משמעותית את ביצועי הרשת והאימות. כל העבודה ב-Codelab הזה יכולה להתבצע בתוך דפדפן. אתה לא צריך להתקין שום דבר.

3. הכנת הסביבה והפעלת ממשקי 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}

בהמשך ב-Codelab הזה, כשנטמיע את ה-API ל-REST, נצטרך למיין את הנתונים ולסנן אותם. למטרה הזו ניצור שלושה אינדקסים:

$ 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 

3 האינדקסים האלה תואמים לחיפושים שנבצע לפי מחבר או שפה, תוך שמירה על הסדר באוסף באמצעות שדה מעודכן.

4. קבל את הקוד

מקבלים את הקוד ממאגר GitHub הבא:

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

קוד האפליקציה נכתב באמצעות Node.JS.

יהיה לכם את מבנה התיקיות הבא שרלוונטי לשיעור ה-Lab:

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 – הפונקציה הזו תציע נקודת קצה (endpoint) לייבוא נתונים לדוגמה.
  • run-crud – הקונטיינר הזה יחשוף את Web API כדי לגשת לנתוני הספרים שמאוחסנים ב-Cloud Firestore.
  • appengine-frontend — אפליקציית האינטרנט הזו של App Engine תציג חזית פשוטה לקריאה בלבד כדי לעיין ברשימת הספרים.

5. נתונים בספריית הספרים לדוגמה

בתיקיית הנתונים יש לנו קובץ books.json שמכיל רשימה של מאה ספרים, שכנראה כדאי לקרוא. מסמך ה-JSON הזה הוא מערך שמכיל אובייקטים של JSON. נבחן את צורת הנתונים שנטמיע באמצעות פונקציה של Cloud Functions:

[
  {
    "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. נקודת קצה (endpoint) של פונקציה לייבוא נתוני ספרים לדוגמה

בחלק הראשון הזה נטמיע את נקודת הקצה שתשמש לייבוא נתונים של ספרים לדוגמה. למטרה הזו נשתמש ב-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, כך שאין צורך להצהיר על כך כתלות.

ביחסי התלות של הפיתוח, אנחנו מצהירים על Functions Framework (@google-cloud/functions-framework), שהיא ה-framework של זמן הריצה להפעלת הפונקציות. זוהי 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 אסורות.
  • אנחנו מקבלים רק מטענים ייעודיים (payloads) של 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()
        });
    }

לאחר מכן נוכל לאחזר את המטען הייעודי (payload) של 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 כדי להודיע שהפעולה נכשלה. אחרת, נוכל להחזיר תגובה תקינה, עם קוד הסטטוס 202 שמציין שהבקשה לשמירה בכמות גדולה אושרה.

הרצה ובדיקה של פונקציית הייבוא

לפני הרצת הקוד, נתקין את יחסי התלות עם:

$ npm install

כדי להריץ את הפונקציה באופן מקומי, הודות ל-Functions Framework, נשתמש בפקודת הסקריפט start שהגדרנו ב-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:

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:

c910875d4dc0aaa8.png

בפלט הפריסה אמורה להופיע כתובת ה-URL של הפונקציה, לפי מוסכמה מסוימת למתן שמות (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}), וכמובן שניתן למצוא גם את כתובת ה-URL הזו של טריגר HTTP בממשק המשתמש של מסוף Cloud, בכרטיסיית הטריגר:

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"}

עכשיו, כשפונקציית הייבוא שלנו פרוסה ומוכנה, והעלינו את הנתונים לדוגמה, הגיע הזמן לפתח API ל-REST שחושף את מערך הנתונים הזה.

7. חוזה API בארכיטקטורת REST

אנחנו לא מגדירים חוזה API באמצעות מפרט Open API, אבל אנחנו בוחנים את נקודות הקצה השונות של API ל-REST.

ה-API מחליף אובייקטים של JSON של ספרים, שכוללים:

  • isbn (אופציונלי) — String באורך 13 תווים שמייצג קוד ISBN חוקי,
  • author – שדה String לא ריק שמייצג את שם מחבר הספר.
  • language — שדה String שאינו ריק, שמכיל את השפה שבה נכתב הספר,
  • pagesInteger חיובי למספר העמודים בספר,
  • title — שדה String שאינו ריק עם שם הספר.
  • year – ערך של Integer לשנת פרסום הספר.

מטען ייעודי (payload) של ספר לדוגמה:

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

GET /books

קבלת רשימה של כל הספרים, שניתן לסנן לפי מחבר ו/או שפה, בחלוקה לפי חלונות של 10 תוצאות בכל פעם.

המטען הייעודי (payload) בגוף: ללא.

הפרמטרים של השאילתה:

  • author (אופציונלי) — מסנן את רשימת הספרים לפי מחבר,
  • language (אופציונלי) — מסנן את רשימת הספרים לפי שפה,
  • page (אופציונלי, ברירת מחדל = 0) — מציין את הדירוג של דף התוצאות שיש להחזיר.

מחזירה: מערך JSON של אובייקטים מהספר.

קודי סטטוס:

  • 200 – כשהבקשה מצליחה לאחזר את רשימת הספרים,
  • 400 – אם מתרחשת שגיאה.

POST /books ו-POST /books/{isbn}

מפרסמים מטען ייעודי (payload) חדש של ספר, עם פרמטר של נתיב isbn (במקרה כזה אין צורך בקוד isbn במטען הייעודי (payload) של הספר) או בלי (במקרה כזה, הקוד isbn חייב להיכלל במטען הייעודי (payload) של הספר).

המטען הייעודי (payload) בגוף: אובייקט של ספר.

פרמטרים של שאילתה: אין.

החזרות: שום דבר.

קודי סטטוס:

  • 201 — כשהספר יאוחסן בהצלחה,
  • 406 — אם קוד isbn לא תקין,
  • 400 – אם מתרחשת שגיאה.

GET /books/{isbn}

מאחזר ספר מהספרייה, שמזוהה באמצעות קוד isbn שלו, ועובר כפרמטר נתיב.

המטען הייעודי (payload) בגוף: ללא.

פרמטרים של שאילתה: אין.

מחזירה: אובייקט JSON של ספר, או אובייקט שגיאה אם הספר לא קיים.

קודי סטטוס:

  • 200 – אם הספר נמצא במסד הנתונים,
  • 400 — אם מתרחשת שגיאה,
  • 404 — אם הספר לא נמצא,
  • 406 — אם קוד isbn לא חוקי.

PUT /books/{isbn}

מעדכן ספר קיים, שזוהה על ידי isbn שלו שמועבר כפרמטר נתיב.

המטען הייעודי (payload) בגוף: אובייקט של ספר. אפשר להעביר רק את השדות שצריך לעדכן. שאר השדות הם אופציונליים.

פרמטרים של שאילתה: אין.

מחזירה: הספר המעודכן.

קודי סטטוס:

  • 200 — כשהספר יעודכן בהצלחה,
  • 400 — אם מתרחשת שגיאה,
  • 406 — אם קוד isbn לא חוקי.

DELETE /books/{isbn}

מחיקת ספר קיים, שזוהה באמצעות ה-isbn שלו שמועבר כפרמטר נתיב.

המטען הייעודי (payload) בגוף: ללא.

פרמטרים של שאילתה: אין.

החזרות: שום דבר.

קודי סטטוס:

  • 204 – אם הספר יימחק בהצלחה,
  • 400 – אם מתרחשת שגיאה.

8. פריסה וחשיפה של API ל-REST בקונטיינר

לעיון בקוד

Dockerfile

נתחיל בבדיקה של Dockerfile, שיהיה אחראי ליצירת קונטיינרים של קוד האפליקציה שלנו:

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

אנחנו משתמשים בתמונה "slim" של 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.

היישום של ממשק ה-API לאינטרנט תלוי בגורמים הבאים:

  • את מודול ה-NPM של Firestore כדי לגשת לנתוני הספר במסד הנתונים,
  • הספרייה cors לטיפול בבקשות CORS (שיתוף משאבים בין מקורות), מכיוון שה-API ל-REST יופעל מקוד הלקוח של ממשק הקצה של אפליקציית האינטרנט של App Engine,
  • מסגרת Express, שתהיה מסגרת האינטרנט לעיצוב ה-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, בתור מסגרת האינטרנט, כדי להטמיע את ה-API ל-REST. אנחנו משתמשים במודול body-parser כדי לנתח את מטענים ייעודיים (payloads) של 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;
}

אנחנו נשתמש במודול ה-NPM isbn3 כדי לנתח ולאמת קודי 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, אבל בקטע הזה נמיין את רשימת הספרים לפי תאריך העדכון האחרון (העדכון האחרון קודם). בנוסף, נחלק את התוצאה לדפים, על ידי הגדרת מגבלה (מספר הרכיבים שיש להחזיר) וקיזוז (נקודת ההתחלה שממנה יוחזר קבוצת הספרים הבאה).

אנחנו מריצים את השאילתה, מקבלים את snapshot של הנתונים ומציבים את התוצאות האלה במערך 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 במטען הייעודי (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

בואו נאחזר ספר מ-Firestore, שמזוהה באמצעות מספר ה-ISBN שלו.

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} שמחליפה שדות קיימים בערכים החדשים שלהם (אחרת, כל השדות יוסרו ורק השדות החדשים במטען הייעודי (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 ויאזין ביציאה 8080 כברירת מחדל.

אפשר גם לפתח קונטיינר של Docker ולהריץ גם את קובץ האימג' של הקונטיינר, עם הפקודות הבאות:

$ docker build -t crud-web-api .

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

הרצה ב-Docker היא גם דרך מצוינת לוודא שהקונטיינרים של האפליקציה פועלים בצורה תקינה בזמן הפיתוח שלהם בענן באמצעות Cloud Build.

בדיקת ה-API

לא משנה איך אנחנו מריצים את קוד ה-API ל-REST (ישירות דרך Node או דרך קובץ אימג' של קונטיינר של Docker), עכשיו אפשר להריץ עליו כמה שאילתות.

  • יצירת ספר חדש (ISBN במטען הייעודי (payload) של הגוף):
$ 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 כדי לצמצם את החיפוש שלנו.

יצירה ופריסה של API ל-REST בקונטיינרים

אנחנו שמחים לדעת ש-API ל-REST פועל בהתאם לתוכנית, וזה הזמן הנכון לפרוס אותו בענן, ב-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 של ה-API ל-REST ב-Cloud Run, כי הקוד הקדמי של App Engine יפעל עם ה-API.

9. אירוח של אפליקציית אינטרנט לצורך דפדוף בספרייה

כדי להוסיף קצת נצנצים לפרויקט הזה, החלק האחרון בפאזל הוא לספק ממשק קצה של אינטרנט שיוכל לפעול עם ה-API ל-REST. לשם כך נשתמש ב-Google App Engine, עם קוד JavaScript כלשהו של הלקוח שיקרא ל-API באמצעות בקשות AJAX (באמצעות ממשק ה-API אחזור בצד הלקוח).

האפליקציה שלנו, על אף שהיא פרוסה בזמן הריצה של Node.JS App Engine, מורכבת בעיקר ממשאבים סטטיים! אין הרבה קוד בקצה העורפי, כי רוב האינטראקציה של המשתמש תתבצע בדפדפן באמצעות JavaScript בצד הלקוח. לא נשתמש ב-JavaScript framework מהודר של קצה קדמי, אלא נשתמש ב-JavaScript "vanilla" מסוים עם כמה רכיבי אינטרנט עבור ממשק המשתמש באמצעות ספריית רכיבי האינטרנט של Shoelace:

  • תיבה לבחירת השפה של הספר:

6fb9f741000a2dc1.png

  • רכיב כרטיס להצגת הפרטים על ספר מסוים (כולל ברקוד שמייצג את מספר ה-ISBN של הספר, באמצעות ספריית JsBarcode):

3aa21a9e16e3244e.png

  • ולחצן לטעינת ספרים נוספים ממסד הנתונים:

3925ad81c91bac9.png

כאשר משלבים את כל הרכיבים החזותיים האלה יחד, דף האינטרנט שמתקבל מהספרייה שלנו ייראה כך:

18a5117150977d6.png

קובץ התצורה app.yaml

נתחיל בבסיס הקוד של אפליקציית App Engine זו על ידי עיון בקובץ התצורה app.yaml. זהו קובץ שהוא ספציפי ל-App Engine ומאפשר להגדיר דברים כמו משתני סביבה, ה-handlers השונים של האפליקציה, או לציין שחלק מהמשאבים הם נכסים סטטיים שיוצגו על ידי ה-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. נצטרך לעדכן את ה-placeholder CHANGE_ME כך שיציין את כתובת ה-URL הנכונה (בהמשך מוסבר איך לשנות זאת).

לאחר מכן אנחנו מגדירים handlers שונים. 3 הקבצים הראשונים מפנים למיקום הקוד בצד הלקוח, HTML, CSS ו-JavaScript, מתחת לתיקייה public/ ולתיקיות המשנה שלה. הערך הרביעי מציין שכתובת ה-URL הבסיסית (root) של אפליקציית App Engine צריכה להפנות לדף index.html. כך לא נראה את הסיומת index.html בכתובת ה-URL כשניגשים לשורש של האתר. והאחרונה היא כתובת ברירת המחדל שתנתב את כל כתובות ה-URL האחרות (/.*) לאפליקציית Node.JS שלנו (כלומר, החלק "דינמי" של האפליקציה, בניגוד לנכסים הסטטיים שתיארנו).

נעדכן עכשיו את כתובת ה-URL של Web API של שירות Cloud Run.

בספרייה appengine-frontend/, מריצים את הפקודה הבאה כדי לעדכן את משתנה הסביבה שמצביע אל כתובת ה-URL של API ל-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

קובץ ה-Node.JS package.json הקובץ

{
    "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 ובמודול NPM isbn3 לאימות ספרים קודי ISBN.

ביחסי תלות של פיתוח, נשתמש במודול nodemon כדי לעקוב אחר שינויים בקבצים. אנחנו יכולים להריץ את האפליקציה שלנו באופן מקומי באמצעות npm start, אבל לבצע שינויים מסוימים בקוד, להפסיק את האפליקציה עם ^C ולאחר מכן להפעיל אותו מחדש, אבל זה קצת מעייף. במקום זאת, אנחנו יכולים להשתמש בפקודה הבאה כדי שהאפליקציה תיטען מחדש / תופעל מחדש באופן אוטומטי לאחר שינויים:

$ npm run dev

קוד Node.JS של index.js

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

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

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

אנחנו דורשים שימוש ב-framework של אתר Express. אנחנו מציינים שהספרייה הציבורית מכילה נכסים סטטיים שניתן להציג (לפחות בהפעלה מקומית במצב פיתוח) באמצעות תווכה static. לסיום, אנחנו דורשים מ-body-parser לנתח מטענים ייעודיים (payloads) של 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 של API ל-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, אנחנו משתמשים ברכיבי שרוך הנעליים עם תגים של רכיבים מותאמים אישית, כמו:

<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, שמקיים אינטראקציה עם ה-API ל-REST.

קוד JavaScript בצד הלקוח של app.js

אנחנו מתחילים ב-event listener ברמה העליונה שממתין לטעינה של תוכן ה-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 של API ל-REST, הודות לקוד הצומת של App Engine שמחזיר את משתנה הסביבה שהגדרנו בהתחלה ב-app.yaml. בזכות משתנה הסביבה, נקודת הקצה /webapi, שנקראה מהקוד בצד הלקוח של JavaScript, לא נאלצנו לקודד בתוך הקוד את כתובת ה-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);
    });

אנחנו מוסיפים רכיב handler של אירועים ללחצן כדי לטעון ספרים. לחיצה עליו תפעיל את הפונקציה 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 המדויקת שתשמש לקריאה ל-API ל-REST. בדרך כלל אנחנו יכולים לציין שלושה פרמטרים של שאילתות, אבל כאן בממשק המשתמש הזה מציינים רק שניים:

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

בסעיף שלמעלה של הפונקציה, לכל ספר שהוחזר על ידי API ל-REST, נשכפל את התבנית עם כמה רכיבי אינטרנט שמייצגים ספר, ואנחנו מאכלסים את המקומות בתבנית עם פרטי הספר.

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, יצרנו קבוצת שירותים שמטרתה לחשוף נקודות קצה של Web API וחזית של אתרים באינטרנט, לאחסן, לעדכן ולדפדף בספריית ספרים, בהתאם לתבניות עיצוב מומלצות לפיתוח API ל-REST לאורך הדרך.

אילו נושאים דיברנו?

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

פעולות נוספות

אם אתם רוצים לחקור לעומק את הדוגמה הממשית הזו ולהרחיב אותה, הנה רשימה של דברים שכדאי לחקור:

  • אפשר להשתמש ב-API Gateway כדי לספק חזית API משותפת לפונקציית ייבוא הנתונים ולקונטיינר של API ל-REST, כדי להוסיף תכונות כמו טיפול במפתחות API לגישה ל-API או להגדיר מגבלות קצב לצרכני API.
  • פורסים את מודול הצומת Swagger-UI באפליקציה של App Engine כדי לתעד ולהציע מגרש משחקים לבדיקה ל-API ל-REST.
  • בחזית, מעבר ליכולת הגלישה הקיימת, אפשר להוסיף מסכים נוספים כדי לערוך את הנתונים וליצור רשומות ספרים חדשות. בנוסף, מכיוון שאנחנו משתמשים במסד הנתונים של Cloud Firestore, ניתן להשתמש בתכונה זמן אמת שלו כדי לעדכן את נתוני הספר שמוצגים כשמתבצעים שינויים.