无服务器 Web API 研讨会

1. 概览

此 Codelab 的目标是获得 Google Cloud Platform 提供的“无服务器”服务的相关经验:

  • Cloud Functions - 以函数形式部署小型业务逻辑,以响应各种事件(Pub/Sub 消息、Cloud Storage 中的新文件、HTTP 请求等);
  • App Engine - 通过快速的伸缩功能部署和提供 Web 应用、Web API、移动后端、静态资源
  • Cloud Run - 用于部署和扩缩容器,其中可以包含任何语言、运行时或库。

探索如何利用这些无服务器服务部署和扩缩 Web API 以及 REST API,并在此过程中看到一些出色的 RESTful 设计原则。

在此研讨会中,我们将创建包含以下内容的书架浏览器:

  • Cloud Functions 函数:在 Cloud Firestore 文档数据库中导入我们图书馆中的图书的初始数据集。
  • 一个 Cloud Run 容器:该容器将在我们的数据库内容上公开 REST API。
  • App Engine 网络前端:通过调用 REST API 浏览图书列表。

此 Codelab 结束时的网络前端将如下所示:

b6964f26b9624565.png

学习内容

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

2. 设置和要求

自定进度的环境设置

  1. 登录 Cloud Console,然后创建一个新项目或重复使用现有项目。(如果您还没有 Gmail 或 Google Workspace 帐号,则必须创建一个。)

96a9c957bc475304.png

b9a10ebdf5b5a448.png

a1e3c01a38fa61c2.png

请记住项目 ID,它在所有 Google Cloud 项目中都是唯一的名称(上述名称已被占用,您无法使用,抱歉!)。它稍后将在此 Codelab 中被称为 PROJECT_ID

  1. 接下来,您需要在 Cloud Console 中启用结算功能,才能使用 Google Cloud 资源。

运行此 Codelab 应该不会产生太多的费用(如果有费用的话)。请务必按照“清理”部分中的说明操作,它会指导您关闭资源,以免产生超出本教程要求的结算费用。Google Cloud 的新用户有资格参与 $300 USD 免费试用计划。

启动 Cloud Shell

虽然可以通过笔记本电脑对 Google Cloud 进行远程操作,但在此 Codelab 中,您将使用 Google Cloud Shell,这是一个在云端运行的命令行环境。

在 GCP 控制台中,点击右上角工具栏上的 Cloud Shell 图标:

bce75f34b2c53987.png

预配和连接到环境应该只需要片刻时间。完成后,您应该会看到如下内容:

f6ef2b5f13479f3a.png

这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 5GB 主目录,并且在 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 --region=${REGION}

稍后在此 Codelab 中,在实现 REST API 时,我们需要对数据进行排序和过滤。为此,我们将创建三个索引:

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

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

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

这 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 Web 应用会显示一个简单的只读前端,用于浏览图书列表。

5. 图书图书馆数据示例

在数据文件夹中,我们有一个 books.json 文件,其中包含一百本书,可能值得一读。此 JSON 文档是包含 JSON 对象的数组。我们来看看通过 Cloud Functions 函数提取的数据的形状:

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

此数组中的所有图书条目都包含以下信息:

  • isbn - 用于标识图书的 ISBN-13 代码。
  • author - 图书作者的姓名。
  • language - 书写时使用的语言。
  • pages - 图书中的页数。
  • title - 图书名称。
  • year - 图书的出版年份。

6.导入示例图书数据的函数端点

在第一部分中,我们将实现将用于导入示例图书数据的端点。我们将使用 Cloud Functions 来实现这一目的。

浏览代码

首先,我们来看一下 package.json 文件:

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

在运行时依赖项中,我们只需使用 @google-cloud/firestore NPM 模块即可访问数据库并存储图书数据。在后台,Cloud Functions 运行时还提供 Express 网络框架,因此我们不需要将其声明为依赖项。

在开发依赖项中,我们声明了函数框架 (@google-cloud/functions-framework),即用于调用函数的运行时框架。它是一种开源框架,您还可以在本地计算机(在本例中为 Cloud Shell 内)运行函数,而无需在每次进行更改时进行部署,从而改善开发反馈循环。

如需安装依赖项,请使用 install 命令:

$ npm install

start 脚本使用 Functions 框架为您提供命令,可用于使用以下命令在本地运行函数:

$ 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 模块实例化,并指向图书集合(类似于关系型数据库中的表)。

exports.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 代码将用作其主键或标识符。

    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,表示已接受批量保存请求。

运行并测试 import 函数

在运行代码之前,我们将使用以下命令安装依赖项:

$ npm install

为了在本地运行该函数,得益于 Functions 框架,我们将使用在 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 Console 界面,检查数据是否确实存储在 Firestore 中:

d6a2b31bfa3443f2.png

在上面的屏幕截图中,我们可以看到所创建的 books 集合、由图书 ISBN 代码标识的图书文档列表,以及该特定图书条目右侧的详细信息。

在云端部署函数

为了在 Cloud Functions 中部署该函数,我们将在 function-import 目录中使用以下命令:

$ gcloud functions deploy bulk-import \
         --trigger-http \
         --runtime=nodejs12 \
         --allow-unauthenticated \
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

我们部署符号名称为 bulk-import 的函数。此函数通过 HTTP 请求触发。我们使用 Node.JS 12 运行时。我们会公开部署该函数(理想情况下,我们应该保护该端点)。我们指定函数所在的区域。我们指向本地目录中的源文件,并使用 parseBooks(导出的 JavaScript 函数)作为入口点。

几分钟后,函数便会部署到云端。在 Cloud Console 界面中,您应该会看到函数显示出来:

c3156d50ba917ddd.png

