教學課程:使用 Azure Web PubSub 服務和 Azure Functions 將 IoT 中樞的 IoT 裝置資料視覺化

在本教學課程中,您將會了解如何使用 Azure Web PubSub 服務和 Azure Functions,從 IoT 中樞建置具有即時資料視覺效果的無伺服器應用程式。

在本教學課程中,您會了解如何:

  • 建置無伺服器資料視覺效果應用程式
  • 搭配使用 Web PubSub 函式輸入/輸出繫結與 Azure IoT 中樞
  • 在本機執行範例函式

重要

原始 連接字串 只會針對示範目的出現在本文中。

連接字串 包含你的應用程式存取 Azure Web PubSub 服務所需的授權資訊。 連接字串 中的存取鍵就像是你服務的根密碼。 在生產環境中,請一律保護您的存取金鑰。 使用 Azure 金鑰保存庫,安全地管理和輪替密鑰,並使用保護連線WebPubSubServiceClient。

避免將存取金鑰散發給其他使用者、寫入程式碼,或將其以純文字儲存在他人可以存取的位置。 如果您認為金鑰可能已遭盜用,請輪替金鑰。

必要條件

如果您沒有 Azure 帳戶,請在開始之前建立 免費帳戶 。

建立 IoT 中樞

在本節中,您會使用 Azure CLI 來建立 IoT 中樞和資源群組。 Azure 資源群組是在其中部署與管理 Azure 資源的邏輯容器。 IoT 中樞可作為 IoT 應用程式與裝置之間雙向通訊的中央訊息中樞。

如果您的 Azure 訂用帳戶中已有 IoT 中樞,則您可以略過本節。

若要建立 IoT 中樞和資源群組:

  1. 啟動 CLI 應用程式。 若要在此文其餘部分執行 CLI 命令,請複製命令語法,並將其貼入 CLI 應用程式,然後編輯變數值,再按 Enter。

    • 如果您要使用 Cloud Shell,則請選取 CLI 命令上的 [試用] 按鈕,以在分割的瀏覽器視窗中啟動 Cloud Shell。 或者,您可以在不同的瀏覽器索引標籤中開啟 Cloud Shell。
    • 如果您要在本機使用 Azure CLI,則請啟動 CLI 主控台應用程式,並登入 Azure CLI。
  2. 執行 az extension add 以將 azure-iot 延伸模組安裝或升級至目前版本。

    az extension add --upgrade --name azure-iot
    
  3. 在 CLI 應用程式中,執行 az group create 命令以建立資源群組。 下列命令會在 eastus 位置中建立名為 MyResourceGroup 的資源群組。

    附註

    您可以選擇性地設定不同的位置。 若要查看可用位置,請執行 az account list-locations。 本快速入門使用 eastus,如範例命令所示。

    az group create --name MyResourceGroup --location eastus
    
  4. 使用 az iot hub create 命令建立 IoT 中樞。 建立 IoT 中樞可能需要幾分鐘的時間。

    YourIoTHubName。 使用您為 IoT 中樞選擇的名稱,取代下列命令中的這個預留位置符號及其周圍的大括號。 IoT 中樞名稱在 Azure 中必須是全域唯一的。 在本快速入門的其餘內容中,凡是看到預留位置符號時,請使用您的 IoT 中樞名稱。

    az iot hub create --resource-group MyResourceGroup --name {YourIoTHubName}
    

建立 Web PubSub 執行個體

如果您的 Azure 訂用帳戶中已有 Web PubSub 執行個體,則您可以略過本節。

執行 az extension add 以將 webpubsub 延伸模組安裝或升級至目前版本。

az extension add --upgrade --name webpubsub

使用 Azure CLI az webpubsub create 命令,在您已建立的資源群組中建立 Web PubSub。 下列命令會在 EastUS 的資源群組 myResourceGroup 下建立免費 Web PubSub 資源:

重要

每個 Web PubSub 資源都必須有唯一的名稱。 在下列範例中,請將 <your-unique-resource-name> 替換為您的 Web PubSub 名稱。

az webpubsub create --name "<your-unique-resource-name>" --resource-group "myResourceGroup" --location "EastUS" --sku Free_F1

