使用回呼建立人機迴圈工作流程

本教學課程說明如何建立翻譯工作流程,等待您輸入內容 (即迴路中的人員),並連結 Firestore 資料庫、兩個 Cloud Run 函式、Cloud Translation API,以及使用 Firebase SDK 即時更新的網頁。

使用 Workflows 時,您可以支援回呼端點 (或 Webhook),等待 HTTP 要求抵達該端點,稍後再繼續執行工作流程。在本例中,工作流程會等待您輸入內容,以拒絕或驗證部分文字的翻譯,但工作流程也可能等待外部程序。詳情請參閱「使用回呼等待」。

架構

本教學課程會建立一個網頁應用程式,讓您執行下列操作:

  1. 在翻譯網頁上,輸入要從英文翻譯成法文的文字。按一下 [翻譯]
  2. 網頁會呼叫 Cloud Run 函式,啟動工作流程的執行作業。要翻譯的文字會以參數形式傳遞至函式和工作流程。
  3. 文字會儲存在 Cloud Firestore 資料庫中。呼叫 Cloud Translation API。傳回的翻譯內容會儲存在資料庫中。網頁應用程式是使用 Firebase Hosting 部署,並即時更新以顯示翻譯文字。
  4. 工作流程中的 create_callback 步驟會建立回呼端點 URL,並儲存在 Firestore 資料庫中。網頁現在會顯示「驗證」和「拒絕」按鈕。
  5. 工作流程現在已暫停,並等待對回呼端點網址發出的明確 HTTP POST 要求。
  6. 您可以決定是否要驗證或拒絕翻譯。點選按鈕會呼叫第二個 Cloud Run 函式,該函式接著會呼叫工作流程建立的回呼端點,並傳遞核准狀態。工作流程會繼續執行,並在 Firestore 資料庫中儲存 truefalse 的核准狀態。

下圖概略說明這個程序:

含回呼的工作流程

部署第一個 Cloud Run 函式

這個 Cloud Run 函式會啟動工作流程的執行作業。要翻譯的文字會以參數形式傳遞至函式和工作流程。

  1. 建立名為 callback-translation 的目錄,並建立名為 invokeTranslationWorkflowtranslationCallbackCallpublic 的子目錄:

    mkdir -p ~/callback-translation/{invokeTranslationWorkflow,translationCallbackCall,public}
  2. 切換至 invokeTranslationWorkflow 目錄:

    cd ~/callback-translation/invokeTranslationWorkflow
  3. 建立名為 index.js 的文字檔,其中包含下列 Node.js 程式碼:

    const cors = require('cors')({origin: true});
    const {ExecutionsClient} = require('@google-cloud/workflows');
    const client = new ExecutionsClient();
    
    exports.invokeTranslationWorkflow = async (req, res) => {
      cors(req, res, async () => {
        const text = req.body.text;
        console.log(`Translation request for "${text}"`);
    
        const PROJECT_ID = process.env.PROJECT_ID;
        const CLOUD_REGION = process.env.CLOUD_REGION;
        const WORKFLOW_NAME = process.env.WORKFLOW_NAME;
    
        const execResponse = await client.createExecution({
          parent: client.workflowPath(PROJECT_ID, CLOUD_REGION, WORKFLOW_NAME),
          execution: {
            argument: JSON.stringify({text})
          }
        });
        console.log(`Translation workflow execution request: ${JSON.stringify(execResponse)}`);
    
        const execName = execResponse[0].name;
        console.log(`Created translation workflow execution: ${execName}`);
    
        res.set('Access-Control-Allow-Origin', '*');
        res.status(200).json({executionId: execName});
      });
    };
  4. 建立名為 package.json 的文字檔,並在當中加入下列 npm 中繼資料:

    {
      "name": "launch-translation-workflow",
      "version": "0.0.1",
      "dependencies": {
        "@google-cloud/workflows": "^1.2.5",
        "cors": "^2.8.5"
      }
    }
    
  5. 部署 HTTP 觸發函式,並允許未經驗證的存取權:

    gcloud functions deploy invokeTranslationWorkflow \
    --region=${REGION} \
    --runtime nodejs14 \
    --entry-point=invokeTranslationWorkflow \
    --set-env-vars PROJECT_ID=${GOOGLE_CLOUD_PROJECT},CLOUD_REGION=${REGION},WORKFLOW_NAME=translation_validation \
    --trigger-http \
    --allow-unauthenticated

    部署函式可能需要幾分鐘的時間。或者,您也可以在 Google Cloud 控制台使用 Cloud Run 函式介面部署函式。

  6. 部署函式後,您可以確認 httpsTrigger.url 屬性:

    gcloud functions describe invokeTranslationWorkflow

    記下傳回的網址,以便在後續步驟中使用。

