無伺服器 Web API 研討會

1. 總覽

本程式碼研究室的目標,在於獲得「無伺服器」的經驗。使用 Google Cloud Platform 提供的多種服務

  • Cloud Functions - 部署少數商業邏輯單元,以函式的形式來回應各種事件 (Pub/Sub 訊息、Cloud Storage 中的新檔案、HTTP 要求等)。
  • App Engine — 部署及提供網頁應用程式、網路 API、行動後端、靜態資產,同時提供快速向上擴充和縮減的功能。
  • Cloud Run:為容器 (可能包含任何語言、執行階段或程式庫) 部署及擴充容器。

並探索如何利用這些無伺服器服務部署及擴充 Web 和 REST API,並在過程中查看一些符合 REST 樣式的優質設計原則。

本研討會將建立書架探索工具,包括:

  • Cloud 函式:匯入程式庫 Cloud Firestore 文件資料庫中可用書籍的初始資料集。
  • Cloud Run 容器:這個容器會對資料庫內容公開 REST API
  • App Engine 網路前端:透過呼叫 REST API 來瀏覽書籍清單。

在本程式碼研究室的結尾處,網路前端會如下所示:

705e014da0ca5e90.png

課程內容

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

2. 設定和需求

自修環境設定

  1. 登入 Google Cloud 控制台,建立新專案或重複使用現有專案。如果您還沒有 Gmail 或 Google Workspace 帳戶,請先建立帳戶

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • 「專案名稱」是這項專案參與者的顯示名稱。這是 Google API 未使用的字元字串。您可以隨時更新付款方式。
  • 所有 Google Cloud 專案的專案 ID 均不得重複,而且設定後即無法變更。Cloud 控制台會自動產生一個不重複的字串。但通常是在乎它何在在大部分的程式碼研究室中,您必須參照專案 ID (通常為 PROJECT_ID)。如果您對產生的 ID 不滿意,可以隨機產生一個 ID。或者,您也可以自行嘗試,看看是否支援。在這個步驟後,這個名稱即無法變更,而且在專案期間內仍會保持有效。
  • 資訊中的第三個值是專案編號,部分 API 會使用這個編號。如要進一步瞭解這三個值,請參閱說明文件
  1. 接下來,您需要在 Cloud 控制台中啟用計費功能,才能使用 Cloud 資源/API。執行本程式碼研究室不會產生任何費用 (如果有的話)。如要關閉資源,以免產生本教學課程結束後產生的費用,您可以刪除自己建立的資源或刪除專案。新使用者符合 $300 美元免費試用計畫的資格。

啟動 Cloud Shell

雖然 Google Cloud 可以從筆記型電腦遠端操作,但在本程式碼研究室中,您將使用 Google Cloud Shell,這是一種在 Cloud 中執行的指令列環境。

Google Cloud 控制台,按一下右上方的工具列上的 Cloud Shell 圖示:

84688aa223b1c3a2.png

佈建並連線至環境的作業只需幾分鐘的時間。完成後,您應該會看到類似下方的內容:

320e18fedb7fbe0.png

這部虛擬機器都裝載了您需要的所有開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,大幅提高網路效能和驗證能力。本程式碼研究室的所有工作都可以在瀏覽器中完成。不必安裝任何程式。

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}

在本程式碼研究室的後續部分,實作 REST API 時,我們必須排序及篩選資料。為此,我們會建立三個索引:

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

這 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 函式擷取資料的形狀:

