每一天:研究室 4:建立網路前端

1. 總覽

在本程式碼研究室中,您將在 Google App Engine 上建立網頁前端,讓使用者從網頁應用程式上傳圖片,以及瀏覽上傳的圖片和縮圖。

21741cd63b425aeb.png

這個網頁應用程式會使用名為 Bulma 的 CSS 架構,提供美觀的使用者介面,並使用 Vue.JS JavaScript 前端架構呼叫您建構的應用程式 API。

這個應用程式包含三個分頁:

  • 首頁:顯示所有上傳圖片的縮圖,以及描述圖片的標籤清單 (先前實驗室中由 Cloud Vision API 偵測到的標籤)。
  • 系統會顯示拼貼頁面,其中包含最近上傳的 4 張相片。
  • 上傳頁面,使用者可以在這裡上傳新圖片。

產生的前端如下所示:

6a4d5e5603ba4b73.png

這 3 個網頁都是簡單的 HTML 網頁:

  • 首頁 (index.html) 會呼叫 Node App Engine 後端程式碼,透過對 /api/pictures 網址的 AJAX 呼叫,取得縮圖清單和標籤。首頁會使用 Vue.js 擷取這項資料。
  • 「美術拼貼」頁面 (collage.html) 會指向 collage.png 圖片,該圖片會組合 4 張最新相片。
  • 「上傳」頁面 (upload.html) 提供簡單的表單,可透過對 /api/pictures URL 的 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 控制台會自動產生專屬字串,通常您不需要在意該字串為何。在大多數程式碼研究室中,您需要參照專案 ID (通常會標示為 PROJECT_ID),因此如果您不喜歡該字串,可以產生另一個隨機字串,或是嘗試使用自己的字串,看看是否可用。專案建立後,系統就會「凍結」該值。
  • 還有第三個值,也就是部分 API 使用的「專案編號」。如要進一步瞭解這三種值,請參閱說明文件
  1. 接著,您需要在 Cloud 控制台中啟用帳單,才能使用 Cloud 資源/API。完成本程式碼研究室的費用應該不高,甚至完全免費。如要停用資源,避免在本教學課程結束後繼續產生帳單費用,請按照程式碼研究室結尾的「清除」操作說明操作。Google Cloud 新使用者可參加價值$300 美元的免費試用計畫。

啟動 Cloud Shell

雖然可以透過筆電遠端操作 Google Cloud,但在本程式碼研究室中,您將使用 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 的網路架構。
  • 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 這個網址會重新導向至全尺寸圖片的雲端儲存位置
  • GET /api/thumbnails/:name 這個網址會重新導向至縮圖的雲端儲存位置
  • GET /api/collage 這個網址會重新導向至所產生合成圖片的雲端儲存位置

上傳圖片

在探索圖片上傳 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 bucket。最後,將使用者重新導向回應用程式主畫面。

列出圖片

現在就來展示美麗的相片吧!

/api/pictures 處理常式中,您會查看 Firestore 資料庫的 pictures 集合,擷取所有已產生縮圖的圖片,並依建立日期降序排序。

您可以在 JavaScript 陣列中推送每張圖片,並附上圖片名稱、描述圖片的標籤 (來自 Cloud Vision API)、主要顏色和建立日期 (使用 dayjs,我們採用相對時間偏移量,例如「3 天後」)。

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 bucket 對應的兩個環境變數:

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 Console 的 App Engine 專區,查看應用程式是否已部署,並探索 App Engine 的各項功能,例如版本控管和流量分配:

db0e196b00fceab1.png

8. 測試應用程式

如要測試,請前往應用程式的預設 App Engine 網址 (https://<YOUR_PROJECT_ID>.appspot.com/),您應該會看到前端 UI 正在運作!

6a4d5e5603ba4b73.png

9. 清除 (選用)

如果您不打算保留應用程式,可以刪除整個專案來清除資源,節省費用,並當個優質的雲端使用者:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

10. 恭喜!

恭喜!這個 Node.js 網路應用程式託管於 App Engine,可將所有服務繫結在一起,並讓使用者上傳及顯示圖片。

涵蓋內容

  • App Engine
  • Cloud Storage
  • Cloud Firestore

後續步驟