⚡️

HttpリクエストがトリガーのAzureFunctionを作成し、結果をSignalRでクライアントへ通知する

2022/07/07に公開約13,600字

概要

開発チームの大野です。最近はAzure Functionsを利用した業務システムを開発しています。
今回はAzure FunctionsとAzure SignalRサービスを利用したリアルタイム通信の実装方法を紹介します。
Azure Functionsのうち、「Httpトリガー」、SignalRサービスの「入力バインド」「出力バインド」を利用します。

利用する技術

  • .NET 6
    • Azure Functions
      • Httpトリガー
    • Azure SignalRサービス
      • 入力バインド
      • 出力バインド
  • Vue.js(ver3)
    • TypeScript
  • Azure SignalR Local Emulator(ローカル環境テスト用)

Azure Functionsプロジェクトを作成する

Visual Studio 2022を使用し、最初にAzure Functionsプロジェクトを作成します。2つ見つかる場合がありますが、⚡マークの方です。
特に変更する必要は無いのでデフォルトで作成しますが、必要に応じて設定してください(「場所」など)。

picture 1

picture 3

picture 4

プロジェクトが作成後、ソリューション内のファイルを確認します。
デフォルトでhttpのエントリーポイントを持つFunction(Function1.cs)が作成されています。

Function1.cs
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace HttpToSignalRApp
{
    public static class Function1
    {
        [FunctionName("Function1")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            log.LogInformation("C# HTTP trigger function processed a request.");

            string name = req.Query["name"];// nameパラメーターをクエリーから取得する

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            dynamic data = JsonConvert.DeserializeObject(requestBody);
            name = name ?? data?.name;

            string responseMessage = string.IsNullOrEmpty(name)
                ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
                : $"Hello, {name}. This HTTP triggered function executed successfully.";// nameがパラメーターで設定されていたら表示する文面を変更される

            return new OkObjectResult(responseMessage);
        }
    }
}

そのまま起動します。

picture 5

表示されたURLにアクセスします。今回はhttp://localhost:7022/api/Function1ですが、ポートの部分は各環境で変わる可能性があります。

picture 6

メッセージが表示され、Httpトリガーを持つAzure Functionsが動作していることが確認出来ます。
ちなみに、nameパラメーターを渡すとメッセージが変化します。http://localhost:7022/api/Function1?name=taro

picture 14

Azure SignalR Local Emulatorの導入

実際のコードを書く前に、ローカルでSignalRサービスの動作確認をするために必要な「Azure SignalR Local Emulator」を導入します。
azure-signalr/emulator.md at dev · Azure/azure-signalr

以下のコマンドでインストールします。
dotnet tool install -g Microsoft.Azure.SignalR.Emulator --version 1.0.0-preview1-10809

インストールが終了したら、設定ファイルを作成する以下のコマンドを実行します。
今回はプロジェクトフォルダの下に「signalr_emulator」を作成し、そこで実行しました。
asrs-emulator upstream init

実行したディレクトリに以下のjsonファイルが作成されます。

settings.json
{
  "UpstreamSettings": {
    "Templates": [
      {
        "UrlTemplate": "http://localhost:7071/runtime/webhooks/signalr",
        "EventPattern": "*",
        "HubPattern": "*",
        "CategoryPattern": "*"
      }
    ]
  }
}

設定を作成したディレクトリで以下のコマンドを実行してエミュレーターを起動します。
asrs-emulator start

picture 13

接続文字列(ConnectionString)が表示されるのでメモします。
今回は以下の文字列でした。

Endpoint=http://localhost;Port=8888;AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0;

プロジェクトにSignalRエントリーポイントを追加

初めに「Microsoft.Azure.WebJobs.Extensions.SignalRService」をインストールします(2022/07/01時点の最新バージョンは1.8)。
ツール>Nugetパッケージマネージャー>ソリューションのNugetパッケージの管理>参照タブでSignalRServiceを検索、対象プロジェクトを指定してインストールします。

picture 11

デフォルトで作成されたFunction.csからRun関数を削除し、negociate関数とHttpPostを受け取る関数を用意します。

Function1.cs
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;
using Newtonsoft.Json;
using System.IO;
using System.Threading.Tasks;

namespace HttpToSignalRApp;