部署第二個 Cloud Run 函式

這項 Cloud Run 函式會對工作流程建立的回呼端點發出 HTTP POST 要求,並傳遞核准狀態,指出翻譯內容是否經過驗證或遭到拒絕。

  1. 切換至 translationCallbackCall 目錄:

    cd ../translationCallbackCall
  2. 建立名為 index.js 的文字檔,其中包含下列 Node.js 程式碼:

    const cors = require('cors')({origin: true});
    const fetch = require('node-fetch');
    
    exports.translationCallbackCall = async (req, res) => {
      cors(req, res, async () => {
        res.set('Access-Control-Allow-Origin', '*');
    
        const {url, approved} = req.body;
        console.log("Approved? ", approved);
        console.log("URL = ", url);
        const {GoogleAuth} = require('google-auth-library');
        const auth = new GoogleAuth();
        const token = await auth.getAccessToken();
        console.log("Token", token);
    
        try {
          const resp = await fetch(url, {
              method: 'POST',
              headers: {
                  'accept': 'application/json',
                  'content-type': 'application/json',
                  'authorization': `Bearer ${token}`
              },
              body: JSON.stringify({ approved })
          });
          console.log("Response = ", JSON.stringify(resp));
    
          const result = await resp.json();
          console.log("Outcome = ", JSON.stringify(result));
    
          res.status(200).json({status: 'OK'});
        } catch(e) {
          console.error(e);
    
          res.status(200).json({status: 'error'});
        }
      });
    };
  3. 建立名為 package.json 的文字檔,並在當中加入下列 npm 中繼資料:

    {
      "name": "approve-translation-workflow",
      "version": "0.0.1",
      "dependencies": {
        "cors": "^2.8.5",
        "node-fetch": "^2.6.1",
        "google-auth-library": "^7.1.1"
      }
    }
    
  4. 部署 HTTP 觸發函式,並允許未經驗證的存取權:

    gcloud functions deploy translationCallbackCall \
    --region=${REGION} \
    --runtime nodejs14 \
    --entry-point=translationCallbackCall \
    --trigger-http \
    --allow-unauthenticated

    部署函式可能需要幾分鐘的時間。或者,您也可以在 Google Cloud 控制台使用 Cloud Run 函式介面部署函式。

  5. 部署函式後,您可以確認 httpsTrigger.url 屬性:

    gcloud functions describe translationCallbackCall

    記下傳回的網址,以便在後續步驟中使用。

部署工作流程

工作流程是由一系列步驟組成,這些步驟使用 Workflows 語法描述,且可採用 YAML 或 JSON 格式編寫。這是工作流程的定義。建立工作流程後,請部署工作流程,以便執行。

  1. 切換至 callback-translation 目錄:

    cd ..
  2. 建立檔案名稱為 translation-validation.yaml 的文字檔案,並加入下列內容:

    main:
        params: [translation_request]
        steps:
            - log_request:
                call: sys.log
                args:
                    text: ${translation_request}
            - vars:
                assign:
                    - exec_id: ${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}
                    - text_to_translate: ${translation_request.text}
                    - database_root: ${"projects/" + sys.get_env("GOOGLE_CLOUD_PROJECT_ID") + "/databases/(default)/documents/translations/"}
            - log_translation_request:
                call: sys.log
                args:
                    text: ${text_to_translate}
    
            - store_translation_request:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['text']
                    body:
                        fields:
                            text:
                                stringValue: ${text_to_translate}
                result: store_translation_request_result
    
            - translate:
                call: googleapis.translate.v2.translations.translate
                args:
                    query:
                        q: ${text_to_translate}
                        target: "FR"
                        format: "text"
                        source: "EN"
                result: translation_result
            - assign_translation:
                assign:
                    - translation: ${translation_result.data.translations[0].translatedText} 
            - log_translation_result:
                call: sys.log
                args:
                    text: ${translation}
    
            - store_translated_text:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['translation']
                    body:
                        fields:
                            translation:
                                stringValue: ${translation}
                result: store_translation_request_result   
    
            - create_callback:
                call: events.create_callback_endpoint
                args:
                    http_callback_method: "POST"
                result: callback_details
            - log_callback_details:
                call: sys.log
                args:
                    text: ${callback_details}
    
            - store_callback_details:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['callback']
                    body:
                        fields:
                            callback:
                                stringValue: ${callback_details.url}
                result: store_callback_details_result
    
            - await_callback:
                call: events.await_callback
                args:
                    callback: ${callback_details}
                    timeout: 3600
                result: callback_request
            - assign_approval:
                assign:
                    - approved: ${callback_request.http_request.body.approved}
    
            - store_approval:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['approved']
                    body:
                        fields:
                            approved:
                                booleanValue: ${approved}
                result: store_approval_result
    
            - return_outcome:
                return:
                    text: ${text_to_translate}
                    translation: ${translation}
                    approved: ${approved}
  3. 建立工作流程後,您可以部署工作流程,但請勿執行工作流程:

    gcloud workflows deploy translation_validation --source=translation-validation.yaml