此命令的輸出顯示新建資源的屬性。 請記下下列兩個屬性:

  • 資源名稱:您提供給上述 --name 參數的名稱。
  • hostName:在此範例中,主機名稱為 <your-unique-resource-name>.webpubsub.azure.com/。

此時,您的 Azure 帳戶是唯一獲得授權在此新資源上執行任何作業的帳戶。

在本機建立並執行函式

  1. 建立專案的空白資料夾,然後在新資料夾中執行下列命令。

    func init --worker-runtime javascript --model V4
    
  2. 建立 index 函式,以讀取和裝載用戶端的靜態網頁。

    func new -n index -t HttpTrigger
    

    使用下列程式碼更新 src/functions/index.js,將 HTML 內容作為靜態網站。

    const { app } = require('@azure/functions');
    const { readFile } = require('fs/promises');
    
    app.http('index', {
        methods: ['GET', 'POST'],
        authLevel: 'anonymous',
        handler: async (context) => {
            const content = await readFile('index.html', 'utf8', (err, data) => {
                if (err) {
                    context.err(err)
                    return
                }
            });
    
            return { 
                status: 200,
                headers: { 
                    'Content-Type': 'text/html'
                }, 
                body: content, 
            };
        }
    });
    
  3. 在根資料夾下建立 index.html 檔案。

    <!doctype html>
    
    <html lang="en">
    
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <script src="https://cdn.jsdelivr.net/npm/chart.js@2.8.0/dist/Chart.min.js" type="text/javascript"
            charset="utf-8"></script>
        <script>
            document.addEventListener("DOMContentLoaded", async function (event) {
                const res = await fetch(`/api/negotiate?id=${1}`);
                const data = await res.json();
                const webSocket = new WebSocket(data.url);
    
                class TrackedDevices {
                    constructor() {
                        // key as the deviceId, value as the temperature array
                        this.devices = new Map();
                        this.maxLen = 50;
                        this.timeData = new Array(this.maxLen);
                    }
    
                    // Find a device temperature based on its Id
                    findDevice(deviceId) {
                        return this.devices.get(deviceId);
                    }
    
                    addData(time, temperature, deviceId, dataSet, options) {
                        let containsDeviceId = false;
                        this.timeData.push(time);
                        for (const [key, value] of this.devices) {
                            if (key === deviceId) {
                                containsDeviceId = true;
                                value.push(temperature);
                            } else {
                                value.push(null);
                            }
                        }
    
                        if (!containsDeviceId) {
                            const data = getRandomDataSet(deviceId, 0);
                            let temperatures = new Array(this.maxLen);
                            temperatures.push(temperature);
                            this.devices.set(deviceId, temperatures);
                            data.data = temperatures;
                            dataSet.push(data);
                        }
    
                        if (this.timeData.length > this.maxLen) {
                            this.timeData.shift();
                            this.devices.forEach((value, key) => {
                                value.shift();
                            })
                        }
                    }
    
                    getDevicesCount() {
                        return this.devices.size;
                    }
                }
    
                const trackedDevices = new TrackedDevices();
                function getRandom(max) {
                    return Math.floor((Math.random() * max) + 1)
                }
                function getRandomDataSet(id, axisId) {
                    return getDataSet(id, axisId, getRandom(255), getRandom(255), getRandom(255));
                }
                function getDataSet(id, axisId, r, g, b) {
                    return {
                        fill: false,
                        label: id,
                        yAxisID: axisId,
                        borderColor: `rgba(${r}, ${g}, ${b}, 1)`,
                        pointBoarderColor: `rgba(${r}, ${g}, ${b}, 1)`,
                        backgroundColor: `rgba(${r}, ${g}, ${b}, 0.4)`,
                        pointHoverBackgroundColor: `rgba(${r}, ${g}, ${b}, 1)`,
                        pointHoverBorderColor: `rgba(${r}, ${g}, ${b}, 1)`,
                        spanGaps: true,
                    };
                }
    
                function getYAxy(id, display) {
                    return {
                        id: id,
                        type: "linear",
                        scaleLabel: {
                            labelString: display || id,
                            display: true,
                        },
                        position: "left",
                    };
                }
    
                // Define the chart axes
                const chartData = { datasets: [], };
    
                // Temperature (ºC), id as 0
                const chartOptions = {
                    responsive: true,
                    animation: {
                        duration: 250 * 1.5,
                        easing: 'linear'
                    },
                    scales: {
                        yAxes: [
                            getYAxy(0, "Temperature (ºC)"),
                        ],
                    },
                };
                // Get the context of the canvas element we want to select
                const ctx = document.getElementById("chart").getContext("2d");
    
                chartData.labels = trackedDevices.timeData;
                const chart = new Chart(ctx, {
                    type: "line",
                    data: chartData,
                    options: chartOptions,
                });
    
                webSocket.onmessage = function onMessage(message) {
                    try {
                        const messageData = JSON.parse(message.data);
                        console.log(messageData);
    
                        // time and either temperature or humidity are required
                        if (!messageData.MessageDate ||
                            !messageData.IotData.temperature) {
                            return;
                        }
                        trackedDevices.addData(messageData.MessageDate, messageData.IotData.temperature, messageData.DeviceId, chartData.datasets, chartOptions.scales);
                        const numDevices = trackedDevices.getDevicesCount();
                        document.getElementById("deviceCount").innerText =
                            numDevices === 1 ? `${numDevices} device` : `${numDevices} devices`;
                        chart.update();
                    } catch (err) {
                        console.error(err);
                    }
                };
            });
        </script>
        <style>
            body {
                font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
                padding: 50px;
                margin: 0;
                text-align: center;
            }
    
            .flexHeader {
                display: flex;
                flex-direction: row;
                flex-wrap: nowrap;
                justify-content: space-between;
            }
    
            #charts {
                display: flex;
                flex-direction: row;
                flex-wrap: wrap;
                justify-content: space-around;
                align-content: stretch;
            }
    
            .chartContainer {
                flex: 1;
                flex-basis: 40%;
                min-width: 30%;
                max-width: 100%;
            }
    
            a {
                color: #00B7FF;
            }
        </style>
    
        <title>Temperature Real-time Data</title>
    </head>
    
    <body>
        <h1 class="flexHeader">
            <span>Temperature Real-time Data</span>
            <span id="deviceCount">0 devices</span>
        </h1>
        <div id="charts">
            <canvas id="chart"></canvas>
        </div>
    </body>
    
    </html>
    
  4. 建立用戶端用來取得服務連線 URL 和存取權杖的 negotiate 函式。

    func new -n negotiate -t HttpTrigger
    

    更新 src/functions/negotiate.js 以使用包含所產生權杖的 WebPubSubConnection。

    const { app, input } = require('@azure/functions');
    
    const connection = input.generic({
        type: 'webPubSubConnection',
        name: 'connection',
        hub: '%hubName%'
    });
    
    app.http('negotiate', {
        methods: ['GET', 'POST'],
        authLevel: 'anonymous',
        extraInputs: [connection],
        handler: async (request, context) => {
            return { body: JSON.stringify(context.extraInputs.get('connection')) };
        },
    });
    
  5. 使用 messagehandler 範本建立 "IoT 中樞 (Event Hub)" 函式來產生通知。

    原始 連接字串 只會針對示範目的出現在本文中。 在生產環境中,請一律保護您的存取金鑰。 使用 Azure 金鑰保存庫,安全地管理和輪替密鑰,並使用保護連線WebPubSubServiceClient。

     func new --template "Azure Event Hub trigger" --name messagehandler
    
    • 使用下列 JSON 程式碼更新 src/functions/messagehandler.js 以新增 Web PubSub 輸出繫結。 我們使用變數 %hubName% 作為 IoT eventHubName 和 Web PubSub 中樞的中樞名稱。

      const { app, output } = require('@azure/functions');
      
      const wpsAction = output.generic({
          type: 'webPubSub',
          name: 'action',
          hub: '%hubName%'
      });
      
      app.eventHub('messagehandler', {
          connection: 'IOTHUBConnectionString',
          eventHubName: '%hubName%',
          cardinality: 'many',
          extraOutputs: [wpsAction],
          handler: (messages, context) => {
              var actions = [];
              if (Array.isArray(messages)) {
                  context.log(`Event hub function processed ${messages.length} messages`);
                  for (const message of messages) {
                      context.log('Event hub message:', message);
                      actions.push({
                          actionName: "sendToAll",
                          data: JSON.stringify({
                              IotData: message,
                              MessageDate: message.date || new Date().toISOString(),
                              DeviceId: message.deviceId,
                          })});
                  }
              } else {
                  context.log('Event hub function processed message:', messages);
                  actions.push({
                      actionName: "sendToAll",
                      data: JSON.stringify({
                          IotData: message,
                          MessageDate: message.date || new Date().toISOString(),
                          DeviceId: message.deviceId,
                      })});
              }
              context.extraOutputs.set(wpsAction, actions);
          }
      });
      
  6. 更新函式設定。

    1. 新增 hubName 設定,並將 {YourIoTHubName} 替換為您建立 IoT 中樞時所使用的中樞名稱。

      func settings add hubName "{YourIoTHubName}"
      
    2. 取得 IoT 中樞的服務連接字串。

    az iot hub connection-string show --policy-name service --hub-name {YourIoTHubName} --output table --default-eventhub
    

    設定 IOTHubConnectionString,以值取代 <iot-connection-string>。

    func settings add IOTHubConnectionString "<iot-connection-string>"
    
    1. 取得 Web PubSub 的連接字串。
    az webpubsub key show --name "<your-unique-resource-name>" --resource-group "<your-resource-group>" --query primaryConnectionString
    

    設定 WebPubSubConnectionString,以值取代 <webpubsub-connection-string>。

    func settings add WebPubSubConnectionString "<webpubsub-connection-string>"
    

    附註

    範例中使用的 Azure Event Hub trigger 函式觸發程序相依於 Azure 儲存體,但您可以在函式於本機執行時使用本機儲存體模擬器。 如果您收到 There was an error performing a read operation on the Blob 儲存體 Secret Repository. Please ensure the 'AzureWebJobsStorage' 連接字串 is valid. 之類的錯誤,您必須下載並啟用儲存體模擬器。

  7. 在本機執行函式。

    現在您可以透過下列命令執行本機函式。

    func start
    

    您可以造訪:https://localhost:7071/api/index,以查看本機主機的靜態頁面。

