放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

Azure Static Web Apps にデプロイした Blazor WebAssembly と Azure Functions で Azure Cosmos DB とやりとりする(1)

今日も今日とて Blazor やれるとこまで頑張ろう!!!それにしてもタイトルがどんどん長くなっていくな…ラノベかな?

Azure Static Web Apps にデプロイした Azure Functions で Azure Cosmos DB とやりとりする

さて前回。Blazor WebAssembly と Azure Functions を Azure Static Web Apps で公開するというのをやってみました。それの発展形として Azure Function で投げたリクエストを Azure Cosmos DB というデータベースに保存したり呼び出したりするのをやってみようと思います。具体的には燻製器内の温度などを読み書きさせます。

開発環境

GitHub

何か参考になれば幸い

github.com

Azure Cosmos DB とは

azure.microsoft.com

Azure Cosmos DB とは Azure サービスの一つでフルマネージドの NoSQL データベースサービスです。フルマネージドというのはいわゆるサーバーレスで管理やメンテなどをあまり必要としないサービスという事で良いと思いますが、NoSQL というのは聞きなれない言葉ですね。

NoSQL データベースとは

MySQL などのリレーショナルデータベース(RDB)とは違い、データを Json (や XML) の形式で保存してしまうタイプのデータベース。Json 形式なので子情報などもまとめて保存でき、汎用性が高く、速度も早い。半面、リレーショナルでないということは、データの整合性に気を付けないとデータベースとして破綻してしまう可能性も秘めていると思われます。恐らくそれほど複雑でないデータに向いた形式なんじゃないかな?なお、名前からしSQL 文からおさらばできそうな感じであるが、NoSQL は Not Only SQL らしく、普通に SQL 文が使える様子。

Azure Cosmos DB の準備

基本的には以下を参考に作成できますので詳細は割愛。ただ悩んだポイントもあるのでそこだけ説明します。

docs.microsoft.com

データベースとコンテナーを作成する

Data Explorer から New Container を選択しデータベースとコンテナーを作成します。この際の Database id と Container id はDBにアクセスする際に必要になりますので控えておきます。今回の例では Database id = kunsei-iot-db Container id = kunsei-iot-container としました。

f:id:roamschemer:20210714004505p:plain

パーティションキー

同じく New Container からコンテナーを作成する際に Paartition key というものの指定が求められます。このパーティションキー(というかパーティション)の概念はかなり奥が深そうなのでここでは言及しませんが、とりあえずこのコンテナーのグループ分け用カラムみたいな使い方をすれば良さそうです。今回は /Name を指定しました。

f:id:roamschemer:20210714010133p:plain

初期設定

プロジェクトを作る

Apiプロジェクト(Azure Functions用)、Clientプロジェクト(Blazor WebAssembly用)、Dataプロジェクト(共通クラス用)を作成しています。詳細は前のエントリーを参照してください。

www.gunshi.info

Microsoft.Azure.WebJobs.Extensions.CosmosDB を入れる

Nuget から Microsoft.Azure.WebJobs.Extensions.CosmosDB を Api プロジェクトにインストールします。

Dataプロジェクトに共通クラスを作成する

このクラスで Azure Functions とやり取りします。

    using System;

    namespace Data {
        public class TempData {
            /// <summary>機器名</summary>
            public string Name { get; set; }
            /// <summary>温度(℃)</summary>
            public double Temp { get; set; }
            /// <summary>燻煙が上がっている</summary>
            public bool HasSmoke { get; set; }
            /// <summary>電熱器がON</summary>
            public bool HasSwitchOn { get; set; }
            /// <summary>日時</summary>
            public DateTime DateTime { get; set; }
        }
    }

Azure Functions の Httpトリガーを準備する

Apiプロジェクトを右クリックから 追加 → 新しい Azure 関数 → ファイル名指定 → Http trigger で追加できます。サンプルでは TempDataFunction.cs を作成しました。

Azure Functions の動作定義

docs.microsoft.com

Azure Functions から Cosmos DB にアクセスできるようにするには Azure Functions の定義を司っている host.json というファイルを書き換える必要があります。