[
  {
    "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

您可以針對 HTTP GET 要求使用 curl 或 Cloud Shell 網頁預覽功能,以便與函式互動。

現在,我們來看看 index.js 檔案,其中包含書籍資料匯入函式的邏輯:

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

我們將 Firestore 模組執行個體化,並指向書籍集合 (類似關聯資料庫中的資料表)。

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

系統正在匯出 parseBooks JavaScript 函式,這是我們稍後部署時會宣告的函式。

接下來的幾個步驟會確認下列項目:

  • 我們只接受 HTTP POST 要求,否則會傳回 405 狀態碼,表示不允許其他 HTTP 方法。
  • 我們只接受 application/json 酬載,否則會傳送 406 狀態碼,表示這不是可接受的酬載格式。
    const books = req.body;

    const writeBatch = firestore.batch();

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

接下來,就可以透過要求的 body 擷取 JSON 酬載。我們正在準備 Firestore 批次作業,以便一次儲存所有書籍。我們會反覆處理包含書籍詳細資料的 JSON 陣列,並檢查 isbntitleauthorlanguagepagesyear 欄位。書籍的 ISBN 代碼會做為主鍵或 ID。

    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

為了在本機執行函式,有了 Functions Framework,我們要使用在 package.json 中定義的 start 指令碼指令:

$ 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 控制台 UI,檢查資料是否確實儲存在 Firestore 中:

409982568cebdbf8.png

在以上螢幕截圖中,我們可以看到已建立的 books 集合、使用書籍 ISBN 代碼識別的書籍文件清單,以及右側特定書籍項目的詳細資料。

在雲端部署函式

如要在 Cloud Functions 中部署函式,我們會在 function-import 目錄中使用下列指令:

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

我們部署函式的符號名稱為 bulk-import。這個函式是透過 HTTP 要求觸發。我們使用 Node.JS 20 執行階段。我們會公開部署函式 (最好,應保護該端點)。我們會指定這個函式所在的區域。然後,指向本機目錄中的來源,並使用 parseBooks (匯出的 JavaScript 函式) 做為進入點。

系統會在幾分鐘內將函式部署至雲端。在 Cloud 控制台 UI 中,您應該會看到這個函式:

c910875d4dc0aaa8.png

在部署作業輸出內容中,您應該會看到函式的網址,且遵循特定的命名慣例 (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME})。當然,您也可以在 Cloud 控制台 UI 的「Trigger」分頁中找到以下 HTTP 觸發條件網址:

380ffc46eb56441e.png

您也可以使用 gcloud 在指令列中擷取網址:

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

將程式碼儲存在 BULK_IMPORT_URL 環境變數中,方便我們重複使用,以測試部署的函式。

測試已部署的函式

使用先前用於測試本機執行的類似 curl 指令,我們會測試部署的函式。這項唯一變更將是以下網址:

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

同樣地,如果執行成功,應會傳回下列輸出內容:

{"status":"OK"}

現在匯入函式已部署完成並準備就緒,可以上傳範例資料,接著要開發 REST API 來公開這個資料集。

7. REST API 合約

雖然我們無法用 Open API 規格等方式定義 API 合約,但我們會帶您瞭解 REST API 的各個端點。

API 交換會預訂 JSON 物件,其中包含:

  • isbn (選用):13 個字元的 String,代表有效的 ISBN 代碼。
  • author — 為非空白的 String,代表書籍作者的名稱。
  • language — 非空白的 String,包含書籍的書寫語言。
  • pages:代表書籍頁數的正數 Integer
  • title — 包含書名且為非空白的 String
  • year:書籍出版年份的 Integer 值。

書籍酬載範例:

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

取得 /books

取得完整書籍清單 (可能按照作者和/或語言篩選),並一次按 10 個視窗顯示分頁。

內文酬載:無。

查詢參數:

  • author (選用):依作者篩選書籍清單。
  • language (選用):依語言篩選書籍清單。
  • page (選用,預設值 = 0) — 表示要傳回之結果網頁的排名。

傳回:書籍物件的 JSON 陣列。

狀態碼:

  • 200:當要求成功擷取書籍清單時,
  • 400:發生錯誤時。

POST /books 和 POST /books/{isbn}

發布新的書籍酬載,可使用 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 的情況一樣。

我們的網路 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 模組剖析與 API 交換的 JSON 酬載。

querystring 模組有助於操控網址。當我們建立用於分頁用途的 Link 標頭時 (稍後會進一步說明)。

然後設定 cors 模組。我們明確要求我們透過 CORS 傳遞的標頭,因為大部分通常會移除。但這裡,我們要保留一般的內容長度和類型,以及我們為分頁指定的 Link 標頭。

const ISBN = require('isbn3');

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

我們將使用 isbn3 NPM 模組剖析和驗證 ISBN 代碼,並開發一個小型公用程式函式,用於剖析 ISBN 代碼,如果 ISBN 代碼無效,我們會在回應中顯示 406 狀態碼。

  • GET /books

讓我們看看 GET /books 端點。

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

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

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

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

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

現可準備查詢,開始查詢資料庫。這項查詢將根據選用的查詢參數,依作者和/或語言進行篩選。我們也會分段傳回 10 本書籍的清單。

如果擷取書籍的過程中發生錯誤,系統會傳回 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);
            });
        }

在上一節中,我們是依 authorlanguage 進行篩選,但是在本節中,我們會按照上次更新日期的順序排列書籍清單 (上次更新時間先顯示)。此外,我們也會定義限制 (要傳回的元素數量) 和偏移 (傳回下一批書籍的起點),來對結果進行分頁。

我們會執行查詢、取得資料的快照,並將結果放在 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);

這裡的邏輯可能看似複雜,但如果我們不是資料第一頁,就需要新增上一個連結。如果資料頁面已滿,則會新增 next 連結 (亦即包含 PAGE_SIZE 常數中定義的書籍數量上限,假設有其他書籍產生更多資料)。然後,我們會使用 Express 的 resource#links() 函式,以正確的語法建立正確的標頭。

資訊連結標頭看起來會像這樣:

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

這兩個端點都在這裡用來建立新書籍。一組將 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

成功傳回時,我們也會設定 Location 標頭,以便為新建立的資源所在的 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 / 節點伺服器

最後,我們還會啟動伺服器,預設監聽通訊埠 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

無論我們如何直接透過 Node 或 Docker 容器映像檔執行 REST API 程式碼,我們現在都能對其執行幾項查詢。

  • 建立新書籍 (內文酬載中的 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
  • 建立新書籍 (path 參數中的 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

您也可以結合 authorlanguagebooks 查詢參數來修正搜尋範圍。

建構及部署容器化 REST API

很高興看到 REST API 會依計畫運作,現在是在 Cloud Run 部署該 API 的好時機!

我們分為兩個步驟:

  • 首先,使用 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 控制台 UI 中再次確認清單上出現 Cloud Run 服務:

f62fbca02a8127c0.png

這裡還有一個步驟,使用下列指令擷取新部署的 Cloud Run 服務網址:

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

App Engine 前端程式碼會與 API 互動,因此在下一節中會需要 Cloud Run REST API 的網址。

9. 託管網頁應用程式,即可瀏覽程式庫

想要在這個專案中添加亮點,最後一個步驟是提供將與 REST API 互動的網路前端。為此,我們會使用 Google App Engine,並搭配部分用戶端 JavaScript 程式碼,透過 AJAX 要求 (使用用戶端 Fetch API) 呼叫 API。

雖然部署在 Node.JS App Engine 執行階段上,但我們的應用程式主要都是使用靜態資源!後端程式碼並不完整,因為大多數使用者互動都是透過用戶端 JavaScript 在瀏覽器中進行。我們不會使用任何高級的前端 JavaScript 架構,只會使用一些「vanilla」JavaScript,然後透過 Shoelace 網頁元件程式庫,建立使用者介面的幾個 Web 元件:

  • 用選取方塊選取書籍語言:

6fb9f741000a2dc1.png

  • 卡片元件,用於顯示特定書籍的詳細資料 (包括使用 JsBarcode 程式庫的條碼代表書籍的 ISBN):

(3aa21a9e16e3244e.png)

  • 以及一個按鈕,用於從資料庫載入更多書籍:

3925ad81c91bbac9.png

結合所有視覺元件後,用來瀏覽程式庫的網頁將如下所示:

18a5117150977d6.png

app.yaml 設定檔

現在讓我們透過 app.yaml 設定檔,探索這個 App Engine 應用程式的程式碼集。這是 App Engine 專屬的檔案,可以設定環境變數、應用程式的各種處理常式,或是指定某些資源是靜態資產,這些資產會由 App Engine 的內建 CDN 提供。

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 版。

接著,我們會定義指向 Cloud Run 服務網址的環境變數。我們需要將 CHANGE_ME 預留位置更新為正確的網址 (請參閱下方說明,瞭解如何變更這項設定)。

之後,我們會定義各種處理常式。前 3 個指向的 HTML、CSS 和 JavaScript 用戶端程式碼位置位於 public/ 資料夾及其子資料夾底下。第四個表示 App Engine 應用程式的根網址應指向 index.html 頁面。如此一來,當存取網站根目錄時,我們就不會在網址中看到 index.html 字尾。最後一個則是預設值,會將所有其他網址 (/.*) 轉送至 Node.JS 應用程式 (相對於前述的靜態資產,也就是應用程式的「動態」部分)。

請立即更新 Cloud Run 服務的 Web API 網址。

appengine-frontend/ 目錄中執行下列指令,以更新指向 Cloud Run 型 REST API 網址的環境變數:

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

或者,使用正確的網址手動變更 app.yaml 中的 CHANGE_ME 字串:

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 代碼。

在開發依附元件中,我們將使用 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);
});

第一個相符的 / 會重新導向至 public/html 目錄中的 index.html。因為處於開發模式,我們並未在 App Engine 執行階段中執行,所以無法進行 App Engine 的網址轉送。因此,這裡只會將根網址重新導向至 HTML 檔案。

我們定義的第二個端點/webapi會傳回 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 代碼的條碼。

最後一行會匯入位於 public/ 子目錄的 JavaScript 程式碼和 CSS 樣式表。

在 HTML 網頁的 body 中,我們將 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 程式碼,我們很快就會完成程式碼審查。還剩下最後一步:與 REST API 互動的 app.js 用戶端 JavaScript 程式碼。

app.js 用戶端 JavaScript 程式碼

我們會先從等候 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 = '';

首先,我們會擷取 REST API 的網址,因為 App Engine 節點程式碼會傳回我們一開始在 app.yaml 中設定的環境變數。有了環境變數 (從 JavaScript 用戶端程式碼呼叫的 /webapi 端點),我們就不需要在前端程式碼中以硬式編碼的方式編寫 REST API 網址。

我們也定義 pagelanguage 變數,用來追蹤分頁和語言篩選作業。

    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() 函式,傳遞 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();
    ... 
}

在上方,我們打造了要用於呼叫 REST API 的確切網址。我們通常可以指定三個查詢參數,但在這個使用者介面中,我們只指定兩個:

  • page — 代表書籍分頁目前頁面的整數。
  • language:要根據書寫語言篩選的語言字串。

接著,我們會使用 Fetch API 來擷取包含書籍詳細資訊的 JSON 陣列。

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

視回應中是否包含 Link 標頭而定,我們決定顯示或隱藏 [More books...] 按鈕,因為 Link 標頭會提示 Google 是否還有載入更多書籍 (Link 標頭中會顯示 next 網址)。

    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

大約一分鐘後,應用程式應已部署完成。

應用程式的格式如下:https://${GOOGLE_CLOUD_PROJECT}.appspot.com

探索 App Engine 網頁應用程式的使用者介面

您現在可以:

  • 按一下 [More books...] 按鈕,即可載入更多書籍。
  • 選取特定語言,查看該語言的書籍。
  • 你可以按住選取方塊中的小十字圖示清除所選內容,返回所有書籍清單。

10. 清除 (選用)

如果不想保留應用程式,可以刪除整個專案來清理資源,以節省成本並成為良好的雲端公民:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. 恭喜!

我們運用 Cloud Functions、App Engine 和 Cloud Run,建立一套服務來公開各種 Web API 端點與網路前端,並依循一些 REST API 開發適用的良好設計模式來儲存、更新及瀏覽書籍程式庫。

涵蓋內容

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

進一步應用

如要進一步探討這個具體範例,並進一步加以延伸,不妨參考以下幾點:

  • 利用 API Gateway,為資料匯入函式和 REST API 容器提供通用 API 門版、新增處理 API 金鑰以存取 API 的功能,或是為 API 使用者定義頻率限制。
  • 在 App Engine 應用程式中部署 Swagger-UI 節點模組,以記錄並提供 REST API 的測試用遊樂場。
  • 在前端介面 (除了現有瀏覽功能以外,新增額外畫面以編輯資料),並建立新的書籍項目。另外,由於我們使用的是 Cloud Firestore 資料庫,因此請運用其即時功能,更新顯示變更的書籍資料。