public static class Function1
{
    /// <summary>
    /// SignalR接続を開始するためのエンドポイント
    /// HubName : どのHubに接続するか指定
    /// ConnectionStringSetting : 接続先の指定
    /// UserId : クライアント識別子をヘッダーで受け取る時の識別子を指定(これを設定しないと個別メッセージの送信が出来ない)
    /// 今回はリクエストヘッダーのうち、「x-ms-signalr-userid」の値をクライアント識別子とする
    /// </summary>
    [FunctionName("negotiate")]
    public static SignalRConnectionInfo Negotiate(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "negotiate")] HttpRequest req,
        [SignalRConnectionInfo(HubName = "MyHub", ConnectionStringSetting = "AzureSignalRConnectionString", UserId = "{headers.x-ms-signalr-userid}")] SignalRConnectionInfo connectionInfo)
    {
        return connectionInfo;
    }

    /// <summary>
    /// Httpリクエストから送信するためのパラメーターを受け取り、
    /// SignalR経由でクライアントに通知するためのエンドポイント
    /// req経由でリクエストを受け取り、messsage.AddAsync()でsignalRクライアントへ送信する
    /// HubName : どのHubに接続するか指定(Negociate関数と同じものを設定する)
    /// ConnectionStringSetting : 接続先の指定(Negociate関数と同じものを設定する)
    /// </summary>
    /// <param name="req">HttpTriggerの入力バインド</param>
    /// <param name="message">SignalRの出力バインド</param>
    /// <returns></returns>
    [FunctionName(nameof(HttpToSignalR))]
    public static async Task HttpToSignalR(
        [HttpTrigger(AuthorizationLevel.Anonymous,"post", Route = null)] HttpRequest req,
        [SignalR(HubName = "MyHub", ConnectionStringSetting = "AzureSignalRConnectionString")] IAsyncCollector<SignalRMessage> message)
    {
        // 受け取ったデータをデシリアライズし、SignalRでクライアントへ送信する
        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var data = JsonConvert.DeserializeObject<RequestBody>(requestBody);
        await message.AddAsync(new SignalRMessage
        {
            Target = nameof(MessageType.Comment),
            Arguments = new[] { data },
            UserId = data.UserId
        });
    }
}

/// <summary>
/// HttpPostとSignalR経由で通知する際のデータフォーマット
/// </summary>
public class RequestBody
{
    [JsonProperty("comment")]
    public string Comment { get; set; }

    [JsonProperty("userId")]
    public string UserId { get; set; }
}

/// <summary>
/// メッセージタイプ
/// フロントが受け取る際、このタイプによって受け取る関数を制御可能(今回は一つだけ)
/// </summary>
public enum MessageType
{
     Comment
}

※注意点

  • 「HubName」や「UserId」は直接指定が可能、「ConnectionStringSetting」は設定から読み込む際の識別文字列なので、接続文字列をそのまま入力できない
    • 設定例
      • ローカルで実行している時:local.setting.jsonに追加
      • publish(発行)した時:関数アプリ>該当する関数を選択>設定>構成>アプリケーション設定>AzureSignalRConnectionStringを設定に追加する
        • Azure SignalR Serviceを利用する場合、Azure SignalR Serviceを作成後、Azure SignalR Serviceの設定>キー>キーの値を設定する
  • FunctionNameか、HttpTriggerのRoute(両方指定した場合、Routeが優先される)を必ず「negotiate」にする
  • 別名にするとフロントの「@microsoft/signalr」クライアントから接続できなくなる

最後にメモしておいたエンドポイントの接続文字列を「AzureSignalRConnectionString」で設定します。

local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AzureSignalRConnectionString": "Endpoint=http://localhost;Port=8888;AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0;"
  }
}

フロントエンドの実装

今回はVueを利用しますが、通信部分の実装ができればどのフレームワークでも利用可能です。

vue createコマンドを実行し、シンプルなプロジェクトを作成します。

$ vue create http_to_signalr
? Please pick a preset: (Use arrow keys) > Manually select features
? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed) > Babel,TypeScript,Linter / Formatter
? Choose a version of Vue.js that you want to start the project with (Use arrow keys) > 3.x
? Use class-style component syntax? (y/N) > N
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n)  > Y
? Pick a linter / formatter config: (Use arrow keys) > ESLint with error prevention only
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed) > Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys) > In dedicated config files
? Save this as a preset for future projects? (y/N) > N

@microsoft/signalrをインストール

以下のコマンドで「@microsoft/signalr」をインストールします。
npm i @microsoft/signalr -S

フロントのコード

今回は接続確認のためだけなので「App.vue」を直接編集し、実装します。