{
    "version": "2.0",
    "extensions": {
        "cosmosDB": {
            "connectionMode": "Gateway",
            "protocol": "Https",
            "leaseOptions": {
                "leasePrefix": "prefix1"
            }
        }
    }
}

API アプリケーション設定

docs.microsoft.com

今回はローカルではローカルに準備したDBにアクセスして開発を行い、デプロイ後は Azure 上の DB にアクセスさせて運用したいので、その為のDB接続文字列は別にしておく必要があります。このようにローカルとデプロイ先で変数の内容を変えたいという場合、ローカル起動時は local.settings.json を参照し、デプロイ先では Azure ポータル で指定した内容を参照するというようなことが Azure Static Web Apps ではできるようになっています。なお、local.settings.json には重要な情報を含む場合が多いため、GitHub で公開しないようにしてください。私の公開プロジェクトにも当然含まれていませんのでご注意を。

{
    "IsEncrypted": false,
    "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
        "FUNCTIONS_WORKER_RUNTIME": "dotnet",
        "CosmosDbConnectionString": "{DB接続文字列}"
    }
}

なお、接続文字列はポータルサイトのキーという項目のところにあります。

f:id:roamschemer:20210714012555p:plain

Azure Functions で Cosmos DB に書き込む

docs.microsoft.com

下準備が終わりましたのでやっていきましょう。まずは書き込みから。TempDataFunction クラスを以下のようにします。

using System;
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 Data;
using System.IO;
using Newtonsoft.Json;

