Pic-a-daily:实验 4 - 创建 Web 前端

1. 概览

在此 Codelab 中,您将在 Google App Engine 上创建一个 Web 前端,让用户能够从 Web 应用上传图片,并浏览上传的图片及其缩略图。

21741cd63b425aeb.png

此 Web 应用将使用名为 Bulma 的 CSS 框架来打造美观的界面,还会使用 Vue.JS JavaScript 前端框架来调用您将构建的应用 API。

此应用将包含三个标签页:

  • 一个首页,其中将显示所有已上传图片的缩略图,以及描述图片的标签列表(之前实验中由 Cloud Vision API 检测到的标签)。
  • 一个拼贴页面,其中将显示由最近上传的 4 张照片制作的拼贴。
  • 一个上传页面,用户可以在其中上传新图片。

生成的前端如下所示:

6a4d5e5603ba4b73.png

这 3 个页面都是简单的 HTML 页面:

  • 首页 (index.html) 通过对 /api/pictures 网址的 AJAX 调用,调用 Node App Engine 后端代码来获取缩略图及其标签的列表。首页使用 Vue.js 来提取这些数据。
  • 拼图页面 (collage.html) 指向由 4 张最新照片组成的 collage.png 图片。
  • 上传页面 (upload.html) 提供了一个简单的表单,用于通过向 /api/pictures 网址发送 POST 请求来上传图片。

学习内容

  • App Engine
  • Cloud Storage
  • Cloud Firestore

2. 设置和要求

自定进度的环境设置

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

b35bf95b8bf3d5d8.png

a99b7ace416376c4.png

bd84a6d3004737c5.png

  • 项目名称是此项目参与者的显示名称。它是 Google API 尚未使用的字符串,您可以随时对其进行更新。
  • 项目 ID 在所有 Google Cloud 项目中必须是唯一的,并且不可变(一经设置便无法更改)。Cloud Console 会自动生成一个唯一字符串;通常情况下,您无需关注该字符串。在大多数 Codelab 中,您都需要引用项目 ID(它通常标识为 PROJECT_ID),因此如果您不喜欢某个 ID,请再生成一个随机 ID,还可以尝试自己创建一个,并确认是否可用。然后,项目创建后,ID 会处于“冻结”状态。
  • 第三个值是一些 API 使用的项目编号。如需详细了解所有这三个值,请参阅文档
  1. 接下来,您需要在 Cloud Console 中启用结算功能,才能使用 Cloud 资源/API。运行此 Codelab 应该不会产生太多的费用(如果有费用的话)。要关闭资源以避免产生超出本教程范围的费用,请按照此 Codelab 末尾提供的任何“清理”说明操作。Google Cloud 的新用户符合参与 $300 USD 免费试用计划的条件。

启动 Cloud Shell

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

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

55efc1aaa7a4d3ad.png

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

7ffe5cbb04455448.png

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

3. 启用 API

App Engine 需要 Compute Engine API。确保已启用该功能:

gcloud services enable compute.googleapis.com

您应该会看到操作成功完成:

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

4. 克隆代码

如果您尚未检出代码,请检出代码:

git clone https://github.com/GoogleCloudPlatform/serverless-photosharing-workshop

然后,您可以前往包含前端的目录:

cd serverless-photosharing-workshop/frontend

前端将具有以下文件布局:

frontend
 |
 ├── index.js
 ├── package.json
 ├── app.yaml
 |
 ├── public
      |
      ├── index.html
      ├── collage.html
      ├── upload.html
      |
      ├── app.js
      ├── script.js
      ├── style.css

在项目根目录中,您有 3 个文件:

  • index.js 包含 Node.js 代码
  • package.json 定义库依赖项
  • app.yaml 是 Google App Engine 的配置文件

