Help us understand the problem. What is going on with this article?

Azure の無料枠のあるサーバーレス系サービスだけで WebSocket (SignalR)

今回の登場人物

3 つとも、それぞれ無料枠があります。今回は、このサービスを使って以下のようなものを作ってみようと思います。

image.png

一番左端と右端は、適当なコンソールアプリでも作って、適当に誤魔化していこうと思います。本番は、IoT デバイスやらスマホやらパソコンやら別サービスやらでやる感じを想定。

開発言語

ここ最近は TypeScript で 2 記事ほど書きましたが、今回は C# で行こうと思います。開発環境は Visual Studio 2019 で。VS Code でもいけます。

リソースの作成

では、Azure ポータルを開いて Function App, Cosmso DB, SignalR Service の 3 つのリソースを作ります。Function App は従量課金プラン(コンサンプションプラン)で 100 万回の呼び出しが無料、Cosmos DB も Free tier を適用することで 5GB のストレージと一定の性能まで無料で使えます、SignalR Service は Free プランがあります。

Cosmos DB のキーから接続文字列をコピーしておきましょう。SignalR Service も作り終わったら Keys から接続文字列をコピーしておきましょう。

プロジェクトの作成

では Azure Functions のプロジェクトを作ります。今回は ServerlessPushApp で作りました。Cosmos DB と SignalR Service を使うので対応するパッケージを NuGet から追加します。追加するパッケージは以下の 2 つになります。

そして、local.settings.json というファイルに Cosmos DB と SignalR Service の接続文字列を追加します。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "DefaultCosmosDB": "ポータルで取得した Cosmos DB の接続文字列",
    "AzureSignalRConnectionString": "ポータルで取得した SignalR Service の接続文字列"
  }
}

このファイルはプロジェクトテンプレートに含まれる .gitignore でリポジトリーには追加されないようになっていて、ローカル開発時に一番手軽に設定を書ける場所になります。
local.settings.json 以外にもリポジトリーに入れたくないような情報を管理する方法はいくつか提供されているので、自分に合いそうなものを選んで使うといいと思います。

SignalR の接続文字列の設定名は AzureSignalRConnectionString にしておくと、デフォルトでこの名前が使われるので、後々 Azure Functions の関数に指定するバインドの設定が楽になります。

Cosmos DB へデータを追加する関数の作成

では、Cosmos DB へデータを追加する関数を作っていきます。とりあえずデータは以下のような JSON を想定します。

[
    { "amount": 100, "sensorId": "xxx" },
    { "amount": 1, "sensorId": "yyy" },
    { "amount": 44, "sensorId": "zzz" },
    { "amount": 32, "sensorId": "xxx" },
    { "amount": 65, "sensorId": "yyy" },
    { "amount": 29, "sensorId": "zzz" },
    { "amount": 47, "sensorId": "xxx" },
    { "amount": 23, "sensorId": "yyy" },
    { "amount": 99, "sensorId": "xxx" },
    { "amount": 120, "sensorId": "zzz" }
]

これが HTTP リクエストの Body に入ってきていて、配列の要素を 1 つずつ Cosmos DB に追加していきます。では、この JSON のデータを格納するためのクラスを定義します。

using Newtonsoft.Json;

namespace ServerlessPushApp
{
    public class Data
    {
        [JsonProperty("id")]
        public string Id { get; set; }
        [JsonProperty("amount")]
        public int Amount { get; set; }
        [JsonProperty("sensorId")]
        public string SensorId { get; set; }
    }
}

そして、データを追加するための関数を定義します。Azure Functions では HttpTrigger を使うと HTTP のリクエストが来たら動く関数が定義できるので、それと Cosmos DB への出力バインドを組み合わせて、以下のようなコードで実現できます。

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