建立網頁應用程式

建立網頁應用程式,呼叫啟動工作流程執行的 Cloud 函式。網頁會即時更新,顯示翻譯要求結果。

  1. 切換至 public 目錄:

    cd public
  2. 建立檔案名稱為 index.html 的文字檔案,並在其中加入下列 HTML 標記。(您會在後續步驟中修改 index.html 檔案,並新增 Firebase SDK 指令碼)。

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width">
    
        <title>Frenglish translation — Feature Workflows callbacks</title>
    
        <link rel="stylesheet"
            href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.42/dist/themes/base.css">
        <script type="module"
            src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.42/dist/shoelace.js"></script>
        <link rel="stylesheet" href="./style.css">
    </head>
    
    <body>
        <h1>Translate from English to French</h1>
    
        <sl-form class="form-overview">
            <sl-textarea id="text" placeholder="The quick brown fox jumps over the lazy dog."
                label="English text to translate"></sl-textarea>
            <p></p>
            <sl-button id="translateBtn" type="primary">Translate</sl-button>
            <p></p>
            <sl-alert id="translation" type="primary">
                Le rapide renard brun saute au dessus du chien paresseux.
            </sl-alert>
            <p></p>
            <div id="buttonRow" style="display: none;">
                <sl-button id="validateBtn" type="success">Validate</sl-button>
                <sl-button id="rejectBtn" type="danger">Reject</sl-button>
            </div>
            <p></p>
            <sl-alert id="validationAlert" type="success">
                <sl-icon slot="icon" name="check2-circle"></sl-icon>
                <strong>The translation has been validated</strong><br>
                Glad that you liked our translation! We'll save it in our database.
            </sl-alert>
            <sl-alert id="rejectionAlert" type="danger">
                <sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
                <strong>The translation has been rejected</strong><br>
                A pity the translation isn't good! We'll do better next time!
            </sl-alert>
            <p></p>
            <sl-button id="newBtn" style="display: none;" type="primary">New translation</sl-button>
        </sl-form>
    
        <script src="https://www.gstatic.com/firebasejs/8.6.3/firebase-app.js"></script>
        <script src="https://www.gstatic.com/firebasejs/8.6.3/firebase-firestore.js"></script>
    
        <script>
            var firebaseConfig = {
                apiKey: "XXXX",
                authDomain: "XXXX",
                projectId: "XXXX",
                storageBucket: "XXXX",
                messagingSenderId: "XXXX",
                appId: "XXXX",
                measurementId: "XXXX"
            };
            // Initialize Firebase
            firebase.initializeApp(firebaseConfig);
        </script>
        <script src="./script.js" type="module"></script>
    </body>
    
    </html>
    
  3. 建立檔案名稱為 script.js 的文字檔案,其中包含下列 JavaScript 程式碼:

    document.addEventListener("DOMContentLoaded", async function (event) {
        const textArea = document.getElementById("text");
        textArea.focus();
    
        const newBtn = document.getElementById("newBtn");
        newBtn.addEventListener("sl-focus", event => {
            event.target.blur();
            window.location.reload();
        });
    
        const translationAlert = document.getElementById("translation");
        const buttonRow = document.getElementById("buttonRow");
    
        var callbackUrl = "";
    
        const validationAlert = document.getElementById("validationAlert");
        const rejectionAlert = document.getElementById("rejectionAlert");
        const validateBtn = document.getElementById("validateBtn");
        const rejectBtn = document.getElementById("rejectBtn");
    
        const translateBtn = document.getElementById("translateBtn");
        translateBtn.addEventListener("sl-focus", async event => {
            event.target.disabled = true;
            event.target.loading = true;
            textArea.disabled = true;
    
            console.log("Text to translate = ", textArea.value);
    
            const fnUrl = UPDATE_ME;
    
            try {
                console.log("Calling workflow executor function...");
                const resp = await fetch(fnUrl, {
                    method: "POST",
                    headers: {
                        "accept": "application/json",
                        "content-type": "application/json"
                    },
                    body: JSON.stringify({ text: textArea.value })
                });
                const executionResp = await resp.json();
                const executionId = executionResp.executionId.slice(-36);
                console.log("Execution ID = ", executionId);
    
                const db = firebase.firestore();
                const translationDoc = db.collection("translations").doc(executionId);
    
                var translationReceived = false;
                var callbackReceived =  false;
                var approvalReceived = false;
                translationDoc.onSnapshot((doc) => {
                    console.log("Firestore update", doc.data());
                    if (doc.data()) {
                        if ("translation" in doc.data()) {
                            if (!translationReceived) {
                                console.log("Translation = ", doc.data().translation);
                                translationReceived = true;
                                translationAlert.innerText = doc.data().translation;
                                translationAlert.open = true;
                            }
                        }
                        if ("callback" in doc.data()) {
                            if (!callbackReceived) {
                                console.log("Callback URL = ", doc.data().callback);
                                callbackReceived = true;
                                callbackUrl = doc.data().callback;
                                buttonRow.style.display = "block";
                            }
                        }
                        if ("approved" in doc.data()) {
                            if (!approvalReceived) {
                                const approved = doc.data().approved;
                                console.log("Approval received = ", approved);
                                if (approved) {
                                    validationAlert.open = true;
                                    buttonRow.style.display = "none";
                                    newBtn.style.display = "inline-block";   
                                } else {
                                    rejectionAlert.open = true;
                                    buttonRow.style.display = "none";
                                    newBtn.style.display = "inline-block";
                                }
                                approvalReceived = true;
                            }
                        }
                    }
                });
            } catch (e) {
                console.log(e);
            }
            event.target.loading = false;
        });
    
        validateBtn.addEventListener("sl-focus", async event => {
            validateBtn.disabled = true;
            rejectBtn.disabled = true;
            validateBtn.loading = true;
            validateBtn.blur();
    
            // call callback
            await callCallbackUrl(callbackUrl, true);
        });
    
        rejectBtn.addEventListener("sl-focus", async event => {
            rejectBtn.disabled = true;
            validateBtn.disabled = true;
            rejectBtn.loading = true;
            rejectBtn.blur();
    
            // call callback
            await callCallbackUrl(callbackUrl, false);
        });
    
    });
    
    async function callCallbackUrl(url, approved) {
        console.log("Calling callback URL with status = ", approved);
    
        const fnUrl = UPDATE_ME;
        try {
            const resp = await fetch(fnUrl, {
                method: "POST",
                headers: {
                    "accept": "application/json",
                    "content-type": "application/json"
                },
                body: JSON.stringify({ url, approved })
            });
            const result = await resp.json();
            console.log("Callback answer = ", result);
        } catch(e) {
            console.log(e);
        }
    }
  4. 編輯 script.js 檔案,將 UPDATE_ME 預留位置替換成您先前記下的 Cloud Run 函式網址。

    1. translateBtn.addEventListener 方法中,將 const fnUrl = UPDATE_ME; 替換為:

      const fnUrl = "https://REGION-PROJECT_ID.cloudfunctions.net/invokeTranslationWorkflow";

    2. callCallbackUrl 函式中,將 const fnUrl = UPDATE_ME; 替換為:

      const fnUrl = "https://REGION-PROJECT_ID.cloudfunctions.net/translationCallbackCall";

  5. 建立檔案名稱為 style.css 的文字檔案,其中包含下列層疊樣式:

    * {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
    }
    
    body {
        margin: 20px;
    }
    
    h1, h2, h3, h4 {
        color: #0ea5e9;
    }
    

