เวิร์กช็อป Serverless Web API

1. ภาพรวม

เป้าหมายของ Codelab นี้คือการได้รับประสบการณ์การใช้งานบริการแบบ "Serverless" ที่ Google Cloud Platform มีให้บริการ

  • Cloud Functions - เพื่อทำให้ใช้งานได้หน่วยตรรกะทางธุรกิจขนาดเล็กในรูปแบบของฟังก์ชันที่ตอบสนองต่อเหตุการณ์ต่างๆ (ข้อความ Pub/Sub, ไฟล์ใหม่ใน Cloud Storage, คำขอ HTTP และอื่นๆ)
  • App Engine - เพื่อทำให้เว็บแอป, Web API, แบ็กเอนด์ของอุปกรณ์เคลื่อนที่, เนื้อหาสื่อแบบคงที่ใช้งานได้ และให้บริการ พร้อมความสามารถในการเพิ่มและลดทรัพยากรได้อย่างรวดเร็ว
  • Cloud Run - เพื่อทำให้ใช้งานได้และปรับขนาดคอนเทนเนอร์ซึ่งมีภาษา รันไทม์ หรือไลบรารีใดก็ได้

และเพื่อดูวิธีใช้ประโยชน์จากบริการแบบ Serverless เหล่านั้นในการทำให้ใช้งานได้และปรับขนาด Web และ REST API พร้อมทั้งดูหลักการออกแบบ RESTful ที่ดีไปพร้อมกัน

ในเวิร์กช็อปนี้ เราจะสร้างโปรแกรมสำรวจชั้นวางหนังสือซึ่งประกอบด้วย

  • Cloud Function: เพื่อนำเข้าชุดข้อมูลเริ่มต้นของหนังสือที่มีในคลังของเราในฐานข้อมูลเอกสาร Cloud Firestore
  • คอนเทนเนอร์ Cloud Run: ที่จะแสดง REST API ผ่านเนื้อหาของฐานข้อมูล
  • ฟรอนท์เอนด์ของเว็บ App Engine: เพื่อเรียกดูรายการหนังสือโดยการเรียก REST API ของเรา

ส่วนหน้าเว็บจะมีลักษณะดังนี้เมื่อสิ้นสุด Codelab นี้

705e014da0ca5e90.png

สิ่งที่คุณจะได้เรียนรู้

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

2. การตั้งค่าและข้อกำหนด

การตั้งค่าสภาพแวดล้อมแบบเรียนรู้ด้วยตนเอง

  1. ลงชื่อเข้าใช้ Google Cloud Console แล้วสร้างโปรเจ็กต์ใหม่หรือใช้โปรเจ็กต์ที่มีอยู่ซ้ำ หากยังไม่มีบัญชี Gmail หรือ Google Workspace คุณต้องสร้างบัญชี

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • ชื่อโปรเจ็กต์คือชื่อที่แสดงสำหรับผู้เข้าร่วมโปรเจ็กต์นี้ ซึ่งเป็นสตริงอักขระที่ Google APIs ไม่ได้ใช้ คุณอัปเดตได้ทุกเมื่อ
  • รหัสโปรเจ็กต์จะไม่ซ้ำกันในโปรเจ็กต์ Google Cloud ทั้งหมดและเปลี่ยนแปลงไม่ได้ (เปลี่ยนไม่ได้หลังจากตั้งค่าแล้ว) Cloud Console จะสร้างสตริงที่ไม่ซ้ำกันโดยอัตโนมัติ ซึ่งโดยปกติแล้วคุณไม่จำเป็นต้องสนใจว่าสตริงนั้นคืออะไร ใน Codelab ส่วนใหญ่ คุณจะต้องอ้างอิงรหัสโปรเจ็กต์ (โดยทั่วไปจะระบุเป็น PROJECT_ID) หากไม่ชอบรหัสที่สร้างขึ้น คุณอาจสร้างรหัสแบบสุ่มอีกรหัสหนึ่งได้ หรือคุณอาจลองใช้ชื่อของคุณเองและดูว่ามีชื่อนั้นหรือไม่ คุณจะเปลี่ยนแปลงรหัสนี้หลังจากขั้นตอนนี้ไม่ได้ และรหัสจะคงอยู่ตลอดระยะเวลาของโปรเจ็กต์
  • โปรดทราบว่ายังมีค่าที่ 3 ซึ่งคือหมายเลขโปรเจ็กต์ที่ API บางตัวใช้ ดูข้อมูลเพิ่มเติมเกี่ยวกับค่าทั้ง 3 นี้ได้ในเอกสารประกอบ
  1. จากนั้นคุณจะต้องเปิดใช้การเรียกเก็บเงินใน Cloud Console เพื่อใช้ทรัพยากร/API ของ Cloud การทำตาม Codelab นี้จะไม่มีค่าใช้จ่ายมากนัก หรืออาจไม่มีค่าใช้จ่ายเลย หากต้องการปิดทรัพยากรเพื่อหลีกเลี่ยงการเรียกเก็บเงินนอกเหนือจากบทแนะนำนี้ คุณสามารถลบทรัพยากรที่สร้างขึ้นหรือลบโปรเจ็กต์ได้ ผู้ใช้ Google Cloud รายใหม่มีสิทธิ์เข้าร่วมโปรแกรมช่วงทดลองใช้ฟรีมูลค่า$300 USD

