今日も今日とて Blazor やれるとこまで頑張ろう!!!それにしてもタイトルがどんどん長くなっていくな…ラノベかな?
Azure Static Web Apps にデプロイした Azure Functions で Azure Cosmos DB とやりとりする
さて前回。Blazor WebAssembly と Azure Functions を Azure Static Web Apps で公開するというのをやってみました。それの発展形として Azure Function で投げたリクエストを Azure Cosmos DB というデータベースに保存したり呼び出したりするのをやってみようと思います。具体的には燻製器内の温度などを読み書きさせます。
開発環境
- Microsoft Visual Studio Community 2019 Version 16.8.2
- Microsoft.AspNetCore.Components.WebAssembly 5.0.5
- Microsoft.NET.Sdk.Functions 3.0.13
- Microsoft.Azure.WebJobs.Extensions.CosmosDB 3.0.10
GitHub
何か参考になれば幸い
Azure Cosmos DB とは
Azure Cosmos DB とは Azure サービスの一つでフルマネージドの NoSQL データベースサービスです。フルマネージドというのはいわゆるサーバーレスで管理やメンテなどをあまり必要としないサービスという事で良いと思いますが、NoSQL というのは聞きなれない言葉ですね。
NoSQL データベースとは
MySQL などのリレーショナルデータベース(RDB)とは違い、データを Json (や XML) の形式で保存してしまうタイプのデータベース。Json 形式なので子情報などもまとめて保存でき、汎用性が高く、速度も早い。半面、リレーショナルでないということは、データの整合性に気を付けないとデータベースとして破綻してしまう可能性も秘めていると思われます。恐らくそれほど複雑でないデータに向いた形式なんじゃないかな?なお、名前からして SQL 文からおさらばできそうな感じであるが、NoSQL は Not Only SQL らしく、普通に SQL 文が使える様子。
Azure Cosmos DB の準備
基本的には以下を参考に作成できますので詳細は割愛。ただ悩んだポイントもあるのでそこだけ説明します。
データベースとコンテナーを作成する
Data Explorer から New Container を選択しデータベースとコンテナーを作成します。この際の Database id と Container id はDBにアクセスする際に必要になりますので控えておきます。今回の例では Database id = kunsei-iot-db
Container id = kunsei-iot-container
としました。
パーティションキー
同じく New Container からコンテナーを作成する際に Paartition key というものの指定が求められます。このパーティションキー(というかパーティション)の概念はかなり奥が深そうなのでここでは言及しませんが、とりあえずこのコンテナーのグループ分け用カラムみたいな使い方をすれば良さそうです。今回は /Name
を指定しました。
初期設定
プロジェクトを作る
Apiプロジェクト(Azure Functions用)、Clientプロジェクト(Blazor WebAssembly用)、Dataプロジェクト(共通クラス用)を作成しています。詳細は前のエントリーを参照してください。
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 の動作定義
Azure Functions から Cosmos DB にアクセスできるようにするには Azure Functions の定義を司っている host.json というファイルを書き換える必要があります。
{ "version": "2.0", "extensions": { "cosmosDB": { "connectionMode": "Gateway", "protocol": "Https", "leaseOptions": { "leasePrefix": "prefix1" } } } }
API アプリケーション設定
今回はローカルではローカルに準備した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接続文字列}" } }
なお、接続文字列はポータルサイトのキーという項目のところにあります。
Azure Functions で Cosmos DB に書き込む
下準備が終わりましたのでやっていきましょう。まずは書き込みから。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 の中にちゃんと書き込まれているかは、ポータルサイトのデータエクスプローラーで確認できます。
{ "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}
ちゃんと書き込まれていますね。
Azure Functions で Cosmos DB から読み込む
次は書き込んだ情報を読み込めるようにしましょう。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 というのを使うのが良いようです。
使い方は超簡単で、上のドキュメントから 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 は参照されません。では接続文字列はどこで指定するのかという問題になるのですが、これは ポータルサイトの構成という項目から指定できます。
まとめ
という事で Azure Static Web Apps にデプロイした Azure Functions で Azure Cosmos DB とやりとりするというのをやってみました。思ったよりずっとずっと楽ですね。やっぱサーバーレスはいいわぁ…(業務でのAWSインフラ管理で苦戦し続けている身)