public 文件夹包含静态资源:

  • index.html 是显示所有缩略图和标签的页面
  • collage.html 显示近期照片的拼贴
  • upload.html 包含用于上传新图片的表单
  • app.js 使用 Vue.js 在 index.html 页面中填充数据
  • script.js 用于处理小屏幕上的导航菜单及其“汉堡”图标
  • style.css 定义了一些 CSS 指令

5. 探索代码

依赖项

package.json 文件定义了所需的库依赖项:

{
  "name": "frontend",
  "version": "0.0.1",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@google-cloud/firestore": "^3.4.1",
    "@google-cloud/storage": "^4.0.0",
    "express": "^4.16.4",
    "dayjs": "^1.8.22",
    "bluebird": "^3.5.0",
    "express-fileupload": "^1.1.6"
  }
}

我们的应用依赖于:

  • firestore:用于通过图片元数据访问 Cloud Firestore,
  • 存储空间:用于访问存储图片的 Google Cloud Storage,
  • express:Node.js 的 Web 框架,
  • dayjs:一个小巧的库,用于以用户友好的方式显示日期,
  • bluebird:一个 JavaScript promise 库,
  • express-fileupload:一个可轻松处理文件上传的库。

Express 前端

index.js 控制器的开头,您将需要之前在 package.json 中定义的所有依赖项:

const express = require('express');
const fileUpload = require('express-fileupload');
const Firestore = require('@google-cloud/firestore');
const Promise = require("bluebird");
const {Storage} = require('@google-cloud/storage');
const storage = new Storage();
const path = require('path');
const dayjs = require('dayjs');
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)

接下来,系统会创建 Express 应用实例。

使用了两个 Express 中间件:

  • express.static() 调用表示静态资源将在 public 子目录中提供。
  • fileUpload() 配置了文件上传,将文件大小限制为 10 MB,以便在 /tmp 目录中的内存文件系统中本地上传文件。
const app = express();
app.use(express.static('public'));
app.use(fileUpload({
    limits: { fileSize: 10 * 1024 * 1024 },
    useTempFiles : true,
    tempFileDir : '/tmp/'
}))

在静态资源中,您会看到首页、拼贴页面和上传页面的 HTML 文件。这些网页将调用 API 后端。此 API 将具有以下端点:

  • POST /api/pictures 通过 upload.html 中的表单,图片将通过 POST 请求上传
  • GET /api/pictures 此端点会返回一个 JSON 文档,其中包含图片列表及其标签
  • GET /api/pictures/:name 此网址会重定向到全尺寸图片的 Cloud Storage 位置
  • GET /api/thumbnails/:name 此网址会重定向到缩略图的 Cloud Storage 位置
  • GET /api/collage 此最后一个网址会重定向到生成的拼贴图片的 Cloud Storage 位置

图片上传

在探索图片上传 Node.js 代码之前,请先快速了解一下 public/upload.html

... 
<form method="POST" action="/api/pictures" enctype="multipart/form-data">
    ... 
    <input type="file" name="pictures">
    <button>Submit</button>
    ... 
</form>
... 

表单元素指向 /api/pictures 端点,使用 HTTP POST 方法和多部分格式。现在,index.js 必须响应相应端点和方法,并提取文件:

app.post('/api/pictures', async (req, res) => {
    if (!req.files || Object.keys(req.files).length === 0) {
        console.log("No file uploaded");
        return res.status(400).send('No file was uploaded.');
    }
    console.log(`Receiving files ${JSON.stringify(req.files.pictures)}`);

    const pics = Array.isArray(req.files.pictures) ? req.files.pictures : [req.files.pictures];

    pics.forEach(async (pic) => {
        console.log('Storing file', pic.name);
        const newPicture = path.resolve('/tmp', pic.name);
        await pic.mv(newPicture);

        const pictureBucket = storage.bucket(process.env.BUCKET_PICTURES);
        await pictureBucket.upload(newPicture, { resumable: false });
    });


    res.redirect('/');
});

