无服务器 Web API 研讨会

1. 概览

此 Codelab 的目标是获得使用“无服务器”的经验,Google Cloud Platform 提供的以下服务:

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

探索如何利用这些无服务器服务部署和扩缩 Web 和 REST API,同时了解一些良好的 RESTful 设计原则。

在此研讨会中,我们将创建一个书架探索器,其中包括:

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

在此 Codelab 结束时,Web 前端将如下所示:

705e014da0ca5e90

学习内容

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

2. 设置和要求

自定进度的环境设置

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

295004821bab6a87

37d264871000675d

96d86d3d5655cdbe.png

  • 项目名称是此项目参与者的显示名称。它是 Google API 尚未使用的字符串。您可以随时对其进行更新。
  • 项目 ID 在所有 Google Cloud 项目中是唯一的,并且是不可变的(一经设置便无法更改)。Cloud 控制台会自动生成一个唯一字符串;通常情况下,您无需关注该字符串。在大多数 Codelab 中,您都需要引用项目 ID(通常用 PROJECT_ID 标识)。如果您不喜欢生成的 ID,可以再随机生成一个 ID。或者,您也可以尝试自己的项目 ID,看看是否可用。完成此步骤后便无法更改该 ID,并且此 ID 在项目期间会一直保留。
  • 此外,还有第三个值,即部分 API 使用的项目编号,供您参考。如需详细了解所有这三个值,请参阅文档
  1. 接下来,您需要在 Cloud 控制台中启用结算功能,以便使用 Cloud 资源/API。运行此 Codelab 应该不会产生太多的费用(如果有的话)。若要关闭资源以避免产生超出本教程范围的结算费用,您可以删除自己创建的资源或删除项目。Google Cloud 新用户符合参与 300 美元免费试用计划的条件。

启动 Cloud Shell

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

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

84688aa223b1c3a2

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

320e18fedb7fbe0

这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 5 GB 主目录,并且在 Google Cloud 中运行,大大增强了网络性能和身份验证功能。您在此 Codelab 中的所有工作都可以在浏览器中完成。您无需安装任何程序。

3. 准备环境并启用 Cloud API

为了在整个项目中使用各种服务,我们将启用一些 API。为此,我们将在 Cloud Shell 中启动以下命令:

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

一段时间后,您应该会看到操作成功完成:

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

我们还将设置在此过程中需要的环境变量:将在其中部署函数、应用和容器的 Cloud 区域:

$ export REGION=europe-west3

由于我们将数据存储在 Cloud Firestore 数据库中,因此需要创建数据库:

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

在此 Codelab 后面的内容中,当实现 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 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": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

在运行时依赖项中,我们只需要 @google-cloud/firestore NPM 模块来访问数据库和存储图书数据。从本质上讲,Cloud Functions 运行时还提供 Express Web 框架,因此我们不需要将其声明为依赖项。

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

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

$ npm install

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

$ 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;
    }
    ... 
})

我们正在导出 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 批量操作,以批量存储所有图书。我们遍历 isbntitleauthorlanguagepagesyear 字段,并遍历包含图书详情的 JSON 数组。图书的 ISBN 代码将用作其主键或标识符。

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

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

现在,批量数据已准备就绪,我们可以提交操作了。如果存储操作失败,我们会返回 400 状态代码来表明操作失败。否则,我们可以返回 OK 响应,其中 202 状态代码表示批量保存请求已被接受。

运行和测试导入函数

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

$ npm install

为了在本地运行函数,借助于 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 控制台界面,检查数据是否确实存储在 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 Console 界面中,您应该会看到该函数:

c910875d4dc0aaa8.png

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

380ffc46eb56441e

您也可以在命令行中使用 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 协定来定义 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 代码

正文载荷:book 对象。

查询参数:无。

返回:无。

状态代码:

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

GET /books/{isbn}

从图书馆检索图书,由其 isbn 代码标识,并作为路径参数传递。

正文载荷:无。

查询参数:无。

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

状态代码:

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

PUT /books/{isbn}

更新一本通过作为路径参数传递的 isbn 标识的现有图书。

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

查询参数:无。

返回:更新后的图书。

状态代码:

  • 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" ]

我们使用的是 Node.JS 20 "slim" 映像。我们正在 /usr/src/app 目录中执行操作。我们正在复制定义依赖项等内容的 package.json 文件(详情如下)。我们使用 npm install 安装依赖项,并复制源代码。最后,我们使用 node index.js 命令指明应如何运行此应用。