เริ่มต้น Cloud Shell

แม้ว่าคุณจะใช้งาน Google Cloud จากระยะไกลจากแล็ปท็อปได้ แต่ใน Codelab นี้คุณจะใช้ Google Cloud Shell ซึ่งเป็นสภาพแวดล้อมบรรทัดคำสั่งที่ทำงานในระบบคลาวด์

จาก Google Cloud Console ให้คลิกไอคอน Cloud Shell ในแถบเครื่องมือด้านขวาบน

84688aa223b1c3a2.png

การจัดสรรและเชื่อมต่อกับสภาพแวดล้อมจะใช้เวลาเพียงไม่กี่นาที เมื่อเสร็จแล้ว คุณควรเห็นข้อความคล้ายกับตัวอย่างต่อไปนี้

320e18fedb7fbe0.png

เครื่องเสมือนนี้มาพร้อมเครื่องมือพัฒนาซอฟต์แวร์ทั้งหมดที่คุณต้องการ โดยมีไดเรกทอรีหลักแบบถาวรขนาด 5 GB และทำงานบน Google Cloud ซึ่งช่วยเพิ่มประสิทธิภาพเครือข่ายและการตรวจสอบสิทธิ์ได้อย่างมาก คุณสามารถทำงานทั้งหมดใน Codelab นี้ได้ภายในเบราว์เซอร์ คุณไม่จำเป็นต้องติดตั้งอะไร

3. เตรียมสภาพแวดล้อมและเปิดใช้ Cloud 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 นี้ เมื่อติดตั้งใช้งาน REST API เราจะต้องจัดเรียงและกรองข้อมูล ด้วยเหตุนี้ เราจึงจะสร้างดัชนี 3 รายการ ได้แก่

$ 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

คุณจะมีโครงสร้างโฟลเดอร์ต่อไปนี้ที่เกี่ยวข้องกับแล็บนี้

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 ซึ่งมีรายชื่อหนังสือ 100 เล่มที่น่าอ่าน เอกสาร 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"
    }
}

ในทรัพยากร Dependency ของรันไทม์ เราต้องการเพียงโมดูล @google-cloud/firestoreNPM เพื่อเข้าถึงฐานข้อมูลและจัดเก็บข้อมูลหนังสือ เบื้องหลังรันไทม์ของ Cloud Functions ยังมีเฟรมเวิร์กเว็บ Express ด้วย ดังนั้นเราจึงไม่จำเป็นต้องประกาศเป็นทรัพยากร Dependency

ในส่วนการขึ้นต่อกันในการพัฒนา เราจะประกาศ 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 และชี้ไปที่คอลเล็กชัน books (คล้ายกับตารางในฐานข้อมูลเชิงสัมพันธ์)

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 นี่คือฟังก์ชันที่เราจะประกาศเมื่อเราติดตั้งใช้งานในภายหลัง

คำสั่งถัดไป 2 รายการจะตรวจสอบว่า

  • เรายอมรับเฉพาะคำขอ HTTP POST และจะแสดงรหัสสถานะ 405 เพื่อระบุว่าไม่อนุญาตให้ใช้วิธี HTTP อื่นๆ
  • เรายอมรับเฉพาะเพย์โหลด application/json และจะส่งรหัสสถานะ 406 เพื่อระบุว่านี่ไม่ใช่รูปแบบเพย์โหลดที่ยอมรับได้
    const books = req.body;

    const writeBatch = firestore.batch();

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

จากนั้นเราจะดึงข้อมูลเพย์โหลด JSON ผ่าน body ของคำขอได้ เรากำลังเตรียมการดำเนินการแบบกลุ่มใน Firestore เพื่อจัดเก็บหนังสือทั้งหมดพร้อมกัน เราจะวนซ้ำในอาร์เรย์ JSON ที่ประกอบด้วยรายละเอียดหนังสือ โดยจะวนซ้ำในช่อง isbn, title, author, language, pages และ year รหัส ISBN ของหนังสือจะทำหน้าที่เป็นคีย์หลักหรือตัวระบุ

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

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