在部署输出中,您应该能看到遵循特定命名惯例 (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}) 的函数网址。当然,您也可以在 Cloud Console 界面的触发器标签页:

2d19539de3de98eb.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
  }

GET /books

获取所有图书的列表,这些图书可能会按作者和/或语言进行过滤,并一次按 10 个结果窗口进行分页。

正文载荷:无。

查询参数:

  • author -(选填)按作者过滤图书列表;
  • language -(可选)按语言过滤图书列表;
  • page(可选,默认值为 0)- 指示要返回的结果的排名。

返回:图书对象的 JSON 数组。

状态代码:

  • 200 - 当请求成功获取图书列表时,
  • 400 - 如果发生错误。

POST /books 和 POST /books/{isbn}

使用isbnpath 参数(在这种情况下,isbn (图书有效负载中不需要代码)或没有代码(在这种情况下,isbn代码必须存在于图书载荷中)

正文载荷:图书对象。

查询参数:无。

返回:无。

状态代码:

  • 201 - 成功存储图书后
  • 406 - 如果 isbn 代码无效,
  • 400 - 如果发生错误。

GET /books/{isbn}

从图书馆检索图书(通过其 isbn 代码识别)并作为路径参数传递。

正文载荷:无。

查询参数:无。

返回:图书 JSON 对象;如果图书不存在,则返回错误对象。

状态代码:

  • 200 - 如果在数据库中找到了图书,
  • 400 - 如果发生错误,
  • 404 - 如果找不到图书,
  • 406 - 如果 isbn 代码无效。

PUT /books/{isbn}

用于更新现有的图书,由其作为路径参数传递的 isbn 标识。

正文载荷:图书对象。只能传递需要更新的字段,其他字段可选填。

查询参数:无。

返回:更新后的图书。

状态代码:

  • 200 - 成功更新图书后,
  • 400 - 如果发生错误,
  • 406 - 如果 isbn 代码无效。

删除 /books/{isbn}

删除现有图书(由作为路径参数传递的 isbn 标识)。

正文载荷:无。

查询参数:无。

返回:无。

状态代码:

  • 204 - 成功删除图书时,
  • 400 - 如果发生错误。

8. 在容器中部署和公开 REST API

浏览代码

Dockerfile

我们首先看一下负责将应用代码容器化的 Dockerfile

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

我们使用 Node.JS 14 "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": ">= 14.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 Web 应用前端的客户端代码调用。
  • Express 框架是用来设计 API 的 Web 框架,
  • 以及 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;
}

我们将使用isbn3NPM 模块,用于解析和验证 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);
            });
        }

在上一部分中,我们按 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);

这个逻辑一开始看起来似乎有点复杂,但如果我们不在第一页的数据上,我们还是会添加上一个链接。 如果数据页已满(即包含 PAGE_SIZE 常量所定义的图书数上限,则假设还有另一页提供了更多数据),我们会添加 next 链接。然后,我们使用 Express 的 resource#links() 函数创建采用正确语法的正确标头。

为方便您查看信息,链接标头应如下所示:

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

这两个端点都用于创建新图书。其中一个在图书载荷中传递 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

成功返回时,我们还会设置位置标头,以便向新创建的资源所在的 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
  • 创建新图书(路径参数中的 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 上的 Cloud Run 上的合适时机!

我们将分两步来完成:

  • 首先,使用以下命令使用 Cloud Build 构建容器映像:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • 然后,使用第二条命令部署该服务:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

使用第一个命令,Cloud Build 构建容器映像并将其托管在 Container Registry 中。下一个命令会从注册表中部署容器映像,并将其部署到云端区域。

我们可以在 Cloud Console 界面中仔细检查我们的 Cloud Run 服务现在是否显示在列表中:

4ca13b0a703b2126.png

我们执行的最后一步是,借助以下命令,检索新部署的 Cloud Run 服务的网址:

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

在下一部分中,我们将需要 Cloud Run REST API 的网址,因为我们的 App Engine 前端代码将与该 API 进行交互。

9. 托管 Web 应用以浏览库

最后,为此项目增添一些亮点,就是提供将与我们的 REST API 交互的网络前端。为此,我们将使用 Google App Engine,以及一些会通过 AJAX 请求调用 API 的客户端 JavaScript 代码(使用客户端 Fetch API)。

虽然我们的应用部署在 Node.JS App Engine 运行时上,但它主要由静态资源构成!后端代码不多,因为大多数用户互动都是通过客户端 JavaScript 在浏览器中进行的。我们不会使用任何酷炫的前端 JavaScript 框架,而是仅使用一些“vanilla” JavaScript 以及适用于几个网页的组件,这些组件使用 Shoelace 网络组件库:

  • 通过选择框选择图书的语言:

1b7bf64bd327b1ee.png

  • 卡片组件,用于显示使用 ISBNsCode 库(如代表图书的 ISBN)的相应图书的详细信息:

4dd54e4d5ee53367.png

  • 以及一个用于从数据库中加载更多图书的按钮:

4766c796a9d87475.png

将所有所有这些视觉组件组合在一起后,用于浏览库的最终网页将如下所示:

fb6eae65811c8ac2.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 应用(即应用的“dynamic”部分,而静态部分则与之不同) 。

现在,我们来更新 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 框架以及用于验证图书的 ISBN 代码的 isbn3 NPM 模块。

在开发依赖项中,我们将使用 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 Web 应用并默认监听端口 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/ 子目录中。

在 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 代码后,我们就大功告成了。剩下的最后一部分是 app.js 客户端 JavaScript 代码,与我们的 REST API 交互。

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 标头是一个提示,告诉我们还有更多图书要加载(有是 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 Web 应用的界面

现在,您可以:

  • 点击 [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 数据库,因此可利用其实时功能来更新所显示的图书数据。