namespace ServerlessPushApp
{
    public class ImportDataFunction
    {
        [FunctionName("ImportData")]
        public static async Task<IActionResult> ImportData(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = "data")] HttpRequest req,
            [CosmosDB("serverlesspushdb", "data", CollectionThroughput = 400,
                ConnectionStringSetting = "DefaultCosmosDB",
                CreateIfNotExists = true)] IAsyncCollector<Data> output,
            ILogger log)
        {
            var dataArray = JsonConvert.DeserializeObject<Data[]>(await req.ReadAsStringAsync());
            log.LogInformation($"{dataArray.Length} items received.");
            foreach (var data in dataArray)
            {
                await output.AddAsync(data);
            }

            return new AcceptedResult();
        }
    }
}

HttpTrigger と Cosmos DB の出力バインドについては以下の公式ドキュメントが参考になります。

では、実行してみましょう。

デバッグ実行すると、コマンドプロンプトが起動してきて、そこでローカルで動いてくれます。デバッグとかも普通に出来るのでローカル開発は正義だなぁといつも思います。

以下のように関数を起動するための URL が表示されます。

image.png

適当なツールで叩いてみましょう。私は最近ずっと VS Code の REST Client 拡張機能を使ってます。

以下のようなリクエストを送ると…

POST http://localhost:7071/api/data
Content-Type: applicatoin/json

[
    { "amount": 100, "sensorId": "xxx" },
    { "amount": 1, "sensorId": "yyy" },
    { "amount": 44, "sensorId": "zzz" },
    { "amount": 32, "sensorId": "xxx" },
    { "amount": 65, "sensorId": "yyy" },
    { "amount": 29, "sensorId": "zzz" },
    { "amount": 47, "sensorId": "xxx" },
    { "amount": 23, "sensorId": "yyy" },
    { "amount": 99, "sensorId": "xxx" },
    { "amount": 120, "sensorId": "zzz" }
]

無事 Accepted が返ってきました。

image.png

Cosmos DB のデータエクスプローラーでデータを覗いてみると、ちゃんと出きてますね。

image.png

続けて SignalR 対応していきます。まず、Cosmos DB の data コレクションに追加・更新があったら動く関数を作ります。
新しクラスを作って以下のようにします。 CosmosDbTrigger が Cosmos DB のデータの変更によって起動する関数のトリガーです。ここに、先ほどデータを追加するときに指定した data コレクションを対象にするように設定を追加しています。

using Microsoft.Azure.Documents;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ServerlessPushApp
{
    public class AlertFunction
    {
        [FunctionName("Alert")]
        public static async Task Alert(
            [CosmosDBTrigger("serverlesspushdb", 
                "data", 
                ConnectionStringSetting = "DefaultCosmosDB", 
                CreateLeaseCollectionIfNotExists = true)] IReadOnlyList<Document> documents,
            ILogger log)
        {
            log.LogInformation($"{documents.Count} items updated. The first data's id is {documents.First().Id}.");
        }
    }
}

まだ SignalR とは繋いでいませんが、データの追加で発火するか試してみましょう。実行して適当にデータを Cosmos DB に突っ込み続けてるとそのうち Alert 関数が動いたログが以下のように表示されます。

image.png

あとは、SignalR 対応です。SignalR に出力するバインドを先ほどの Alert 関数に追加します。そして、100以上の値を異常値として SignalR に出力します。以下のようになりました。

using Microsoft.Azure.Documents;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ServerlessPushApp
{
    public class AlertFunction
    {
        [FunctionName("Alert")]
        public static async Task Alert(
            [CosmosDBTrigger("serverlesspushdb", 
                "data", 
                ConnectionStringSetting = "DefaultCosmosDB", 
                CreateLeaseCollectionIfNotExists = true)] IReadOnlyList<Document> documents,
            [SignalR(HubName = "alert")] IAsyncCollector<SignalRMessage> messages,
            ILogger log)
        {
            log.LogInformation($"{documents.Count} items updated. The first data's id is {documents.First().Id}.");
            var alertTargets = documents.Select(x => (Data)(dynamic)x)
                .Where(x => x.Amount >= 100)
                .ToArray();
            if (alertTargets.Any())
            {
                log.LogInformation($"{alertTargets.Length} items are outlier.");
                await messages.AddAsync(new SignalRMessage
                {
                    Target = "alert",
                    Arguments = new object[] { alertTargets },
                });
            }
        }
    }
}