เมื่อข้อมูลส่วนใหญ่พร้อมแล้ว เราก็สามารถยืนยันการดำเนินการได้ หากการดำเนินการจัดเก็บข้อมูลไม่สำเร็จ เราจะแสดงรหัสสถานะ 400 เพื่อแจ้งให้ทราบว่าการดำเนินการไม่สำเร็จ ไม่เช่นนั้น เราจะส่งการตอบกลับ OK พร้อม202รหัสสถานะที่บ่งบอกว่าระบบยอมรับคำขอการบันทึกแบบกลุ่มแล้ว

การเรียกใช้และทดสอบฟังก์ชันการนำเข้า

ก่อนเรียกใช้โค้ด เราจะติดตั้งการอ้างอิงด้วยคำสั่งต่อไปนี้

$ npm install

หากต้องการเรียกใช้ฟังก์ชันในเครื่อง เราจะใช้คำสั่งสคริปต์ start ที่กำหนดไว้ใน package.json โดยใช้ Functions Framework

$ npm start

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

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

หากต้องการส่งคำขอ HTTP POST ไปยังฟังก์ชันในเครื่อง ให้เรียกใช้คำสั่งต่อไปนี้

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

เมื่อเปิดใช้คำสั่งนี้ คุณจะเห็นเอาต์พุตต่อไปนี้ ซึ่งยืนยันว่าฟังก์ชันทำงานในเครื่อง

{"status":"OK"}

นอกจากนี้ คุณยังไปที่ UI ของ 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 ที่ส่งออก) เป็นจุดแรกเข้า

หลังจากผ่านไปไม่เกิน 2 นาที ระบบจะติดตั้งใช้งานฟังก์ชันในระบบคลาวด์ ใน UI ของ Cloud Console คุณควรเห็นฟังก์ชันปรากฏขึ้น

c910875d4dc0aaa8.png

ในเอาต์พุตการทำให้ใช้งานได้ คุณควรจะเห็น URL ของฟังก์ชันซึ่งเป็นไปตามรูปแบบการตั้งชื่อที่แน่นอน (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}) และแน่นอนว่าคุณยังดู URL ทริกเกอร์ HTTP นี้ได้ใน UI ของ Cloud Console ในแท็บทริกเกอร์

380ffc46eb56441e.png

นอกจากนี้ คุณยังดึงข้อมูล URL ผ่านบรรทัดคำสั่งได้ด้วย gcloud

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

มาจัดเก็บไว้ในBULK_IMPORT_URLตัวแปรสภาพแวดล้อม เพื่อให้เรานำกลับมาใช้ใหม่ในการทดสอบฟังก์ชันที่ใช้งานจริงได้

การทดสอบฟังก์ชันที่ใช้งาน

เราจะทดสอบฟังก์ชันที่ใช้งานจริงด้วยคำสั่ง curl ที่คล้ายกันกับที่เราใช้ก่อนหน้านี้เพื่อทดสอบฟังก์ชันที่ทำงานในเครื่อง การเปลี่ยนแปลงเพียงอย่างเดียวคือ URL

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

หากสำเร็จ คำสั่งควรแสดงเอาต์พุตต่อไปนี้

{"status":"OK"}

ตอนนี้ฟังก์ชันการนำเข้าพร้อมใช้งานแล้ว และเราได้อัปโหลดข้อมูลตัวอย่างแล้ว จึงถึงเวลาที่เราจะพัฒนา REST API ที่แสดงชุดข้อมูลนี้

7. สัญญา REST API

แม้ว่าเราจะไม่ได้กำหนดสัญญา API โดยใช้ เช่น ข้อกำหนดของ Open API แต่เราจะมาดูที่ปลายทางต่างๆ ของ REST API

API จะแลกเปลี่ยนออบเจ็กต์ JSON ของหนังสือ ซึ่งประกอบด้วย

  • isbn (ไม่บังคับ) — String ที่มีอักขระ 13 ตัวซึ่งแสดงรหัส ISBN ที่ถูกต้อง
  • authorString ที่ไม่ว่างเปล่าซึ่งแสดงชื่อผู้แต่งหนังสือ
  • languageString ที่ไม่ว่างซึ่งมีภาษาที่ใช้เขียนหนังสือ
  • pages — ค่าบวก Integer สำหรับจำนวนหน้าของหนังสือ
  • titleString ที่ไม่ว่างเปล่าพร้อมชื่อหนังสือ
  • year — ค่า Integer สำหรับปีที่ตีพิมพ์หนังสือ

ตัวอย่างเพย์โหลดหนังสือ

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

GET /books

รับรายการหนังสือทั้งหมด ซึ่งอาจกรองตามผู้แต่งและ/หรือภาษา และแบ่งหน้าตามหน้าต่างที่มีผลลัพธ์ 10 รายการต่อครั้ง

เพย์โหลดของเนื้อหา: ไม่มี

พารามิเตอร์การค้นหา

  • author (ไม่บังคับ) - กรองรายการหนังสือตามผู้แต่ง
  • language (ไม่บังคับ) — กรองรายการหนังสือตามภาษา
  • page (ไม่บังคับ ค่าเริ่มต้น = 0) — ระบุอันดับของหน้าผลการค้นหาที่จะแสดง