首先,您要检查是否确实有文件正在上传。然后,您可以通过文件上传 Node 模块提供的 mv 方法在本地下载文件。现在,文件已在本地文件系统中提供,您可以将图片上传到 Cloud Storage 存储分区。最后,您将用户重定向回应用的主屏幕。

列出图片

是时候展示您的精美照片了!

/api/pictures 处理程序中,您会查看 Firestore 数据库的 pictures 集合,以检索所有已生成缩略图的图片,并按创建日期降序排列。

您将每张图片及其名称、描述标签(来自 Cloud Vision API)、主色调和创建日期(使用 dayjs,我们使用相对时间偏移量,例如“3 天后”)推送到 JavaScript 数组中。

app.get('/api/pictures', async (req, res) => {
    console.log('Retrieving list of pictures');

    const thumbnails = [];
    const pictureStore = new Firestore().collection('pictures');
    const snapshot = await pictureStore
        .where('thumbnail', '==', true)
        .orderBy('created', 'desc').get();

    if (snapshot.empty) {
        console.log('No pictures found');
    } else {
        snapshot.forEach(doc => {
            const pic = doc.data();
            thumbnails.push({
                name: doc.id,
                labels: pic.labels,
                color: pic.color,
                created: dayjs(pic.created.toDate()).fromNow()
            });
        });
    }
    console.table(thumbnails);
    res.send(thumbnails);
});

此控制器会返回以下形状的结果:

[
   {
      "name": "IMG_20180423_163745.jpg",
      "labels": [
         "Dish",
         "Food",
         "Cuisine",
         "Ingredient",
         "Orange chicken",
         "Produce",
         "Meat",
         "Staple food"
      ],
      "color": "#e78012",
      "created": "a day ago"
   },
   ...
]

此数据结构由 index.html 网页中的一小段 Vue.js 代码使用。以下是该网页上标记的简化版本:

<div id="app">
        <div class="container" id="app">
                <div id="picture-grid">
                        <div class="card" v-for="pic in pictures">
                                <div class="card-content">
                                        <div class="content">
                                                <div class="image-border" :style="{ 'border-color': pic.color }">
                                                        <a :href="'/api/pictures/' + pic.name">
                                                                <img :src="'/api/thumbnails/' + pic.name">
                                                        </a>
                                                </div>
                                                <a class="panel-block" v-for="label in pic.labels" :href="'/?q=' + label">
                                                        <span class="panel-icon">
                                                                <i class="fas fa-bookmark"></i> &nbsp;
                                                        </span>
                                                        {{ label }}
                                                </a>
                                        </div>
                                </div>
                        </div>
            </div>
        </div>
</div>

div 的 ID 将向 Vue.js 表明,它是将动态呈现的标记的一部分。迭代是通过 v-for 指令完成的。

图片会获得一个漂亮的彩色边框,该边框的颜色与 Cloud Vision API 找到的图片中的主色相对应,并且我们在链接和图片来源中指向缩略图和全宽图片。

最后,我们列出描述图片的标签。

以下是 Vue.js 代码段的 JavaScript 代码(位于 index.html 页面底部导入的 public/app.js 文件中):

var app = new Vue({
  el: '#app',
  data() {
    return { pictures: [] }
  },
  mounted() {
    axios
      .get('/api/pictures')
      .then(response => { this.pictures = response.data })
  }
})

Vue 代码使用 Axios 库向我们的 /api/pictures 端点发出 AJAX 调用。然后,返回的数据会绑定到您之前看到的标记中的视图代码。

查看照片

index.html 中,用户可以查看图片缩略图,点击缩略图即可查看全尺寸图片;在 collage.html 中,用户可以查看 collage.png 图片。

在这些网页的 HTML 标记中,图片 src 和链接 href 指向这 3 个端点,这些端点会重定向到图片、缩略图和拼贴的 Cloud Storage 位置。无需在 HTML 标记中对路径进行硬编码。

app.get('/api/pictures/:name', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_PICTURES}/${req.params.name}`);
});

app.get('/api/thumbnails/:name', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_THUMBNAILS}/${req.params.name}`);
});