將 Firebase 新增至您的網頁應用程式

在本教學課程中,HTML 網頁、JavaScript 指令碼和 CSS 樣式表會使用 Firebase Hosting 部署為靜態資產,但這些資產可代管在任何位置,並在本機上提供服務,以供測試。

建立 Firebase 專案

將 Firebase 新增至應用程式前,請先建立要連結至該應用程式的 Firebase 專案。

  1. Firebase 控制台中,按一下「建立專案」,然後從下拉式選單中選取現有的 Google Cloud 專案,將 Firebase 資源新增至該專案。

  2. 按一下「繼續」,直到看到新增 Firebase 的選項。

  3. 略過為專案設定 Google Analytics。

  4. 按一下「新增 Firebase」

Firebase 會自動為 Firebase 專案佈建資源。程序完成後,系統會將您帶往 Firebase 控制台的專案總覽頁面。

向 Firebase 註冊應用程式

建立 Firebase 專案後,你可以加入網頁應用程式。

  1. 在專案總覽頁面中間,按一下「Web」圖示 (</>) 啟動設定工作流程。

  2. 輸入應用程式的暱稱。

    只有您能在 Firebase 控制台中看到這項資訊。

  3. 暫時略過設定 Firebase 託管。

  4. 按一下「Register app」,然後繼續前往主控台。

