无服务器 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.png

学习内容

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

2. 设置和要求

自定进度的环境设置

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

295004821bab6a87.png

37d264871000675d.png

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.png

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

320e18fedb7fbe0.png

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

3. 准备环境并启用 Cloud API

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

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

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

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

我们还将设置一个环境变量,该变量将在后续步骤中用到:我们将要在其中部署函数、应用和容器的云区域:

$ export REGION=europe-west3

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

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

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

$ 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 文件,其中包含 100 本可能值得一读的书籍的列表。此 JSON 文档是一个包含 JSON 对象的数组。我们来看看将通过 Cloud Functions 函数提取的数据的形状:

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

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

  • isbn - 用于标识图书的 ISBN-13 代码。
  • author - 图书作者的姓名。
  • language - 图书所用的口语。
  • pages - 图书的页数。
  • title - 图书的书名。
  • year - 图书的出版年份。

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

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

探索代码

我们先来看看 package.json 文件:

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

在运行时依赖项中,我们只需要 @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 批量操作,以批量存储所有图书。我们遍历包含图书详细信息的 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 状态代码,表示批量保存请求已被接受。

运行和测试导入功能

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

$ 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 控制台界面中,您应该会看到该函数:

c910875d4dc0aaa8.png

在部署输出中,您应该能够看到函数的网址,该网址遵循特定的命名惯例 (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}),当然,您还可以在 Cloud 控制台界面的“触发器”标签页中找到此 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(可选)- 表示有效 ISBN 代码的 13 字符 String
  • 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}

发布新的图书载荷,可以包含 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 “精简”映像。我们将在 /usr/src/app 目录中进行操作。我们正在复制 package.json 文件(详见下文),该文件定义了我们的依赖项等内容。我们使用 npm install 安装依赖项,并复制源代码。最后,我们使用 node index.js 命令指明了应如何运行此应用。

package.json

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

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

我们指定要使用 Node.JS 14,这与 Dockerfile 的情况相同。

我们的 Web API 应用依赖于:

  • 用于访问数据库中图书数据的 Firestore NPM 模块,
  • cors 库来处理 CORS(跨源资源共享)请求,因为我们的 REST API 将从 App Engine 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 作为 Web 框架来实现 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 常量定义的最大图书数量,假设还有另一页包含更多数据),我们会添加下一页链接。然后,我们使用 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

成功返回时,我们还会设置位置标头,以便向 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 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 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 交互的网络前端。为此,我们将使用 Google App Engine,并编写一些客户端 JavaScript 代码,通过 AJAX 请求(使用客户端 Fetch API)调用 API。

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

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

6fb9f741000a2dc1.png

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

3aa21a9e16e3244e.png

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

3925ad81c91bbac9.png

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

18a5117150977d6.png

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 框架以及用于验证图书 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 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 Web 组件库(一个脚本和一个样式表)。

下一行导入 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 模板及其 slot filling 功能来表示图书。我们将创建该模板的副本,以填充图书列表,并将插槽中的值替换为图书的详细信息:

    <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 中最初设置的环境变量。借助环境变量,我们无需在前端代码中硬编码 REST API 网址,即可从 JavaScript 客户端代码调用 /webapi 端点。

我们还定义了 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 组件的模板,并使用图书的详细信息填充模板的 slot。

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 数据库,因此可以利用其实时功能,在更改图书数据时更新显示的数据。