App.vue
<template>
  <div>
    <label>UserId:</label>
    <input type="text" v-model="userId">
    <button v-if="!isConnected" @click="connect">接続する</button>
    <label v-else>接続済み</label>
  </div>
  <div>
    <label>コメント:</label>
    <input type="text" v-model="comment" />
    <label>送信先UserId:</label>
    <input type="text" v-model="targetUserId" />
    <button @click="sendMessage">送信</button>
  </div>
  <div>
    <p>メッセージ</p>
    <p v-for="( message, index) in messages" :key="index">{{ message.comment }}</p>
  </div>
</template>

<script setup  lang="ts">
import { Ref, ref } from 'vue';
import * as signalR from "@microsoft/signalr";
// 「Function1.cs」の「RequestBody」に対応する型
type Message = {
  comment: string;
  userId: string;
};
// 自分のクライアント識別子
let userId = ref("userid_sample");
// 送信先のクライアント識別子
let targetUserId = ref("userid_sample");
// SignalR接続状態
let isConnected = ref(false);
// SignalR接続
let connection: signalR.HubConnection | null = null;
/**
 * SignalRへ接続を開始する
 */
const connect = () => {
  {
    connection = new signalR.HubConnectionBuilder()
      .withUrl("http://localhost:7022/api", { // negociate関数のURLを指定する(negociateより前の部分)
        headers: {
          "x-ms-signalr-userid": userId.value,// ここで自分の識別子を指定する
        },
      })
      .configureLogging(signalR.LogLevel.Information)
      .build();
    connection
      .start()
      .then(() => {
        isConnected.value = true;
      })
      .catch(console.error);
    connection.on("Comment", (message: Message) => { // 「Comment」イベントが来たら受け取る関数
      messages.value.unshift(message);
    });
  }
};
// 送信するコメント内容
let comment = ref("テストコメント");
/**
 * Httpトリガーのエンドポイントへメッセージと送信先を指定して送信する
 */
const sendMessage = async () => {
  await fetch("http://localhost:7022/api/HttpToSignalR", {// HttpToSignalR関数のURLを指定する
    method: "POST",
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ comment: comment.value, userId: targetUserId.value })
  });
}
// 受け取ったコメントを保持、表示するための配列
let messages: Ref<Message[]> = ref([]);
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

開発サーバーを起動してサーバーのURLを確認します。

$ yarn serve
yarn run v1.22.5
$ vue-cli-service serve
 INFO  Starting development server...


 DONE  Compiled successfully in 15777ms                                                                                          18:27:43


  App running at:
  - Local:   http://localhost:8082/
  - Network: http://192.168.1.158:8082/

  Note that the development build is not optimized.
  To create a production build, run npm run build.

ブラウザでURLを開くとアプリが表示されます。

picture 17

URLを確認したらAzure FunctionsのHttpエンドポイントのCORS対応のため、プロジェクトの「local.settings.json」に「Host」を追記します。「CORS」にフロントのURLを入れます。

local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AzureSignalRConnectionString": "Endpoint=http://localhost;Port=8888;AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0;"
  },
  "Host": {
    "CORS": "http://localhost:8082",
    "CORSCredentials": true
  }
}

ここまでで動作させる準備か完了しました。

動作確認

まず、Azure Functionを起動します。
picture 16

アプリ上の「接続ボタン」でSignalRへ接続し、「送信ボタン」でメッセージを送信します。
送信先が自分なので、下部に送信したメッセージが表示されます。
picture 19

もう1つブラウザを開いてUserIdを別の文字列に変更して接続します。
宛先が自分のメッセージだけが表示されます。
※複数のブラウザ(タブ)から同じUserIdで接続した場合、全ての画面で表示されます。

picture 20

おわりに

HttpリクエストをAzure Functionで受け取り、SignalR経由でクライアントへ通知するコードを紹介しました。
今回はHttpトリガー→SignalRサービスのシンプルな経路で、受け取ったデータを返すシンプルな構成を紹介しました。
一方、Azureで提供されている各種サービスや、「トリガー、入力バインド、出力バインド」を組み合わせれば様々な情報をクライアントへ通知出来ます。
こちらの記事で紹介している内容と組み合わせれば「CosmosDBエミュレーターの変更フィードの通知」を「SignalR」経由で行う事も可能です。

https://zenn.dev/jtechjapan/articles/f5cf05dc198033

参考資料

https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-triggers-bindings?tabs=csharp

https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-signalr-service?tabs=in-process&pivots=programming-language-csharp

Discussion

ログインするとコメントできます