แสดงผล: อาร์เรย์ JSON ของออบเจ็กต์หนังสือ

รหัสสถานะ:

  • 200 — เมื่อคำขอเรียกข้อมูลรายการหนังสือสำเร็จ
  • 400 — หากเกิดข้อผิดพลาด

POST /books และ POST /books/{isbn}

โพสต์เพย์โหลดหนังสือใหม่โดยมีisbnพารามิเตอร์เส้นทาง (ในกรณีนี้ไม่จำเป็นต้องมีรหัส isbn ในเพย์โหลดหนังสือ) หรือไม่มี (ในกรณีนี้ต้องมีรหัส isbn ในเพย์โหลดหนังสือ)

เพย์โหลดของเนื้อหา: ออบเจ็กต์หนังสือ

พารามิเตอร์การค้นหา: ไม่มี

การคืนสินค้า: ไม่มี

รหัสสถานะ:

  • 201 — เมื่อจัดเก็บหนังสือสำเร็จ
  • 406 - หากรหัส isbn ไม่ถูกต้อง
  • 400 — หากเกิดข้อผิดพลาด

GET /books/{isbn}

ดึงข้อมูลหนังสือจากห้องสมุดโดยใช้รหัส isbn ที่ส่งเป็นพารามิเตอร์เส้นทาง

เพย์โหลดของเนื้อหา: ไม่มี

พารามิเตอร์การค้นหา: ไม่มี

แสดงผล: ออบเจ็กต์ JSON ของหนังสือ หรือออบเจ็กต์ข้อผิดพลาดหากไม่มีหนังสือ

รหัสสถานะ:

  • 200 — หากพบหนังสือในฐานข้อมูล
  • 400 — หากเกิดข้อผิดพลาด
  • 404 — หากไม่พบหนังสือ
  • 406 — หากรหัส isbn ไม่ถูกต้อง

PUT /books/{isbn}

อัปเดตหนังสือที่มีอยู่ซึ่งระบุโดย isbn ที่ส่งผ่านเป็นพารามิเตอร์เส้นทาง

เพย์โหลดของเนื้อหา: ออบเจ็กต์หนังสือ คุณส่งได้เฉพาะช่องที่ต้องอัปเดต ส่วนช่องอื่นๆ จะส่งหรือไม่ก็ได้

พารามิเตอร์การค้นหา: ไม่มี

ผลลัพธ์: หนังสือที่อัปเดตแล้ว

รหัสสถานะ:

  • 200 — เมื่ออัปเดตหนังสือสำเร็จ
  • 400 — หากเกิดข้อผิดพลาด
  • 406 — หากรหัส isbn ไม่ถูกต้อง

DELETE /books/{isbn}

ลบหนังสือที่มีอยู่ซึ่งระบุโดย isbn ที่ส่งเป็นพารามิเตอร์เส้นทาง

เพย์โหลดของเนื้อหา: ไม่มี

พารามิเตอร์การค้นหา: ไม่มี

การคืนสินค้า: ไม่มี

รหัสสถานะ:

  • 204 — เมื่อลบหนังสือสำเร็จแล้ว
  • 400 — หากเกิดข้อผิดพลาด

8. ทำให้ใช้งานได้และเปิดเผย REST API ในคอนเทนเนอร์

สำรวจโค้ด

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 "slim" เรากำลังทำงานในไดเรกทอรี /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

แอปพลิเคชัน Web API ของเราขึ้นอยู่กับสิ่งต่อไปนี้

  • โมดูล Firestore NPM เพื่อเข้าถึงข้อมูลหนังสือในฐานข้อมูล
  • cors ไลบรารีเพื่อจัดการคำขอ CORS (กลไกการแชร์ทรัพยากรข้ามโดเมน) เนื่องจาก REST API ของเราจะเรียกใช้จากโค้ดฝั่งไคลเอ็นต์ของส่วนหน้าของเว็บแอปพลิเคชัน 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 เป็นเฟรมเวิร์กของเว็บเพื่อใช้ 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 และตอบกลับด้วยรหัสสถานะ 406 ในการตอบกลับ หากรหัส ISBN ไม่ถูกต้อง

  • GET /books

มาดูGET /booksอุปกรณ์ปลายทางทีละส่วนกัน

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

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

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

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

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

เรากำลังเตรียมพร้อมที่จะค้นหาฐานข้อมูลโดยการเตรียมคำค้นหา การค้นหานี้จะขึ้นอยู่กับพารามิเตอร์การค้นหาที่ไม่บังคับ เพื่อกรองตามผู้แต่งและ/หรือตามภาษา นอกจากนี้ เรายังส่งคืนรายการหนังสือเป็นกลุ่มๆ ละ 10 เล่มด้วย

