﻿# 使用 Azure Functions 搭配 Azure SignalR Service 實現無伺服器架構

要在 Azure 實現即時連線式的無伺服器架構(Serverless architecture)其實相當容易，只要學會 Azure Functions 的開發方式，搭配著 Azure SignalR Service 就可以輕鬆實現，本篇文章我將說明實現這個架構的相關說明與注意事項。

### 開發環境

要開發 Azure Functions 建議先安裝以下開發工具：

1.  [Visual Studio Code](https://code.visualstudio.com/Download)
    
    ```bash
    choco install vscode -y
    ```
    
    > 請一併安裝 [Azure Functions](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions) 擴充套件
    
2.  [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v3%2Cwindows%2Ccsharp%2Cportal%2Cbash%2Ckeda&WT.mc_id=DT-MVP-4015686) v3
    
    ```bash
    choco install azure-functions-core-tools -y
    ```
    
    > [Azure Functions runtime versions overview](https://learn.microsoft.com/en-us/azure/azure-functions/functions-versions?WT.mc_id=DT-MVP-4015686&tabs=csharp%2Cv3)
    
3.  [Azure Storage Emulator](https://go.microsoft.com/fwlink/?linkid=717179&clcid=0x409)
    
    由於 Azure Functions 在執行時需要 Azure Storage 才能運作，在本機開發時建議你先啟動 Azure Storage Emulator 再來執行測試。
    
    我在安裝 Visual Studio 2019 的時候都會安裝 **Azure development** 工作負載(Workload)，所以預設就會安裝 Azure Storage Emulator 起來。如果你沒有安裝的話，可以用以下命令進行安裝，或是[直接下載 MSI 檔](https://go.microsoft.com/fwlink/?linkid=717179&clcid=0x409)進行安裝。
    
    ```bash
    choco install azurestorageemulator -y
    ```
    
    > 這個 Chocolatey 套件雖然顯示稍微舊版，但事實上預設會安裝 Azure Storage Emulator 最新版！
    
    第一次使用 Azure Storage Emulator 的人，可以參考以下兩個命令初始化並啟動模擬器：
    
    ```bash
    "C:\Program Files (x86)\Microsoft SDKs\Azure\Storage Emulator\AzureStorageEmulator.exe" init
    "C:\Program Files (x86)\Microsoft SDKs\Azure\Storage Emulator\AzureStorageEmulator.exe" start
    ```
    
    > 如果你有啟動 Docker Desktop for Windows 的話，可能會佔用 `10000` (Blob), `10001` (Queue), `10002` (Table) 這三個本機通訊埠(Ports)，若遇到這種情況，只要重開機就能解決。(重開治百病)
    
4.  [.NET Core SDK](https://dotnet.microsoft.com/download) 3.1
    
    ```bash
    choco install dotnetcore-sdk -y
    ```
    
5.  [建立 Azure SignalR Service 服務](https://learn.microsoft.com/en-us/azure/azure-signalr/signalr-quickstart-azure-functions-csharp#create-an-azure-signalr-service-instance)
    

### 開發 Azure Functions 應用程式

1.  初始化 Azure Functions 應用程式
    
    ```bash
    mkdir demo1
    cd demo1
    func init --worker-runtime dotnet
    ```
    
    這個步驟會建立 5 個檔案，其中 `local.settings.json` 會儲存「應用程式組態」設定，預設會被 `.gitignore` 排除在版控之外，要特別注意！
    
    ```
    .vscode\extensions.json
    .gitignore
    demo1.csproj
    host.json
    local.settings.json
    ```
    
    > 這裡的 `host.json` 用來定義 Azure Functions 的執行環境相關組態(Configurations)，而 `local.settings.json` 則是只會存留於本機的組態設定，當你要將 Function App 部署到 Azure App Service 時，這些組態要手動註冊到 App Service 的應用程式組態設定中！
    
2.  啟動 Visual Studio Code 編輯器並初始化開發 Azure Functions 所需的編輯器環境
    
    ![初始化開發 Azure Functions 所需的編輯器環境](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/60dd95e7faaf756a2f8b-139699664-f6883ece-3d8b-47a9-9672-a763a3c93197.png)
    
3.  加入 [Microsoft.Azure.WebJobs.Extensions.SignalRService](https://www.nuget.org/packages/Microsoft.Azure.WebJobs.Extensions.SignalRService) 套件 (NuGet)
    
    ```bash
    dotnet add package Microsoft.Azure.WebJobs.Extensions.SignalRService
    ```
    
4.  加入 `Functions.cs` 程式碼
    
    ```csharp
    using System;
    using System.IO;
    using System.Net.Http;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Azure.WebJobs;
    using Microsoft.Azure.WebJobs.Extensions.Http;
    using Microsoft.Azure.WebJobs.Extensions.SignalRService;
    using Newtonsoft.Json;
    
    namespace CSharp
    {
        public static class Function
        {
            private static HttpClient httpClient = new HttpClient();
    
            [FunctionName("index")]
            public static IActionResult Index([HttpTrigger(AuthorizationLevel.Anonymous)]HttpRequest req, ExecutionContext context)
            {
                var path = Path.Combine(context.FunctionAppDirectory, "content", "index.html");
                return new ContentResult
                {
                    Content = File.ReadAllText(path),
                    ContentType = "text/html",
                };
            }
    
            [FunctionName("negotiate")]
            public static SignalRConnectionInfo Negotiate(
                [HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest req,
                [SignalRConnectionInfo(HubName = "serverlessSample")] SignalRConnectionInfo connectionInfo)
            {
                return connectionInfo;
            }
    
            [FunctionName("broadcast")]
            public static async Task Broadcast([TimerTrigger("*/5 * * * * *")] TimerInfo myTimer,
            [SignalR(HubName = "serverlessSample")] IAsyncCollector<SignalRMessage> signalRMessages)
            {
                var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/azure/azure-signalr");
                request.Headers.UserAgent.ParseAdd("Serverless");
                var response = await httpClient.SendAsync(request);
                var result = JsonConvert.DeserializeObject<GitResult>(await response.Content.ReadAsStringAsync());
                await signalRMessages.AddAsync(
                    new SignalRMessage
                    {
                        Target = "newMessage",
                        Arguments = new[] { $"Current star count of https://github.com/Azure/azure-signalr is: {result.StarCount}" }
                    });
            }
    
            private class GitResult
            {
                [JsonRequired]
                [JsonProperty("stargazers_count")]
                public string StarCount { get; set; }
            }
        }
    }
    ```
    
    > 注意：呼叫 GitHub API 時，在未通過身份驗證的狀態下，一小時只能發出 60 個 Requests 而已喔！你可以用 `curl -I https://api.github.com/repos/azure/azure-signalr` 查出你的目前 IP 還剩餘多少 API 的 RateLimit 呼叫次數(`X-RateLimit-Remaining`)以及下次重置時間(`X-RateLimit-Reset`)。
    
5.  加入 `content/index.html` 預設首頁 HTML 原始碼
    
    ```html
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Azure SignalR Serverless Sample</title>
    </head>
    <body>
    
      <h1>Azure SignalR Serverless Sample</h1>
      <div id="messages"></div>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/3.1.20/signalr.min.js" integrity="sha512-Tmi7k+eXKdibvPJfx/L8uCogEzWFQvC+heO3urDkTr5px1QfpwY1auftPq85zpGG++KbekSpPL7wn6bOS0oJVg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
      <script>
        let messages = document.querySelector('#messages');
        const apiBaseUrl = window.location.origin;
        const connection = new signalR.HubConnectionBuilder()
            .withUrl(apiBaseUrl + '/api')
            .configureLogging(signalR.LogLevel.Information)
            .build();
          connection.on('newMessage', (message) => {
            document.getElementById("messages").innerHTML = message;
          });
    
          connection.start()
            .catch(console.error);
      </script>
    
    </body>
    </html>
    ```
    
    > 關於 `microsoft-signalr` 的 CDN 位址可以從 https://cdnjs.com/libraries/microsoft-signalr/3.1.20 取得。
    
    SignalR 用戶端函式庫的 `.withUrl(apiBaseUrl + '/api')` 會自動找出 `apiBaseUrl + '/api/negotiate'` 進行協商，這個過程會呼叫 `/api/negotiate` 這個 API 並取得 Azure SignalR Service 的服務網址與 Access Token，接著就會讓瀏覽器用戶端直接連接 Azure SignalR Service 服務。
    
6.  更新 `demo1.csproj` 並設定 `content/index.html` 檔案會複製到輸出目錄
    
    ```xml
    <ItemGroup>
      <None Update="content/index.html">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      </None>
    </ItemGroup>
    ```
    
7.  從 `local.settings.json` 加入名為 `AzureSignalRConnectionString` 的組態設定
    
    ```bash
    func settings add AzureSignalRConnectionString "<signalr-connection-string>"
    ```
    
    > 注意：上述命令只會修改 `local.settings.json` 這個檔案，並在該檔案加入名為`AzureSignalRConnectionString` 的應用程式參數，這個參數主要提供給 [SignalR Service bindings for Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-signalr-service?WT.mc_id=DT-MVP-4015686) 之用。
    
8.  在本機啟動 Azure Functions 應用程式
    
    ```bash
    func start
    ```
    
    由於 Azure Function 在本機開發時，預設使用 `7071` 通訊埠，如果你不幸這個 Port 被佔用的話，一樣重開機可能會好。或者你也可以改另一個 Port 進行測試，例如：
    
    ```bash
    func start -p 17071
    ```
    
    ![func start -p 17071](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/91409f326ae0f7db5b5b-139713971-2bcdfc08-20be-4951-9220-e6c5c705dea3.png)
    

### 部署 Azure Functions 應用程式

1.  安裝 [Azure CLI](https://learn.microsoft.com/zh-tw/cli/azure/install-azure-cli-windows?WT.mc_id=DT-MVP-4015686&tabs=azure-cli) 命令列工具
    
    由於部署 Azure Functions 的時候，需要先安裝好 [Azure CLI 命令列工具](https://learn.microsoft.com/zh-tw/cli/azure/install-azure-cli-windows?WT.mc_id=DT-MVP-4015686&tabs=azure-cli)，並在 `az login` 登入後選好預設**訂用帳戶** (Subscription)
    
    ```bash
    choco install azure-cli -y
    
    az login --use-device-code
    
    az account set -s "Microsoft Azure Sponsorship"
    ```
    
2.  建立 Function App
    
    你可以透過 Azure Portal 先建立好 Serverless 的 Function App，或是直接透過 Azure CLI 建立 Function App 服務。
    
    以下命令先建立「資源群組」，再建立「儲存體帳戶」，再建立名為 `mydemo1func` 的 Serverless Function App：
    
    ```bash
    az group create -n demo1 -l japaneast
    az storage account create -n myfuncstor1 -g demo1 -l japaneast --sku Standard_LRS
    az functionapp create -n mydemo1func -s myfuncstor1 -g demo1 -c japaneast --functions-version 3
    ```
    
    > 透過 Azure CLI 建立 Function App 的方法可以參考 [Create a function app for serverless code execution](https://learn.microsoft.com/en-us/azure/azure-functions/scripts/functions-cli-create-serverless?WT.mc_id=DT-MVP-4015686) 文件，如果要建立 Premium plan 的 Function App 可以參考 [Create a function app in a Premium plan - Azure CLI](https://learn.microsoft.com/en-us/azure/azure-functions/scripts/functions-cli-create-premium-plan?WT.mc_id=DT-MVP-4015686) 文件。
    
3.  將現有 Function App 發行到 Azure Functions 中
    
    ```bash
    func azure functionapp publish mydemo1func --publish-local-settings
    ```
    
    加入 `--publish-local-settings` 參數可以將你的 `local.settings.json` 組態設定全部自動同步到 Azure 上，執行過程如果看到 `Would you like to overwrite value in azure? [yes/no/show]` 提示，請記得輸入 `no` 才對，否則你的 `AzureWebJobsStorage` 組態會被設定為本機開發環境的 Storage 連接字串！
    

### 相關連結

-   [Quickstart: Create an App showing GitHub star count with Azure Functions and SignalR Service using C#](https://learn.microsoft.com/en-us/azure/azure-signalr/signalr-quickstart-azure-functions-csharp)
-   [aspnet/AzureSignalR-samples: Code samples for Azure SignalR](https://github.com/aspnet/AzureSignalR-samples)
-   [Azure SignalR Service documentation](https://learn.microsoft.com/en-us/azure/azure-signalr/)
-   [使用 Azure CLI 快速建立 Azure Web App 測試網站、資料庫與儲存體帳戶](/post/2021/09/09/Quickly-create-Azure-Web-App-SQL-Database-Storage-account)
-   [ASP.NET Core SignalR JavaScript client](https://learn.microsoft.com/en-us/aspnet/core/signalr/javascript-client?view=aspnetcore-5.0&WT.mc_id=DT-MVP-4015686#connect-to-a-hub)
-   [@microsoft/signalr package | Microsoft Docs](https://learn.microsoft.com/en-us/javascript/api/@microsoft/signalr/?view=signalr-js-latest&WT.mc_id=DT-MVP-4015686)
    -   [HubConnectionBuilder class](https://learn.microsoft.com/en-us/javascript/api/@microsoft/signalr/hubconnectionbuilder?view=signalr-js-latest&WT.mc_id=DT-MVP-4015686)
    -   [IHttpConnectionOptions interface](https://learn.microsoft.com/en-us/javascript/api/@microsoft/signalr/ihttpconnectionoptions?view=signalr-js-latest&WT.mc_id=DT-MVP-4015686)
    -   [HttpClient class](https://learn.microsoft.com/en-us/javascript/api/@microsoft/signalr/httpclient?view=signalr-js-latest)