namespace Api {
    public static class TempDataFunction {
        [FunctionName("temp")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            [CosmosDB(
                databaseName: "kunsei-iot-db", //Database id
                collectionName: "kunsei-iot-container", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString")]
            IAsyncCollector<TempData> documentsOut,
            ILogger log) {
            log.LogInformation("C# HTTP trigger function processed a request.");

            //requestBody を取得する
            string requestBody;
            using (StreamReader streamReader = new StreamReader(req.Body)) {
                requestBody = await streamReader.ReadToEndAsync();
            }
            //requestBodyをTempData型にデシリアライズ
            var tempData = JsonConvert.DeserializeObject<TempData>(requestBody);
            //現在時刻を代入
            tempData.DateTime = DateTime.Now;
            //DBへ出力
            await documentsOut.AddAsync(tempData);
            //responseを返す
            return new OkObjectResult(tempData);
        }
    }
}

コメントに描いた通りの挙動をします。Visual Studio Code拡張機能 REST Client で以下のようなデータを POST してみましょう。

### POST
POST http://localhost:7071/api/temp

{"name":"myIoT1","temp":15.9,"hasSmoke":true,"hasSwitchOn":false}

response は以下のように帰ってきます。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 19 Jul 2021 13:47:09 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 112

{
  "name": "myIoT1",
  "temp": 15.9,
  "hasSmoke": true,
  "hasSwitchOn": false,
  "dateTime": "2021-07-19T22:47:09.7372089+09:00"
}

CosmosDB の中にちゃんと書き込まれているかは、ポータルサイトのデータエクスプローラーで確認できます。

f:id:roamschemer:20210719225135p:plain

{
    "Name": "myIoT1",
    "Temp": 15.9,
    "HasSmoke": true,
    "HasSwitchOn": false,
    "DateTime": "2021-07-19T22:47:09.7372089+09:00",
    "id": "aedc20b2-1def-4629-b561-3029f4a5659a",
    "_rid": "AvYZAJL4zwkBAAAAAAAAAA==",
    "_self": "dbs/AvYZAA==/colls/AvYZAJL4zwk=/docs/AvYZAJL4zwkBAAAAAAAAAA==/",
    "_etag": "\"00000000-0000-0000-7ca4-920df9a801d7\"",
    "_attachments": "attachments/",
    "_ts": 1626702429
}

どうやらちゃんと書き込まれているようです。折角なので以下のようなデータも複数回投げ込んでみましょう。

### POST
POST http://localhost:7071/api/temp

{"name":"myIoT2","temp":12.5,"hasSmoke":false,"hasSwitchOn":true}

f:id:roamschemer:20210719225714p:plain

ちゃんと書き込まれていますね。

Azure Functions で Cosmos DB から読み込む

docs.microsoft.com

次は書き込んだ情報を読み込めるようにしましょう。TempDataFunction クラスを以下のようにします。

using System;
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 Data;
using System.Collections.Generic;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Documents.Linq;
using System.Linq;

namespace Api {
    public static class TempDataFunction {
        [FunctionName("temp")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            [CosmosDB(
                databaseName: "kunsei-iot-db",
                collectionName: "kunsei-iot-container",
                ConnectionStringSetting = "CosmosDbConnectionString")]DocumentClient client,
            ILogger log) {
            log.LogInformation("C# HTTP trigger function processed a request.");

            //GET http://{{host}}/api/temp?name=[これを取得] 
            string name = req.Query["name"];
            if (string.IsNullOrWhiteSpace(name)) {
                return new NotFoundResult();
            }
            //dbとコンテナーを指定
            Uri collectionUri = UriFactory.CreateDocumentCollectionUri("kunsei-iot-db", "kunsei-iot-container");
            // Name == nameを全部読んで来いqueryを作成
            IDocumentQuery<TempData> query = client.CreateDocumentQuery<TempData>(collectionUri, new FeedOptions { EnableCrossPartitionQuery = true })
                .Where(p => p.Name.Contains(name))
                .AsDocumentQuery();
            //results に詰める
            var results = new List<TempData>();
            while (query.HasMoreResults) {
                foreach (TempData result in await query.ExecuteNextAsync()) {
                    results.Add(result);
                }
            }
            //responseを返す
            return new OkObjectResult(results);
        }
    }
}

コメントに描いた通りの挙動をします。Visual Studio Code拡張機能 REST Client で以下のようなデータを GET してみましょう。

### GET
GET http://localhost:7071/api/temp?name=myIoT1

response は以下のように帰ってきます。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 19 Jul 2021 14:02:10 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 114

[
  {
    "name": "myIoT1",
    "temp": 15.9,
    "hasSmoke": true,
    "hasSwitchOn": false,
    "dateTime": "2021-07-19T22:47:09.7372089+09:00"
  }
]

先ほど POST した myIoT1 のデータが返ってきました。次に myIoT2 を GET してみます。

### GET
GET http://localhost:7071/api/temp?name=myIoT2
HTTP/1.1 200 OK
Connection: close
Date: Mon, 19 Jul 2021 14:05:29 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 677

[
  {
    "name": "myIoT2",
    "temp": 12.5,
    "hasSmoke": false,
    "hasSwitchOn": true,
    "dateTime": "2021-07-19T22:55:18.2337354+09:00"
  },
  {
    "name": "myIoT2",
    "temp": 12.5,
    "hasSmoke": false,
    "hasSwitchOn": true,
    "dateTime": "2021-07-19T22:55:19.02751+09:00"
  },
  {
    "name": "myIoT2",
    "temp": 12.5,
    "hasSmoke": false,
    "hasSwitchOn": true,
    "dateTime": "2021-07-19T22:55:19.5639916+09:00"
  },
  {
    "name": "myIoT2",
    "temp": 12.5,
    "hasSmoke": false,
    "hasSwitchOn": true,
    "dateTime": "2021-07-19T22:55:19.9926698+09:00"
  },
  {
    "name": "myIoT2",
    "temp": 12.5,
    "hasSmoke": false,
    "hasSwitchOn": true,
    "dateTime": "2021-07-19T22:55:20.3623241+09:00"
  },
  {
    "name": "myIoT2",
    "temp": 12.5,
    "hasSmoke": false,
    "hasSwitchOn": true,
    "dateTime": "2021-07-19T22:55:20.8040639+09:00"
  }
]

投げた分だけ返ってきていますね。これで完成…ってあれれ? GET と POST の両方を行えるようにするにはどうするの?

Azure Functions で Cosmos DB と読み書きする

これ正しいのか非常に怪しいのですが、このような形で再現しました。

using System;
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 Data;
using System.IO;
using Newtonsoft.Json;
using System.Collections.Generic;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Documents.Linq;
using System.Linq;

namespace Api {
    public static class TempDataFunction {
        [FunctionName("temp")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            [CosmosDB(
                databaseName: "kunsei-iot-db",
                collectionName: "kunsei-iot-container",
                ConnectionStringSetting = "CosmosDbConnectionString")]IAsyncCollector<TempData> documentsOut,
            [CosmosDB(
                databaseName: "kunsei-iot-db",
                collectionName: "kunsei-iot-container",
                ConnectionStringSetting = "CosmosDbConnectionString")]DocumentClient client,
            ILogger log) {
            log.LogInformation("C# HTTP trigger function processed a request.");

            if (req.Method == "GET") {
                string name = req.Query["name"];
                if (string.IsNullOrWhiteSpace(name)) {
                    return new NotFoundResult();
                }
                Uri collectionUri = UriFactory.CreateDocumentCollectionUri("kunsei-iot-db", "kunsei-iot-container");
                IDocumentQuery<TempData> query = client.CreateDocumentQuery<TempData>(collectionUri, new FeedOptions { EnableCrossPartitionQuery = true })
                    .Where(p => p.Name.Contains(name))
                    .AsDocumentQuery();

                var results = new List<TempData>();
                while (query.HasMoreResults) {
                    foreach (TempData result in await query.ExecuteNextAsync()) {
                        results.Add(result);
                    }
                }
                return new OkObjectResult(results);
            }
            if (req.Method == "POST") {
                string requestBody = String.Empty;
                using (StreamReader streamReader = new StreamReader(req.Body)) {
                    requestBody = await streamReader.ReadToEndAsync();
                }
                var tempData = JsonConvert.DeserializeObject<TempData>(requestBody);
                tempData.DateTime = DateTime.Now;
                await documentsOut.AddAsync(tempData);
                return new OkObjectResult(tempData);
            }
            return null;
        }
    }
}

基本的に前述した POST と GET のコードを合体させただけで、POST か GET かを req.Method で判定して処理を分岐させています。ただどうにも気持ちが悪い気が…とくに Run() の引数部分。あまりに気持ちが悪いので、 POST と GET を別メソッドにできないかと試してみましたがどうやらできないようです。正しい記述方法を知ってる方がいらっしゃいましたら是非ともご連絡ください。

開発時にローカルなDBに接続する

さてここまででは開発時にも Azure 上の DB にアクセスして動かしました。ですが実際の開発ではローカルな DB を準備してそこで開発を行うのが普通かと思います。Cosmos DB の場合は Azure Cosmos DB Emulator というのを使うのが良いようです。

docs.microsoft.com

使い方は超簡単で、上のドキュメントから Azure Cosmos DB Emulator をダウンロードしてインストールするだけです。後はメニューから Azure Cosmos DB Emulator を起動するとブラウザで画面が表示されますので、あとはポータルサイトと同じ感覚で扱えます。先に説明した local.settings.jsonエミュレータ側の接続文字列を書き込めば、それだけでローカルなDBに繋がります。便利だ!ただしこのエミュレータWindows 限定なので他のOSで動かす場合は若干面倒かもしれませんね。

Azure Static Web Apps デプロイ時にパブリックなDBに接続する

逆に Azure Static Web Apps にデプロイして公開した Api は当然ながらパブリックな DB に接続しますが、当然 local.settings.json は参照されません。では接続文字列はどこで指定するのかという問題になるのですが、これは ポータルサイトの構成という項目から指定できます。

f:id:roamschemer:20210719231519p:plain

まとめ

という事で Azure Static Web Apps にデプロイした Azure Functions で Azure Cosmos DB とやりとりするというのをやってみました。思ったよりずっとずっと楽ですね。やっぱサーバーレスはいいわぁ…(業務でのAWSインフラ管理で苦戦し続けている身)