หากเกิดข้อผิดพลาดขณะดึงข้อมูลหนังสือ เราจะแสดงข้อผิดพลาดพร้อมรหัสสถานะ 400

มาซูมเข้าไปที่ส่วนที่ตัดของปลายทางนั้นกัน

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

        const books = [];

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

ในส่วนก่อนหน้า เราได้กรองตาม author และ language แต่ในส่วนนี้ เราจะจัดเรียงรายการหนังสือตามลำดับวันที่อัปเดตล่าสุด (อัปเดตล่าสุดจะแสดงก่อน) นอกจากนี้ เราจะแบ่งหน้าผลลัพธ์ด้วยการกำหนดขีดจำกัด (จำนวนองค์ประกอบที่จะแสดง) และออฟเซ็ต (จุดเริ่มต้นที่จะแสดงหนังสือชุดถัดไป)

เราจะเรียกใช้การค้นหา รับสแนปชอตของข้อมูล และใส่ผลลัพธ์เหล่านั้นในอาร์เรย์ JavaScript ซึ่งจะแสดงผลเมื่อฟังก์ชันสิ้นสุด

มาดูแนวทางปฏิบัติที่ดีกันเพื่ออธิบายเกี่ยวกับปลายทางนี้ให้จบ นั่นคือการใช้ส่วนหัว Link เพื่อกำหนดลิงก์ URI ไปยังหน้าแรก ก่อนหน้า ถัดไป หรือหน้าสุดท้ายของข้อมูล (ในกรณีนี้ เราจะระบุเฉพาะหน้าก่อนหน้าและหน้าถัดไป)

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

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

ตรรกะอาจดูซับซ้อนเล็กน้อยในตอนแรก แต่สิ่งที่เราทำคือการเพิ่มลิงก์ก่อนหน้าหากเราไม่ได้อยู่ที่หน้าแรกของข้อมูล และเราจะเพิ่มลิงก์ถัดไปหากหน้าข้อมูลเต็ม (เช่น มีหนังสือตามจำนวนสูงสุดที่กำหนดโดยค่าคงที่ PAGE_SIZE โดยสมมติว่ามีหน้าอื่นที่มีข้อมูลเพิ่มเติม) จากนั้นเราจะใช้resource#links()ฟังก์ชันของ Express เพื่อสร้างส่วนหัวที่ถูกต้องด้วยไวยากรณ์ที่ถูกต้อง

โปรดทราบว่าส่วนหัวของลิงก์จะมีลักษณะดังนี้

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

ทั้ง 2 ปลายทางนี้มีไว้เพื่อสร้างหนังสือใหม่ โดยวิธีหนึ่งจะส่งรหัส ISBN ในเพย์โหลดของหนังสือ ส่วนอีกวิธีจะส่งเป็นพารามิเตอร์เส้นทาง ไม่ว่าจะใช้วิธีใด ทั้ง 2 วิธีจะเรียกใช้ฟังก์ชัน createBook() ของเรา

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

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

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

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

เราจะตรวจสอบว่ารหัส isbn ถูกต้องหรือไม่ หากไม่ถูกต้อง ฟังก์ชันจะส่งคืน (และตั้งรหัสสถานะ 406) เราจะดึงข้อมูลฟิลด์หนังสือจากเพย์โหลดที่ส่งในเนื้อหาของคำขอ จากนั้นเราจะจัดเก็บรายละเอียดหนังสือใน Firestore โดยจะแสดง 201 เมื่อสำเร็จ และ 400 เมื่อไม่สำเร็จ

เมื่อส่งคืนสำเร็จ เราจะตั้งค่าส่วนหัวของตำแหน่งด้วย เพื่อให้คำแนะนำแก่ไคลเอ็นต์ของ 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 ไม่พบกลับไป เราจะดึงข้อมูลหนังสือและสร้างออบเจ็กต์ 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

ไม่ว่าเราจะเรียกใช้โค้ด REST API อย่างไร (โดยตรงผ่าน Node หรือผ่านอิมเมจคอนเทนเนอร์ Docker) ตอนนี้เราก็เรียกใช้การค้นหา 2-3 รายการกับโค้ดนั้นได้แล้ว

  • สร้างหนังสือใหม่ (ISBN ในเพย์โหลดของเนื้อหา)
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • สร้างหนังสือใหม่ (ISBN ในพารามิเตอร์เส้นทาง)
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • ลบหนังสือ (ที่เราสร้างขึ้น)
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • ดึงข้อมูลหนังสือตาม ISBN
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • อัปเดตหนังสือที่มีอยู่โดยเปลี่ยนเฉพาะชื่อ
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • ดึงข้อมูลรายการหนังสือ (10 รายการแรก)
$ curl http://localhost:8080/books
  • วิธีค้นหาหนังสือที่เขียนโดยผู้แต่งที่ต้องการ
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • แสดงรายการหนังสือที่เขียนเป็นภาษาอังกฤษ
$ curl http://localhost:8080/books?language=English
  • โหลดหน้า 4 ของหนังสือ