app.get('/api/collage', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_THUMBNAILS}/collage.png`);
});

运行 Node 应用

定义完所有端点后,Node.js 应用即可启动。Express 应用默认监听端口 8080,并已准备好处理传入请求。

const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
    console.log(`Started web frontend service on port ${PORT}`);
    console.log(`- Pictures bucket = ${process.env.BUCKET_PICTURES}`);
    console.log(`- Thumbnails bucket = ${process.env.BUCKET_THUMBNAILS}`);
});

6. 在本地测试

在部署到云端之前,请先在本地测试代码,确保其正常运行。

您需要导出与两个 Cloud Storage 存储分区对应的两个环境变量:

export BUCKET_THUMBNAILS=thumbnails-${GOOGLE_CLOUD_PROJECT}
export BUCKET_PICTURES=uploaded-pictures-${GOOGLE_CLOUD_PROJECT}

frontend 文件夹中,安装 npm 依赖项并启动服务器:

npm install; npm start

如果一切顺利,它应该会在端口 8080 上启动服务器:

Started web frontend service on port 8080
- Pictures bucket = uploaded-pictures-${GOOGLE_CLOUD_PROJECT}
- Thumbnails bucket = thumbnails-${GOOGLE_CLOUD_PROJECT}

这些日志中会显示存储分区的真实名称,这有助于进行调试。

在 Cloud Shell 中,您可以使用网页预览功能浏览在本地运行的应用:

82fa3266d48c0d0a.png

使用 CTRL-C 退出。

7. 部署到 App Engine

您的应用已准备好进行部署。

配置 App Engine

检查 App Engine 的 app.yaml 配置文件:

runtime: nodejs16
env_variables:
  BUCKET_PICTURES: uploaded-pictures-GOOGLE_CLOUD_PROJECT
  BUCKET_THUMBNAILS: thumbnails-GOOGLE_CLOUD_PROJECT

第一行声明运行时基于 Node.js 10。定义了两个环境变量,分别指向原始图片和缩略图所在的两个存储分区。

如需将 GOOGLE_CLOUD_PROJECT 替换为您的实际项目 ID,您可以运行以下命令:

sed -i -e "s/GOOGLE_CLOUD_PROJECT/${GOOGLE_CLOUD_PROJECT}/" app.yaml

部署

为 App Engine 设置首选区域,请务必使用之前实验中使用的同一区域:

gcloud config set compute/region europe-west1

并部署:

gcloud app deploy

一两分钟后,系统会告知您应用正在处理流量:

Beginning deployment of service [default]...
╔════════════════════════════════════════════════════════════╗
╠═ Uploading 8 files to Google Cloud Storage                ═╣
╚════════════════════════════════════════════════════════════╝
File upload done.
Updating service [default]...done.
Setting traffic split for service [default]...done.
Deployed service [default] to [https://GOOGLE_CLOUD_PROJECT.appspot.com]
You can stream logs from the command line by running:
  $ gcloud app logs tail -s default
To view your application in the web browser run:
  $ gcloud app browse

您还可以访问 Cloud 控制台的 App Engine 部分,查看应用是否已部署,并探索 App Engine 的功能,例如版本控制和流量拆分:

db0e196b00fceab1.png

8. 测试应用

如需进行测试,请前往应用的默认 App Engine 网址 (https://<YOUR_PROJECT_ID>.appspot.com/),您应该会看到前端界面已启动并正在运行!

6a4d5e5603ba4b73.png

9. 清理(可选)

如果您不打算保留该应用,可以通过删除整个项目来清理资源,从而节省费用并践行良好的云资源管理实践:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

10. 恭喜!

恭喜!此 Node.js Web 应用托管在 App Engine 上,可将您的所有服务绑定在一起,并允许用户上传和直观呈现图片。

所学内容

  • App Engine
  • Cloud Storage
  • Cloud Firestore

后续步骤