執行裝置以傳送資料

註冊裝置

裝置必須向您的 IoT 中樞註冊,才能進行連線。 如果您已在 IoT 中樞內註冊裝置,則可以略過本節。

  1. 在 Azure Cloud Shell 中執行 az iot hub device-identity create 命令,以建立裝置身分識別。

    YourIoTHubName:以您為 IoT 中樞選擇的名稱取代此預留位置。

    az iot hub device-identity create --hub-name {YourIoTHubName} --device-id simDevice
    
  2. 在 Azure Cloud Shell 中執行 Az PowerShell module iot hub device-identity connection-string show 命令,以針對您剛註冊的裝置取得「裝置連接字串」:

    YourIoTHubName:以您為 IoT 中樞選擇的名稱取代此預留位置。

    az iot hub device-identity connection-string show --hub-name {YourIoTHubName} --device-id simDevice --output table
    

    記下裝置連接字串,看起來如下:

    HostName={YourIoTHubName}.azure-devices.net;DeviceId=simDevice;SharedAccessKey={YourSharedAccessKey}

執行視覺化網站

開啟函式主機索引頁面:http://localhost:7071/api/index 以檢視即時儀表板。 註冊多個裝置,您會看到儀表板即時更新多個裝置。 開啟多個瀏覽器,您會看到每個頁面都會即時更新。

使用 Web PubSub 服務的多個裝置數據視覺效果螢幕快照。

清除資源

如果您打算繼續進行後續的快速入門和教學課程,您可以讓這些資源留在原處。

若不再需要,您可以使用 Azure CLI az group delete 命令來移除資源群組和所有相關資源:

az group delete --name "myResourceGroup"

下一步