啟用 Cloud Firestore

網頁應用程式會使用 Cloud Firestore 接收及儲存資料。您需要啟用 Cloud Firestore。

  1. 在 Firebase 控制台的「Build」專區中,按一下「Firestore Database」

    (您可能需要先展開左側導覽窗格,才能看到「建構」部分)。

  2. 在 Cloud Firestore 窗格中,按一下「建立資料庫」

  3. 選取「以測試模式啟動」,並使用下列安全性規則:

    rules_version = '2';
    service cloud.firestore {
    match /databases/{database}/documents {
      match /{document=**} {
        allow read, write;
      }
    }
    }
  4. 詳閱安全性規則免責事項後,請按一下「繼續」

  5. 設定 Cloud Firestore 資料的儲存位置。 您可以接受預設值,或選擇您附近的區域。

  6. 按一下「啟用」,佈建 Firestore。

新增 Firebase SDK 並初始化 Firebase

Firebase 為大多數 Firebase 產品提供 JavaScript 程式庫。使用 Firebase Hosting 前,請先在 Web 應用程式中加入 Firebase SDK。

  1. 如要在應用程式中初始化 Firebase,請提供應用程式的 Firebase 專案設定。
    1. 在 Firebase 控制台中,前往「專案設定」
    2. 在「您的應用程式」窗格中選取應用程式。
    3. 在「SDK setup and configuration」(SDK 設定和設定) 窗格中,如要從 CDN 載入 Firebase SDK 程式庫,請選取「CDN」
    4. 將程式碼片段複製到 index.html 檔案的 <body> 代碼底部,並取代 XXXX 預留位置值。
  2. 安裝 Firebase JavaScript SDK。

    1. 如果您尚未建立 package.json 檔案,請從 callback-translation 目錄執行下列指令來建立檔案:

      npm init
    2. 執行下列指令,安裝 firebase npm 套件並儲存至 package.json 檔案:

      npm install firebase

初始化及部署專案

如要將本機專案檔案連結至 Firebase 專案,請先初始化專案。接著即可部署網頁應用程式。

  1. callback-translation 目錄執行下列指令:

    firebase init
  2. 選取 Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys 選項。

  3. 選擇使用現有專案,並輸入專案 ID。

  4. 接受 public 做為預設公開根目錄。

  5. 選擇設定單頁應用程式。

  6. 略過透過 GitHub 設定自動建構及部署。

  7. File public/index.html already exists. Overwrite? 提示中輸入「No」

  8. 切換至 public 目錄:

    cd public
  9. public 目錄執行下列指令,將專案部署至網站:

    firebase deploy --only hosting

在本機測試網頁應用程式

Firebase 託管服務可讓您在本機查看及測試變更,並與模擬的後端專案資源互動。使用 firebase serve 時,應用程式會與代管內容和設定的模擬後端互動,但所有其他專案資源則會與實際後端互動。在本教學課程中,您可以使用 firebase serve,但進行更廣泛的測試時,不建議使用這項工具。

  1. public 目錄執行下列指令:

    firebase serve
  2. 在傳回的本機網址 (通常為 http://localhost:5000) 開啟網頁應用程式。

  3. 輸入一些英文文字,然後按一下「翻譯」

    系統應顯示法文譯文。

  4. 現在可以點選「驗證」或「拒絕」

    您可以在 Firestore 資料庫中驗證內容。 畫面應如下所示:

    approved: true
    callback: "https://workflowexecutions.googleapis.com/v1/projects/26811016474/locations/us-central1/workflows/translation_validation/executions/68bfce75-5f62-445f-9cd5-eda23e6fa693/callbacks/72851c97-6bb2-45e3-9816-1e3dcc610662_1a16697f-6d90-478d-9736-33190bbe222b"
    text: "The quick brown fox jumps over the lazy dog."
    translation: "Le renard brun rapide saute par-dessus le chien paresseux."
    

    approved 狀態為 truefalse,取決於您是否驗證或拒絕翻譯。

恭喜!您已建立人機迴圈翻譯工作流程,並使用 Workflows 回呼。