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 前端将如下所示:
学习内容
- Cloud Functions
- Cloud Firestore
- Cloud Run
- App Engine
2. 设置和要求
自定进度的环境设置
- 登录 Google Cloud 控制台,然后创建一个新项目或重复使用现有项目。如果您还没有 Gmail 或 Google Workspace 账号,则必须创建一个。
- 项目名称是此项目参与者的显示名称。它是 Google API 尚未使用的字符串。您可以随时对其进行更新。
- 项目 ID 在所有 Google Cloud 项目中是唯一的,并且是不可变的(一经设置便无法更改)。Cloud 控制台会自动生成一个唯一字符串;通常情况下,您无需关注该字符串。在大多数 Codelab 中,您都需要引用项目 ID(通常用
PROJECT_ID
标识)。如果您不喜欢生成的 ID,可以再随机生成一个 ID。或者,您也可以尝试自己的项目 ID,看看是否可用。完成此步骤后便无法更改该 ID,并且此 ID 在项目期间会一直保留。 - 此外,还有第三个值,即部分 API 使用的项目编号,供您参考。如需详细了解所有这三个值,请参阅文档。
- 接下来,您需要在 Cloud 控制台中启用结算功能,以便使用 Cloud 资源/API。运行此 Codelab 应该不会产生太多的费用(如果有的话)。若要关闭资源以避免产生超出本教程范围的结算费用,您可以删除自己创建的资源或删除项目。Google Cloud 新用户符合参与 300 美元免费试用计划的条件。
启动 Cloud Shell
虽然可以通过笔记本电脑对 Google Cloud 进行远程操作,但在此 Codelab 中,您将使用 Google Cloud Shell,这是一个在云端运行的命令行环境。
在 Google Cloud 控制台 中,点击右上角工具栏中的 Cloud Shell 图标:
预配和连接到环境应该只需要片刻时间。完成后,您应该会看到如下内容:
这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 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 批量操作,以批量存储所有图书。我们遍历 isbn
、title
、author
、language
、pages
和 year
字段,并遍历包含图书详情的 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 中:
在上面的屏幕截图中,我们可以在右侧看到已创建的 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 界面中,您应该会看到该函数:
在部署输出中,您应该能够看到遵循特定命名惯例 (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}
) 的函数网址。当然,您还可以在 Cloud Console 界面的“触发器”标签页中找到此 HTTP 触发器网址:
您也可以在命令行中使用 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);
});
}
在上一部分中,我们按 author
和 language
进行了过滤,但在此部分中,我们将按上次更新日期的顺序对图书列表进行排序(上次更新时间在前)。此外,我们还会通过定义限制(要返回的元素数量)和偏移量(返回下一批图书的起点)对结果进行分页。
执行查询,获取数据的快照,然后将查询结果放入一个要在函数结束时返回的 JavaScript 数组中。
下面我们来看一个很好的做法,以便完成对此端点的解释:使用 Link
标头定义指向数据的第一页、上一页、下一页或最后一页的 URI 链接(在本例中,我们仅提供上一页和下一页)。
var links = {};
if (page > 0) {
const prevQuery = querystring.stringify({...req.query, page: page - 1});
links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
}
if (snapshot.docs.length === PAGE_SIZE) {
const nextQuery = querystring.stringify({...req.query, page: page + 1});
links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
}
if (Object.keys(links).length > 0) {
res.links(links);
}
res.status(200).send(books);
这里的逻辑起初似乎有点复杂,但我们现在要做的就是添加上一个链接(如果不是在数据的第一页)。此外,如果数据页面已满(即,包含 PAGE_SIZE
常量所定义的最大图书数量,假设还有另一个图书提供更多数据),我们将添加一个 next 链接。然后,我们使用 Express 的 resource#links()
函数创建使用正确语法的正确标头。
链接标头大致如下所示:
link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
POST /books
和POST /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
我们还可以结合使用 author
、language
和 books
查询参数来优化搜索。
构建和部署容器化 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 服务现在是否显示在列表中:
在这里,我们要执行的最后一步是检索新部署的 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 组件:
- 用于选择图书语言的选择框:
- 用于显示特定图书详细信息的卡片组件(包括使用 JsBarcode 库表示图书 ISBN 的条形码):
- 以及一个可从数据库加载更多图书的按钮:
将这些可视组件组合在一起后,用于浏览库的生成网页将如下所示:
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 网址进行硬编码。
我们还定义了 page
和 language
变量,供我们用来跟踪分页和语言过滤。
const moreButton = document.getElementById('more-button');
moreButton.addEventListener('sl-focus', event => {
console.log('Button clicked');
moreButton.blur();
appendMoreBooks(server, page++, language);
});
我们在用于加载图书的按钮上添加了一个事件处理脚本。点击后,它会调用 appendMoreBooks()
函数。
const langSelect = document.getElementById('language-select');
langSelect.addEventListener('sl-change', event => {
page = 0;
language = event.srcElement.value;
document.getElementById('library').replaceChildren();
console.log(`Language selected: "${language}"`);
appendMoreBooks(server, page++, language);
});
与选择框类似,我们添加了一个事件处理脚本,以便在语言选择发生变化时收到通知。与按钮一样,我们还调用 appendMoreBooks()
函数,传递 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 数据库,因此当发生更改时,请利用其实时功能来更新显示的图书数据。