実行して適当にデータを追加してみると、ちゃんとそれっぽい数の異常値が検出されてますね。

image.png

最後に SignalR Service にクライアントから繋ぐための情報を返す関数を作ります。ここに対して SignalR のクライアントアプリが最初にアクセスしに来ます。

関数の URL は固定で negotiate になります。
個の関数は SignalRConnectionInfo を返すだけの関数になります。

using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;

namespace ServerlessPushApp
{
    public class SignalRFunction
    {
        [FunctionName("Negotiate")]
        public static SignalRConnectionInfo Negotiate(
            [HttpTrigger(AuthorizationLevel.Function, Route = "negotiate")] HttpRequest req,
            [SignalRConnectionInfo(HubName = "alert")]SignalRConnectionInfo signalRConnectionInfo) =>
            signalRConnectionInfo;
    }
}

これで、この negotiate 関数で alert という名前の Hub に繋ぐための情報が返ります。alert という名前の Hub は AlertFunction でも同じ名前を使ってるので、ここに繋ぎに来たクライアントが AlertFunction で SignalR に出力したメッセージを受けることが出来るっていう寸法です。

クライアントアプリは、適当に .NET Core のコンソールアプリを作って以下の 2 つの NuGet パッケージを追加します。

  • Microsoft.AspNetCore.SignalR.Client
  • Nwetonsoft.Json

そして、以下のようなコードを書いて完成。とりあえず受け取ったものを標準出力にメッセージ出すだけにしました。

using Microsoft.AspNetCore.SignalR.Client;
using Newtonsoft.Json;
using System;
using System.Threading.Tasks;

namespace ServerlessPushApp.Client
{
    class Program
    {
        static async Task Main(string[] args)
        {
            Console.WriteLine("何かキーを押すとサーバーに接続します。");
            Console.ReadKey();
            var hubConnection = new HubConnectionBuilder()
                .WithUrl("http://localhost:7071/api") // この URL に追加して /negotiate に自動で行く
                .WithAutomaticReconnect()
                .Build();
            hubConnection.On<Data[]>("alert", x =>
            {
                Console.WriteLine("異常値がありました");
                foreach (var d in x)
                {
                    Console.WriteLine($"  {d.SensorId}: {d.Amount}");
                }
            });
            await hubConnection.StartAsync();
            Console.WriteLine("接続しました。何かキーを押すとプログラムが終了します。");
            Console.ReadKey();
        }
    }

    public class Data
    {
        [JsonProperty("id")]
        public string Id { get; set; }
        [JsonProperty("amount")]
        public int Amount { get; set; }
        [JsonProperty("sensorId")]
        public string SensorId { get; set; }
    }
}

では、Azure Functions とコンソールアプリを同時に実行して動かしてみましょう。適当に Visual Studio Code からデータを追加していきます。
前と同じ下記のデータを追加すると…

POST http://localhost:7071/api/data
Content-Type: applicatoin/json

[
    { "amount": 100, "sensorId": "xxx" },
    { "amount": 1, "sensorId": "yyy" },
    { "amount": 44, "sensorId": "zzz" },
    { "amount": 32, "sensorId": "xxx" },
    { "amount": 65, "sensorId": "yyy" },
    { "amount": 29, "sensorId": "zzz" },
    { "amount": 47, "sensorId": "xxx" },
    { "amount": 23, "sensorId": "yyy" },
    { "amount": 99, "sensorId": "xxx" },
    { "amount": 120, "sensorId": "zzz" }
]

クライアントのコンソールにちゃんと異常値がすぐに表示されます。

image.png

SignalR のクライアントは C# 以外にも Java や JavaScript 等があるので、対応言語の中で好きなやつでクライアント側は作れます。対応言語などについては以下のドキュメントに記載があります。

Azure にデプロイしてみよう

Azure Functions は色々な方法でデプロイできます。Visual Studio を使ってる場合は、一番簡単なのは Visual Studio からやることです。本番環境では各種 CI/CD 系のサービスやツールでデプロイしましょう。

