1. סקירה כללית
המטרה של ה-Codelab הזה היא לצבור ניסיון בשירותים 'בלי שרת (serverless)' שמוצעים על ידי Google Cloud Platform:
- Cloud Functions – כדי לפרוס יחידות קטנות של לוגיקה עסקית בצורת פונקציות שמגיבות לאירועים שונים (הודעות Pub/Sub, קבצים חדשים ב-Cloud Storage, בקשות HTTP ועוד).
- App Engine – לפריסה ולהצגה של אפליקציות אינטרנט, ממשקי API לאינטרנט, נקודות קצה עורפי בנייד ונכסים סטטיים, עם יכולות מהירות של הרחבה והקטנה אנכית בהתאם לעומס.
- Cloud Run – לפריסה ולשינוי קנה מידה של קונטיינרים, שיכולים להכיל כל שפה, זמן ריצה או ספרייה.
בנוסף, תלמדו איך להשתמש בשירותים בלי שרת (serverless) האלה כדי לפרוס ולהרחיב ממשקי API של אינטרנט ו-REST, ותכירו כמה עקרונות טובים לעיצוב RESTful.
בסדנה הזו ניצור כלי לחיפוש במדף ספרים שכולל:
- פונקציית Cloud Function: לייבוא מערך הנתונים הראשוני של הספרים שזמינים בספרייה, במסד הנתונים של מסמכים ב-Cloud Firestore.
- קונטיינר של Cloud Run: שיחשוף API בארכיטקטורת REST מעל התוכן של מסד הנתונים שלנו,
- קצה קדמי לאינטרנט של App Engine: כדי לעיין ברשימת הספרים, באמצעות קריאה ל-API בארכיטקטורת REST שלנו.
כך ייראה ממשק הקצה של האתר בסוף ה-codelab הזה:

מה תלמדו
- Cloud Functions
- Cloud Firestore
- Cloud Run
- App Engine
2. הגדרה ודרישות
הגדרת סביבה בקצב אישי
- נכנסים ל-מסוף Google Cloud ויוצרים פרויקט חדש או משתמשים בפרויקט קיים. אם עדיין אין לכם חשבון Gmail או Google Workspace, אתם צריכים ליצור חשבון.



- שם הפרויקט הוא השם המוצג של הפרויקט הזה למשתתפים. זו מחרוזת תווים שלא נמצאת בשימוש ב-Google APIs. תמיד אפשר לעדכן את המיקום.
- מזהה הפרויקט הוא ייחודי לכל הפרויקטים ב-Google Cloud, והוא קבוע (אי אפשר לשנות אותו אחרי שהוא מוגדר). מסוף Cloud יוצר באופן אוטומטי מחרוזת ייחודית, ובדרך כלל לא צריך לדעת מה היא. ברוב ה-Codelabs, תצטרכו להפנות למזהה הפרויקט (בדרך כלל מסומן כ-
PROJECT_ID). אם אתם לא אוהבים את המזהה שנוצר, אתם יכולים ליצור מזהה אקראי אחר. אפשר גם לנסות כתובת משלכם ולבדוק אם היא זמינה. אי אפשר לשנות את הערך הזה אחרי השלב הזה, והוא יישאר כזה למשך הפרויקט. - לידיעתכם, יש ערך שלישי, מספר פרויקט, שחלק מממשקי ה-API משתמשים בו. במאמרי העזרה מפורט מידע נוסף על שלושת הערכים האלה.
- בשלב הבא, תצטרכו להפעיל את החיוב במסוף Cloud כדי להשתמש במשאבי Cloud או בממשקי API של Cloud. השלמת ה-codelab הזה לא תעלה לכם הרבה, אם בכלל. כדי להשבית את המשאבים ולמנוע חיובים נוספים אחרי שתסיימו את המדריך הזה, תוכלו למחוק את המשאבים שיצרתם או למחוק את הפרויקט. משתמשים חדשים ב-Google Cloud זכאים לתוכנית תקופת ניסיון בחינם בשווי 300$.
מפעילים את Cloud Shell
אפשר להפעיל את Google Cloud מרחוק מהמחשב הנייד, אבל ב-codelab הזה תשתמשו ב-Google Cloud Shell, סביבת שורת פקודה שפועלת בענן.
ב-מסוף Google Cloud, לוחצים על סמל Cloud Shell בסרגל הכלים שבפינה הימנית העליונה:

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

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

בצילום המסך שלמעלה אפשר לראות את האוסף 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, הפונקציה אמורה להופיע:

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