$ curl http://localhost:8080/books?page=3

นอกจากนี้ เรายังรวมพารามิเตอร์การค้นหา author, language และ books เพื่อปรับแต่งการค้นหาได้ด้วย

การสร้างและติดตั้งใช้งาน REST API ที่อยู่ในคอนเทนเนอร์

เนื่องจากเรายินดีที่ REST API ทำงานได้ตามแผน จึงเป็นเวลาที่เหมาะสมที่จะติดตั้งใช้งานในระบบคลาวด์บน Cloud Run

โดยเราจะดำเนินการใน 2 ขั้นตอน ดังนี้

  • ก่อนอื่น ให้สร้างอิมเมจคอนเทนเนอร์ด้วย Cloud Build โดยใช้คำสั่งต่อไปนี้
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • จากนั้นให้ติดตั้งใช้งานบริการด้วยคำสั่งที่ 2 ดังนี้
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

คำสั่งแรกจะทำให้ Cloud Build สร้างอิมเมจคอนเทนเนอร์และโฮสต์ใน Container Registry คำสั่งถัดไปจะทําการติดตั้งใช้งานอิมเมจคอนเทนเนอร์จากรีจิสทรี และติดตั้งใช้งานในภูมิภาคของระบบคลาวด์

เราสามารถตรวจสอบอีกครั้งใน UI ของ Cloud Console ว่าตอนนี้บริการ Cloud Run ของเราปรากฏในรายการแล้ว

f62fbca02a8127c0.png

ขั้นตอนสุดท้ายที่เราจะทำที่นี่คือการดึง URL ของบริการ Cloud Run ที่เพิ่งติดตั้งใช้งานใหม่ด้วยคำสั่งต่อไปนี้

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

เราจะต้องใช้ URL ของ REST API ของ Cloud Run ในส่วนถัดไป เนื่องจากโค้ดส่วนหน้าของ App Engine จะโต้ตอบกับ API

9. โฮสต์เว็บแอปเพื่อเรียกดูคลัง

ส่วนสุดท้ายของปริศนาที่จะเพิ่มความน่าสนใจให้กับโปรเจ็กต์นี้คือการจัดหาฟรอนท์เอนด์ของเว็บที่จะโต้ตอบกับ REST API ของเรา ด้วยเหตุนี้ เราจะใช้ Google App Engine กับโค้ด JavaScript ของไคลเอ็นต์บางส่วนที่จะเรียก API ผ่านคำขอ AJAX (โดยใช้ API Fetch ฝั่งไคลเอ็นต์)

แม้ว่าแอปพลิเคชันของเราจะได้รับการติดตั้งใช้งานในรันไทม์ของ App Engine สำหรับ Node.JS แต่ส่วนใหญ่ก็สร้างขึ้นจากทรัพยากรแบบคงที่ ไม่มีโค้ดแบ็กเอนด์มากนัก เนื่องจากผู้ใช้ส่วนใหญ่จะโต้ตอบในเบราว์เซอร์ผ่าน JavaScript ฝั่งไคลเอ็นต์ เราจะไม่ใช้เฟรมเวิร์ก JavaScript ที่ซับซ้อนในส่วนหน้า แต่จะใช้ JavaScript "ธรรมดา" ร่วมกับ Web Components บางรายการสำหรับ UI โดยใช้ไลบรารี Web Components ของ Shoelace

  • กล่องเลือกเพื่อเลือกภาษาของหนังสือ

6fb9f741000a2dc1.png

  • คอมโพเนนต์การ์ดเพื่อแสดงรายละเอียดเกี่ยวกับหนังสือเล่มหนึ่งๆ (รวมถึงบาร์โค้ดที่แสดง ISBN ของหนังสือโดยใช้ไลบรารี JsBarcode)

3aa21a9e16e3244e.png

  • และปุ่มเพื่อโหลดหนังสือเพิ่มเติมจากฐานข้อมูล

3925ad81c91bbac9.png

เมื่อรวมคอมโพเนนต์ภาพทั้งหมดเข้าด้วยกัน เว็บเพจที่ได้สำหรับการเรียกดูคลังจะมีลักษณะดังนี้

18a5117150977d6.png

app.yaml ไฟล์การกำหนดค่า

มาเริ่มเจาะลึกฐานของโค้ดของแอปพลิเคชัน App Engine นี้กันโดยดูที่app.yamlไฟล์การกำหนดค่า ไฟล์นี้เป็นไฟล์เฉพาะของ App Engine และช่วยให้กำหนดค่าสิ่งต่างๆ ได้ เช่น ตัวแปรสภาพแวดล้อม "ตัวแฮนเดิล" ต่างๆ ของแอปพลิเคชัน หรือการระบุว่าทรัพยากรบางอย่างเป็นเนื้อหาสแตติกซึ่งจะแสดงโดย CDN ในตัวของ App Engine

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

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

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

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

เราระบุว่าแอปพลิเคชันของเราเป็นแอปพลิเคชัน Node.JS และเราต้องการใช้เวอร์ชัน 14

จากนั้นเราจะกำหนดตัวแปรสภาพแวดล้อมที่ชี้ไปยัง URL ของบริการ Cloud Run เราจะต้องอัปเดตตัวยึดตำแหน่ง CHANGE_ME ด้วย URL ที่ถูกต้อง (ดูวิธีเปลี่ยนได้ที่ด้านล่าง)

หลังจากนั้น เราจะกำหนดตัวแฮนเดิลต่างๆ 3 รายการแรกชี้ไปยังตำแหน่งโค้ดฝั่งไคลเอ็นต์ของ HTML, CSS และ JavaScript ภายใต้โฟลเดอร์ public/ และโฟลเดอร์ย่อย ส่วนรายการที่ 4 ระบุว่า URL รูทของแอปพลิเคชัน App Engine ควรชี้ไปที่หน้า index.html ด้วยวิธีนี้ เราจะไม่เห็นคำต่อท้าย index.html ใน URL เมื่อเข้าถึงรูทของเว็บไซต์ และสุดท้ายคือค่าเริ่มต้นที่จะกำหนดเส้นทาง URL อื่นๆ ทั้งหมด (/.*) ไปยังแอปพลิเคชัน Node.JS (เช่น ส่วน "ไดนามิก" ของแอปพลิเคชัน ซึ่งตรงกันข้ามกับชิ้นงานแบบคงที่เราได้อธิบายไว้)

มาอัปเดต URL ของ Web 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 ของหนังสือ

ในทรัพยากร Dependency ของการพัฒนา เราจะใช้โมดูล 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

มาดูเส้นทาง 2 เส้นทางที่เรากำหนดไว้กัน

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 แทน

ปลายทางที่ 2 ที่เรากำหนด /webapi จะแสดง URL ของ REST API ของ 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">

2 บรรทัดแรกจะนำเข้าไลบรารีคอมโพเนนต์เว็บของ 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 เราเกือบจะตรวจสอบโค้ดเสร็จแล้ว ส่วนสุดท้ายที่สำคัญคือapp.jsโค้ด JavaScript ฝั่งไคลเอ็นต์ที่โต้ตอบกับ REST API ของเรา

โค้ด JavaScript ฝั่งไคลเอ็นต์ app.js

เราเริ่มต้นด้วย 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 ของ REST API โดยใช้โค้ดโหนด App Engine ซึ่งจะแสดงตัวแปรสภาพแวดล้อมที่เราตั้งค่าไว้ตอนแรกใน app.yaml ตัวแปรสภาพแวดล้อมและปลายทาง /webapi ที่เรียกจากโค้ดฝั่งไคลเอ็นต์ JavaScript ทำให้เราไม่ต้องฮาร์ดโค้ด URL ของ REST API ในโค้ดส่วนหน้า

นอกจากนี้ เรายังกำหนดตัวแปร page และ language ซึ่งเราจะใช้เพื่อติดตามการแบ่งหน้าและการกรองภาษา

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

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

เราเพิ่มตัวแฮนเดิลเหตุการณ์ในปุ่มเพื่อโหลดหนังสือ เมื่อคลิก ระบบจะเรียกใช้ฟังก์ชัน appendMoreBooks()

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

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

เช่นเดียวกับช่องตัวเลือก เราจะเพิ่มตัวแฮนเดิลเหตุการณ์เพื่อให้ได้รับการแจ้งเตือนเมื่อมีการเปลี่ยนแปลงในการเลือกภาษา และเช่นเดียวกับปุ่ม เรายังเรียกใช้ฟังก์ชัน appendMoreBooks() โดยส่ง URL ของ REST API, หน้าปัจจุบัน และการเลือกภาษาด้วย

มาดูฟังก์ชันที่ดึงและต่อท้ายหนังสือกัน

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

ด้านบน เรากำลังสร้าง URL ที่แน่นอนเพื่อใช้เรียก REST API โดยปกติแล้วเราจะระบุพารามิเตอร์การค้นหาได้ 3 รายการ แต่ใน UI นี้ เราจะระบุเพียง 2 รายการ ดังนี้

  • 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';
    }

เราจะแสดงหรือซ่อนปุ่ม [More books...] ขึ้นอยู่กับว่ามีส่วนหัว Link ในการตอบกลับหรือไม่ เนื่องจากส่วนหัว 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();

เราใช้ไลบรารี JsBarcode เพื่อสร้างบาร์โค้ดที่ดูดีเหมือนกับที่อยู่บนปกหลังของหนังสือจริง เพื่อให้รหัส ISBN ดูดีขึ้นเล็กน้อย

เรียกใช้และทดสอบแอปพลิเคชันในเครื่อง

ตอนนี้โค้ดก็เพียงพอแล้ว ได้เวลาดูการทำงานของแอปพลิเคชัน ก่อนอื่น เราจะดำเนินการในเครื่องภายใน Cloud Shell ก่อนที่จะทำให้ใช้งานได้จริง

เราติดตั้งโมดูล NPM ที่แอปพลิเคชันของเราต้องการด้วยคำสั่งต่อไปนี้

$ npm install

และเราจะเรียกใช้แอปด้วยวิธีปกติอย่างใดอย่างหนึ่งต่อไปนี้

$ npm start

หรือใช้การโหลดการเปลี่ยนแปลงซ้ำโดยอัตโนมัติด้วย nodemon โดยมีสิ่งต่อไปนี้

$ npm run dev

แอปพลิเคชันทำงานในเครื่อง และเราเข้าถึงได้จากเบราว์เซอร์ที่ http://localhost:8080

การทําให้แอปพลิเคชัน App Engine ใช้งานได้

เมื่อมั่นใจว่าแอปพลิเคชันทำงานได้ดีในเครื่องแล้ว ก็ถึงเวลาที่จะทําให้ใช้งานได้ใน App Engine

หากต้องการทำให้แอปพลิเคชันใช้งานได้ ให้เรียกใช้คำสั่งต่อไปนี้

$ gcloud app deploy -q

หลังจากผ่านไปประมาณ 1 นาที ระบบควรจะติดตั้งใช้งานแอปพลิเคชัน

แอปพลิเคชันจะพร้อมใช้งานที่ URL ในรูปแบบ https://${GOOGLE_CLOUD_PROJECT}.appspot.com

การสำรวจ UI ของเว็บแอปพลิเคชัน App Engine

ตอนนี้คุณทำสิ่งต่อไปนี้ได้แล้ว

  • คลิกปุ่ม [More books...] เพื่อโหลดหนังสือเพิ่มเติม
  • เลือกภาษาที่ต้องการเพื่อดูเฉพาะหนังสือในภาษานั้น
  • คุณล้างการเลือกได้โดยใช้กากบาทเล็กๆ ในช่องเลือก เพื่อกลับไปที่รายการหนังสือทั้งหมด

10. ล้างข้อมูล (ไม่บังคับ)

หากไม่ต้องการเก็บแอปไว้ คุณสามารถล้างข้อมูลทรัพยากรเพื่อประหยัดค่าใช้จ่ายและเป็นพลเมืองที่ดีของระบบคลาวด์โดยรวมได้โดยการลบทั้งโปรเจ็กต์ ดังนี้

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. ยินดีด้วย

เราได้สร้างชุดบริการขึ้นมาโดยใช้ Cloud Functions, App Engine และ Cloud Run เพื่อแสดงปลายทางของ Web API และส่วนหน้าของเว็บต่างๆ เพื่อจัดเก็บ อัปเดต และเรียกดูคลังหนังสือ โดยทำตามรูปแบบการออกแบบที่ดีสำหรับการพัฒนา REST API ไปพร้อมกัน

สิ่งที่เราได้พูดถึง

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

เจาะลึกยิ่งขึ้น

หากต้องการดูตัวอย่างที่เป็นรูปธรรมนี้เพิ่มเติมและขยายตัวอย่างดังกล่าว ต่อไปนี้คือรายการสิ่งที่คุณอาจต้องการตรวจสอบ

  • ใช้ประโยชน์จาก API Gateway เพื่อจัดเตรียมส่วนหน้า API ทั่วไปให้กับฟังก์ชันการนำเข้าข้อมูลและคอนเทนเนอร์ REST API เพื่อเพิ่มฟีเจอร์ต่างๆ เช่น การจัดการคีย์ API เพื่อเข้าถึง API หรือกำหนดขีดจำกัดอัตราสำหรับผู้ใช้ API
  • ทำให้โมดูลโหนด Swagger-UI ใช้งานได้ในแอปพลิเคชัน App Engine เพื่อจัดทำเอกสารและเสนอสนามทดสอบสำหรับ REST API
  • ในส่วนฟรอนท์เอนด์ นอกเหนือจากความสามารถในการเรียกดูที่มีอยู่แล้ว ให้เพิ่มหน้าจอพิเศษเพื่อแก้ไขข้อมูลและสร้างรายการหนังสือใหม่ นอกจากนี้ เนื่องจากเราใช้ฐานข้อมูล Cloud Firestore ให้ใช้ประโยชน์จากฟีเจอร์เรียลไทม์เพื่ออัปเดตข้อมูลหนังสือที่แสดงเมื่อมีการเปลี่ยนแปลง