package.json

接下来,我们可以查看 package.json 文件:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

我们指定想要使用 Node.JS 14,就像使用 Dockerfile 时一样。

我们的 Web API 应用依赖于以下产品:

  • Firestore NPM 模块,用于访问数据库中的图书数据;
  • 用于处理 CORS(跨域资源共享)请求的 cors 库,因为系统将从 App Engine Web 应用前端的客户端代码调用 REST API,
  • 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;
}

我们将使用 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);

这里的逻辑起初似乎有点复杂,但我们现在要做的就是添加上一个链接(如果不是在数据的第一页)。此外,如果数据页面已满(即,包含 PAGE_SIZE 常量所定义的最大图书数量,假设还有另一个图书提供更多数据),我们将添加一个 next 链接。然后,我们使用 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 Not Found 状态代码。我们检索图书数据,并创建表示要返回的图书的 JSON 对象。

  • PUT /books/:isbn

我们使用 PUT 方法来更新现有图书。

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

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

我们会更新 updated 日期/时间字段,以记住上次更新该记录的时间。我们使用 {merge:true} 策略,将现有字段替换为新值(否则,系统会移除所有字段,并且只会保存载荷中的新字段,从而清除上次更新或初始创建中的现有字段)。

我们还将 Location 标头设置为指向图书的 URI。

  • DELETE /books/:isbn

删除图书非常简单。我们只需对文档引用调用 delete() 方法即可。由于不会返回任何内容,因此会返回 204 状态代码。

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

启动 Express / Node 服务器

最后但同样重要的是,我们启动服务器,默认监听端口 8080

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

在本地运行应用

如需在本地运行应用,我们首先要使用以下命令安装依赖项:

$ npm install

然后,我们可以从以下内容开始:

$ npm start

默认情况下,服务器将在 localhost 上启动,并监听端口 8080。

您也可以使用以下命令构建 Docker 容器并运行容器映像:

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

在 Docker 中运行也是一个好方法,它让我们可以再次确认当我们使用 Cloud Build 在云端构建应用时,应用的容器化是否正常运行。

测试 API

无论我们如何运行 REST API 代码(直接通过 Node 或通过 Docker 容器映像运行),我们现在都可以对其运行一些查询。

  • 创建新图书(正文负载中包含 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 区域中。

我们可以在 Cloud 控制台界面中仔细检查我们的 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)')

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

9. 托管 Web 应用以浏览库

为此项目增加一些亮点是最后一块谜题,即提供一个与 REST API 进行交互的 Web 前端。为此,我们将使用 Google App Engine,以及一些将通过 AJAX 请求(使用客户端 Fetch API)调用 API 的客户端 JavaScript 代码。

我们的应用虽然部署在 Node.JS App Engine 运行时上,但主要由静态资源构成!后端代码不多,因为大部分用户互动都是通过客户端 JavaScript 在浏览器中进行的。我们不会使用任何花哨的前端 JavaScript 框架,而是使用一些“原版”JavaScript,以及一些使用 Shoelace Web 组件库用于界面的 Web 组件:

  • 用于选择图书语言的选择框:

6fb9f741000a2dc1

  • 用于显示特定图书详细信息的卡片组件(包括使用 JsBarcode 库表示图书 ISBN 的条形码):

3aa21a9e16e3244e

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

3925ad81c91bbac9

将这些可视组件组合在一起后,用于浏览库的生成网页将如下所示:

18a5117150977d6

app.yaml 配置文件

我们先来看看此 App Engine 应用的代码库,查看其 app.yaml 配置文件。这是特定于 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 个文件夹指向 public/ 文件夹及其子文件夹下的 HTML、CSS 和 JavaScript 客户端代码位置。第四个表示 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 Web 框架。我们指定公共目录包含可由 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,我们差不多完成了对代码的审核。最后一个重要部分:与 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 标头是一种提示,可告知我们是否还有更多图书需要加载(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 返回的每本图书,我们将使用一些表示图书的 Web 组件克隆模板,并使用图书的详细信息填充模板的槽位。

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 端点和 Web 前端,用于存储、更新和浏览图书库,并在此过程中遵循一些适用于 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 数据库,因此当发生更改时,请利用其实时功能来更新显示的图书数据。