אפשר גם לאחזר את כתובת ה-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, אנחנו נבדוק את נקודות הקצה (endpoint) השונות של API בארכיטקטורת REST.
ה-API מחליף אובייקטים של ספרים בפורמט JSON, שכוללים:
-
isbn(אופציונלי) – קודStringISBN תקין באורך 13 תווים, -
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
}
GET /books
לקבל את רשימת כל הספרים, שאפשר לסנן לפי מחבר או שפה, ולחלק לדפים של 10 תוצאות בכל פעם.
מטען ייעודי (payload) של הגוף: אין.
פרמטרים של שאילתה:
-
author(אופציונלי) – סינון רשימת הספרים לפי מחבר, -
language(אופציונלי) – מסנן את רשימת הספרים לפי שפה, -
page(אופציונלי, ברירת מחדל = 0) – מציין את הדירוג של דף התוצאות שיוחזר.
הפונקציה מחזירה: מערך JSON של אובייקטים של ספרים.
קודי סטטוס:
-
200– כשהבקשה מצליחה לאחזר את רשימת הספרים, -
400— אם מתרחשת שגיאה.
POST /books ו-POST /books/{isbn}
שליחת מטען ייעודי (payload) של ספר חדש, עם isbn פרמטר נתיב (במקרה כזה לא צריך לציין את isbn הקוד במטען הייעודי של הספר) או בלי (במקרה כזה צריך לציין את isbn הקוד במטען הייעודי של הספר)
מטען ייעודי (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" ]
אנחנו משתמשים בתמונה 'רזה' של 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 כדי לנתח את נתוני ה-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 ותגיב עם קוד סטטוס 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);
יכול להיות שהלוגיקה כאן תיראה קצת מורכבת בהתחלה, אבל מה שאנחנו עושים זה להוסיף קישור previous אם אנחנו לא בדף הראשון של הנתונים. אם הדף מלא בנתונים (כלומר, מכיל את המספר המקסימלי של ספרים שמוגדר על ידי הקבוע 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} שמחליפה שדות קיימים בערכים החדשים שלהם (אחרת, כל השדות מוסרים ונשמרים רק השדות החדשים במטען הייעודי, וכך נמחקים שדות קיימים מהעדכון הקודם או מהיצירה הראשונית).
הגדרנו גם את הכותרת 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, ב-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 שהשירות Cloud Run שלנו מופיע עכשיו ברשימה:

השלב האחרון הוא לאחזר את כתובת ה-URL של שירות Cloud Run שזה עתה נפרס, באמצעות הפקודה הבאה:
$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
--region=${REGION} \
--platform=managed \
--format='value(status.url)')
בקטע הבא נצטרך את כתובת ה-URL של Cloud Run API בארכיטקטורת REST, כי קוד הקצה הקדמי של App Engine יקיים אינטראקציה עם ה-API.
9. אירוח אפליקציית אינטרנט כדי לגלוש בספרייה
החלק האחרון בפאזל שיוסיף קצת ניצוץ לפרויקט הזה הוא לספק קצה קדמי אינטרנט שיקיים אינטראקציה עם ה-API בארכיטקטורת REST שלנו. לשם כך, נשתמש ב-Google App Engine, עם קוד JavaScript של לקוח שיקרא ל-API באמצעות בקשות AJAX (באמצעות Fetch API בצד הלקוח).
האפליקציה שלנו, למרות שהיא פרוסה בסביבת זמן הריצה של Node.JS App Engine, מורכבת בעיקר ממשאבים סטטיים. אין הרבה קוד בקצה העורפי, כי רוב האינטראקציות של המשתמשים יתבצעו בדפדפן באמצעות JavaScript בצד הלקוח. לא נשתמש בשום מסגרת JavaScript מיוחדת לחלק הקדמי, אלא רק ב-JavaScript רגיל, עם כמה רכיבי אינטרנט לממשק המשתמש באמצעות ספריית רכיבי האינטרנט Shoelace:
- תיבת בחירה לבחירת שפת הספר:

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

- וכפתור לטעינת ספרים נוספים מהמסד הנתונים:

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

app.yaml קובץ התצורה
נתחיל לצלול אל בסיס הקוד של אפליקציית App Engine הזו, ונבחן את קובץ התצורה app.yaml שלה. זהו קובץ שספציפי ל-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, ושאנחנו רוצים להשתמש בגרסה 14.
לאחר מכן מגדירים משתנה סביבה שמצביע על כתובת ה-URL של שירות Cloud Run. צריך לעדכן את ה-placeholder 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 של 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
הקובץ 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 של Cloud RUN API בארכיטקטורת REST. כך קוד ה-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 בצד הלקוח שמתקשר עם ה-API בארכיטקטורת REST שלנו.app.js
קוד 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);
});
אנחנו מוסיפים גורם מטפל באירועים ללחצן כדי לטעון ספרים. כשלוחצים עליו, הוא קורא לפונקציה 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(), ומעבירים את כתובת ה-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...]. - בוחרים שפה מסוימת כדי לראות רק ספרים בשפה הזו.
- כדי לחזור לרשימה של כל הספרים, אפשר ללחוץ על סימן ה-X הקטן בתיבת הבחירה.
10. ניקוי (אופציונלי)
אם אתם לא מתכוונים להשאיר את האפליקציה, אתם יכולים למחוק את הפרויקט כולו כדי לחסוך בעלויות ולשמור על סביבת ענן נקייה:
gcloud projects delete ${GOOGLE_CLOUD_PROJECT}
11. מעולה!
יצרנו קבוצה של שירותים, באמצעות Cloud Functions, App Engine ו-Cloud Run, כדי לחשוף נקודות קצה שונות ל-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, אנחנו יכולים להשתמש בתכונה בזמן אמת כדי לעדכן את נתוני הספר שמוצגים כשהשינויים מתבצעים.