プロジェクト右クリックメニューからの「発行」で始まるウィザードに従ってればデプロイされます。デプロイされたらポータルを開くと関数の一覧に関数が表示されます。

image.png

構成を選択してアプリケーション設定に local.settings.json で設定したのと同じ DefaultCosmosDBAzureSignalRConnectionString を追加します。

image.png

ポータルから Negotiate 関数の URL をゲットして /negotiate を削除してクライアントのコードの URL を置き換えましょう。

using Microsoft.AspNetCore.SignalR.Client;
using Newtonsoft.Json;
using System;
using System.Threading.Tasks;

namespace ServerlessPushApp.Client
{
    class Program
    {
        static async Task Main(string[] args)
        {
            Console.WriteLine("何かキーを押すとサーバーに接続します。");
            Console.ReadKey();
            var hubConnection = new HubConnectionBuilder()
                // /negotiate は自動で追加されるので消してね
                .WithUrl(new Uri("https://freeokazuki.azurewebsites.net/api?code=ZmOaAaEWOcy0eoQ13J1hLBLGPyVPFNnFSUbxMYZCsxzN8xmeYeZsmw=="))
                .WithAutomaticReconnect()
                .Build();
            hubConnection.On<Data[]>("alert", x =>
            {
                Console.WriteLine("異常値がありました");
                foreach (var d in x)
                {
                    Console.WriteLine($"  {d.SensorId}: {d.Amount}");
                }
            });
            await hubConnection.StartAsync();
            Console.WriteLine("接続しました。何かキーを押すとプログラムが終了します。");
            Console.ReadKey();
        }
    }

    public class Data
    {
        [JsonProperty("id")]
        public string Id { get; set; }
        [JsonProperty("amount")]
        public int Amount { get; set; }
        [JsonProperty("sensorId")]
        public string SensorId { get; set; }
    }
}

クライアントを実行すると接続が成功して待機を始めます。そして、ポータルから ImportData 関数の URL を取得して Visual Studio Code から以下のようなリクエストを送ります。

POST https://freeokazuki.azurewebsites.net/api/data?code=aqgH/RXjsZoPrRB7pknD2A7Zg3JfnZnazmnHmuc6MP5c9EaRlz1Mzw==
Content-Type: applicatoin/json

[
    { "amount": 100, "sensorId": "xxx" },
    { "amount": 1, "sensorId": "yyy" },
    { "amount": 44, "sensorId": "zzz" },
    { "amount": 32, "sensorId": "xxx" },
    { "amount": 65, "sensorId": "yyy" },
    { "amount": 29, "sensorId": "zzz" },
    { "amount": 47, "sensorId": "xxx" },
    { "amount": 23, "sensorId": "yyy" },
    { "amount": 99, "sensorId": "xxx" },
    { "amount": 120, "sensorId": "zzz" }
]

そうすると、クライアントに以下のように表示されます。ばっちりですね。

image.png

まとめ

ということで、無料枠のあるサーバーレス系サービスだけを使ってクライアントにプッシュで配信するような感じのものを作ってみました。結構いい感じに作れるのじゃないかなぁと思います。

今回のソースコードは以下に置いています。Azure Functions の関数に繋ぐためのキーとか入っていますが、Azure 上のリソースは全部消してあるので気にしないでください。

https://github.com/runceel/ServerlessPushApp

okazuki
日本マイクロソフトでサポート系のエンジニアとして働いています。 好きな言語は C# と TypeScript。メインの興味領域は Windows クライアントアプリ開発と Xamarin によるモバイルアプリ開発。その延長として API を作るための Azure の PaaS 系サービスが好きです。 SPA はたしなむ程度に。 お約束ですが、ここでの発言は個人の見解になります。
https://blog.okazuki.jp
microsoft
マイクロソフトのメンバーが最新の技術情報をお届けします。Twitterアカウント(@msdevjp)やYouTubeチャンネル「クラウドデベロッパーちゃんねる」も運用中です。
https://aka.ms/MSFT-Docs-JPN
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした