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

1. ภาพรวม

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

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

และเพื่อสำรวจวิธีใช้ประโยชน์จากบริการแบบ Serverless เหล่านี้เพื่อทำให้ Web และ REST API ใช้งานได้และปรับขนาด 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 ของระบบคลาวด์ การใช้งาน Codelab นี้จะไม่มีค่าใช้จ่ายใดๆ หากมี หากต้องการปิดทรัพยากรเพื่อหลีกเลี่ยงการเรียกเก็บเงินที่นอกเหนือจากบทแนะนำนี้ คุณสามารถลบทรัพยากรที่คุณสร้างหรือลบโปรเจ็กต์ได้ ผู้ใช้ Google Cloud ใหม่มีสิทธิ์เข้าร่วมโปรแกรมช่วงทดลองใช้ฟรี$300 USD

เริ่มต้น Cloud Shell

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

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

84688aa223b1c3a2.png

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

320e18fedb7fbe0.png

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

3. เตรียมสภาพแวดล้อมและเปิดใช้ Cloud API

เพื่อที่จะใช้บริการต่างๆ ที่เราต้องใช้ตลอดโครงการนี้ เราจะเปิดใช้ API 2-3 รายการ โดยการเรียกใช้คำสั่งต่อไปนี้ใน Cloud Shell

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

หลังจากนั้นสักครู่ คุณควรเห็นว่าการดำเนินการเสร็จสิ้นเรียบร้อยแล้ว

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

นอกจากนี้ เราจะตั้งค่าตัวแปรสภาพแวดล้อมที่จะต้องใช้ไปพร้อมๆ กันด้วย ซึ่งได้แก่ ภูมิภาคระบบคลาวด์ที่เราจะทำให้ฟังก์ชัน แอป และคอนเทนเนอร์ใช้งานได้

$ export REGION=europe-west3

เนื่องจากเราจะเก็บข้อมูลในฐานข้อมูล Cloud Firestore เราจึงจะต้องสร้างฐานข้อมูล ดังนี้

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

จากนั้นเมื่อนำ REST API มาใช้ใน Codelab นี้ เราจะต้องจัดเรียงและกรองข้อมูล ด้วยเหตุนี้ เราจะสร้างดัชนี 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 ที่มีรายชื่อหนังสือกว่าร้อยเล่มน่าจะควรค่าแก่การอ่าน เอกสาร JSON นี้เป็นอาร์เรย์ที่มีออบเจ็กต์ JSON มาดูรูปร่างข้อมูลที่เราจะนำเข้าผ่าน Cloud Function กัน

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

รายการหนังสือทั้งหมดในอาร์เรย์นี้ประกอบด้วยข้อมูลต่อไปนี้

  • isbn — รหัส ISBN-13 ที่ระบุหนังสือ
  • author — ชื่อผู้แต่งหนังสือ
  • language — ภาษาพูดที่ใช้เขียนหนังสือเล่มนี้
  • pages — จำนวนหน้าในหนังสือ
  • title — ชื่อหนังสือ
  • year — ปีที่ตีพิมพ์หนังสือ

6. ปลายทางของฟังก์ชันสำหรับนำเข้าข้อมูลหนังสือตัวอย่าง

ในส่วนแรกนี้ เราจะติดตั้งใช้งานปลายทางที่จะใช้นําเข้าข้อมูลหนังสือตัวอย่าง เราจะใช้ Cloud Functions เพื่อจุดประสงค์นี้

สำรวจโค้ด

เรามาเริ่มต้นด้วยการดูไฟล์ package.json ดังนี้

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

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

ในทรัพยากร Dependency ของการพัฒนา เราประกาศเฟรมเวิร์กฟังก์ชัน (@google-cloud/functions-framework) ซึ่งเป็นเฟรมเวิร์กรันไทม์ที่ใช้ในการเรียกใช้ฟังก์ชัน ซึ่งเป็นเฟรมเวิร์กโอเพนซอร์สที่คุณใช้ภายในเครื่องได้ (ในกรณีของเราคือภายใน Cloud Shell) เพื่อเรียกใช้ฟังก์ชันโดยไม่ต้องทำให้ใช้งานได้ทุกครั้งที่ทำการเปลี่ยนแปลง ซึ่งจะช่วยปรับปรุงลูปความคิดเห็นสำหรับการพัฒนา

หากต้องการติดตั้งทรัพยากร Dependency ให้ใช้คำสั่ง install

$ npm install

สคริปต์ start จะใช้เฟรมเวิร์กฟังก์ชันเพื่อมอบคำสั่งที่ใช้เพื่อเรียกใช้ฟังก์ชันในเครื่องตามคำสั่งต่อไปนี้

$ npm start

คุณสามารถใช้ Curl หรือตัวอย่างเว็บ Cloud Shell สำหรับคำขอ HTTP GET เพื่อโต้ตอบกับฟังก์ชัน

คราวนี้เรามาดูไฟล์ index.js ที่มีตรรกะของฟังก์ชันนำเข้าข้อมูลหนังสือ ดังนี้

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

เราจะสร้างอินสแตนซ์ของโมดูล Firestore และชี้ไปที่คอลเล็กชันหนังสือ (คล้ายกับตารางในฐานข้อมูลเชิงสัมพันธ์)

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

เรากำลังส่งออกฟังก์ชัน 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 เพื่อแจ้งว่าการดำเนินการนี้ล้มเหลว ไม่เช่นนั้นเราอาจแสดงผลการตอบกลับ "ตกลง" ซึ่งมีรหัสสถานะ 202 ระบุว่ายอมรับคำขอบันทึกจำนวนมากแล้ว

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

ก่อนเรียกใช้โค้ด เราจะติดตั้ง Dependencies ที่มี:

$ npm install

หากต้องการเรียกใช้ฟังก์ชันภายในเครื่อง การใช้เฟรมเวิร์กฟังก์ชันจะใช้คำสั่งสคริปต์ 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"}

นอกจากนี้ คุณยังไปที่ 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-3 นาที ระบบจะทำให้ฟังก์ชันใช้งานได้ในระบบคลาวด์ คุณควรเห็นฟังก์ชันใน 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 ที่ไม่ว่างเปล่าซึ่งมีภาษาที่ใช้เขียนหนังสือ
  • pagesInteger บวกสำหรับจำนวนหน้าของหนังสือ
  • titleString ที่ไม่ว่างเปล่าพร้อมชื่อหนังสือ
  • year — ค่า Integer สำหรับปีที่ตีพิมพ์หนังสือ

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

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

ดาวน์โหลด /หนังสือ

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

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

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

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

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

รหัสสถานะ:

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

โพสต์ /books และ POST /books/{isbn}

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

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

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

ส่งคืน: ไม่มี

รหัสสถานะ:

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

ดาวน์โหลด /books/{isbn}

เรียกดูหนังสือจากห้องสมุด ซึ่งระบุด้วยรหัส isbn ของหนังสือเล่มนั้นๆ และส่งผ่านเป็นพารามิเตอร์เส้นทาง

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

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

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

รหัสสถานะ:

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

PUT /หนังสือ/{isbn}

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

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

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

การส่งคืน: หนังสือที่อัปเดตแล้ว

รหัสสถานะ:

  • 200 — เมื่ออัปเดตหนังสือเรียบร้อยแล้ว
  • 400 — หากเกิดข้อผิดพลาด
  • 406 — หากรหัส isbn ไม่ถูกต้อง

ลบ /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" ]

เราใช้อิมเมจ "Slim" ของ Node.JS 20 เรากำลังดำเนินการในไดเรกทอรี /usr/src/app เรากำลังคัดลอกไฟล์ package.json (ตามรายละเอียดด้านล่าง) ที่กำหนดทรัพยากร Dependency ของเรา รวมถึงสิ่งอื่นๆ เราติดตั้งทรัพยากร Dependency ด้วย 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 (การแชร์ทรัพยากรข้ามโดเมน) เนื่องจาก REST API จะเรียกใช้จากโค้ดไคลเอ็นต์ของฟรอนท์เอนด์ของเว็บแอปพลิเคชัน App Engine
  • ซึ่งเป็นเฟรมเวิร์กเว็บสำหรับการออกแบบ API ของเรา
  • จากนั้นใช้โมดูล isbn3 ซึ่งจะช่วยตรวจสอบความถูกต้องของรหัส ISBN ของหนังสือ

นอกจากนี้ เรายังระบุสคริปต์ start ซึ่งจะเป็นประโยชน์ในการเริ่มต้นแอปพลิเคชันในเครื่องเพื่อการพัฒนาและทดสอบ

index.js

มาดูรายละเอียดของโค้ดกัน ด้วยการดูรายละเอียดที่ index.js

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

เราต้องการโมดูล Firestore และอ้างอิงคอลเล็กชัน books ซึ่งจัดเก็บข้อมูลหนังสือของเรา

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

const querystring = require('querystring');

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

เราใช้ Express ในฐานะเฟรมเวิร์กเว็บเพื่อใช้งาน REST API เราใช้โมดูล body-parser เพื่อแยกวิเคราะห์เพย์โหลด JSON ที่แลกเปลี่ยนกับ API ของเรา

โมดูล querystring มีประโยชน์ในการจัดการ URL เราจะสร้างส่วนหัว Link เพื่อใส่เลขหน้า (เราจะมีข้อมูลเพิ่มเติมในภายหลัง)

จากนั้นเราจะกำหนดค่าโมดูล cors เรากำหนดส่วนหัวที่ต้องการให้ส่งผ่าน CORS เนื่องจากส่วนใหญ่มักจะถูกตัดออก แต่ในที่นี้เราต้องการคงความยาวและประเภทเนื้อหาตามปกติ รวมถึงส่วนหัว Link ที่เราจะระบุสำหรับการใส่เลขหน้า

const ISBN = require('isbn3');

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

เราจะใช้โมดูล isbn3 NPM ในการแยกวิเคราะห์และตรวจสอบรหัส ISBN และเราพัฒนาฟังก์ชันยูทิลิตีขนาดเล็กที่จะแยกวิเคราะห์รหัส ISBN และตอบกลับด้วยรหัสสถานะ 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 ในเพย์โหลดหนังสือ ขณะที่อีกฉบับส่งผ่านเป็นพารามิเตอร์เส้นทาง ไม่ว่าจะใช้วิธีใด ให้เรียกใช้ฟังก์ชัน 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 / โหนด

สุดท้ายแต่ไม่ท้ายสุด เราเริ่มต้นเซิร์ฟเวอร์โดยรออ่านบนพอร์ต 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}`);
});

การเรียกใช้แอปพลิเคชันในเครื่อง

หากต้องการเรียกใช้แอปพลิเคชันในเครื่อง ก่อนอื่นเราจะติดตั้งทรัพยากร Dependency ด้วย

$ 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 (ผ่านโหนดหรือผ่านอิมเมจคอนเทนเนอร์ 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 บน 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 ของ Cloud Run REST API ในหัวข้อถัดไป เนื่องจากโค้ดฟรอนท์เอนด์ของ App Engine จะโต้ตอบกับ API ดังกล่าว

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

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

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

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

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 สุดท้ายคือ URL เริ่มต้นที่จะกำหนดเส้นทาง URL อื่นๆ ทั้งหมด (/.*) ไปยังแอปพลิเคชัน Node.JS (เช่น ส่วน "dynamic" ของแอปพลิเคชัน ซึ่งแตกต่างจากเนื้อหาแบบคงที่ที่เราได้อธิบายไว้)

มาอัปเดต URL ของ Web API ของบริการ Cloud Run กันเลย

ในไดเรกทอรี appengine-frontend/ ให้เรียกใช้คำสั่งต่อไปนี้เพื่ออัปเดตตัวแปรสภาพแวดล้อมซึ่งชี้ไปที่ URL ของ REST API บน Cloud Run-based REST API

$ 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 และโมดูล isbn3 NPM สำหรับการตรวจสอบความถูกต้องของหนังสือ รหัส ISBN

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

เราต้องการเฟรมเวิร์กเว็บ 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 แทน

ปลายทางที่ 2 ที่เรากำหนด /webapi จะแสดงผล URL ของ Cloud RUN REST API ด้วยวิธีนี้ โค้ด JavaScript ฝั่งไคลเอ็นต์จะทราบว่าจะเรียกรายชื่อหนังสือได้จากที่ใด

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

เราจึงใช้เว็บแอป Express และฟังอยู่บนพอร์ต 8080 โดยค่าเริ่มต้น

หน้า index.html

เราจะไม่ดูทุกบรรทัดของหน้า HTML แบบยาวนี้ เราจะเน้นบรรทัดหลักๆ แทน

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

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

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

สองบรรทัดแรกจะนำเข้าไลบรารีคอมโพเนนต์เว็บ Shoelace (สคริปต์และสไตล์ชีต)

บรรทัดถัดไปจะนำเข้าไลบรารี JsBarcode เพื่อสร้างบาร์โค้ดของรหัส ISBN ของหนังสือ

บรรทัดสุดท้ายกำลังนำเข้าโค้ด JavaScript และสไตล์ชีต CSS ของเราเอง ซึ่งอยู่ในไดเรกทอรีย่อย public/

ใน body ของหน้า HTML เราจะใช้คอมโพเนนต์ Shoelace กับแท็กองค์ประกอบที่กำหนดเอง เช่น

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

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

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

นอกจากนี้เรายังใช้เทมเพลต HTML และความสามารถในการเติมช่องเพื่อแสดงถึงหนังสือ เราจะสร้างสำเนาของเทมเพลตนั้นเพื่อเติมรายชื่อหนังสือและแทนที่ค่าในช่องด้วยรายละเอียดของหนังสือ:

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

มี HTML เพียงพอแล้ว เราตรวจทานโค้ดใกล้เสร็จแล้ว เหลืออีกส่วนสุดท้ายคือ โค้ด JavaScript ฝั่งไคลเอ็นต์ app.js โค้ดที่โต้ตอบกับ REST API ของเรา

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

เราเริ่มต้นด้วย 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 — สตริงภาษาที่จะกรองตามภาษาเขียน

จากนั้นเราจะใช้ 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();

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

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

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