放浪軍師のアプリ開発局

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

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

新年あけましてだいぶ経ちますがおめでとうございます。放浪軍師です。別にお勉強をさぼってたわけじゃないよ。ただちょっと苦戦していただけ。

Azure Functions で Azure Cosmos DB ともっといい感じにやりとりしたい

以前このような記事を書きしました。

www.gunshi.info

こちら確かに Azure Functions を介して Cosmos DB への書き込みと読み込みができたのですが、どうにも Azure Functions のメソッドがごちゃごちゃして嫌な感じだし、なおかつ検索、編集、削除といった方法もよくわからないままだったので、その辺を自分なりに良い感じに使えるようにまとめてみました。ただ、この辺の情報ってあまり落ちていなくて、ほぼ独自の書き方になりますので、その辺はご了承ください。多分もっと良い書き方があるんじゃないかなぁ…有識者求ム。

開発環境

GitHub

github.com

こちら Blazor WebAssembly から Azure Functions を介して Azure CosmosDB に入力出力編集削除ができるサンプルです。以下手順で準備すれば動きます。

CosmosDB の準備

Azure Cosmos DB Emulator を起動してください。もちろん本物でもいいです。

local.settings.json の準備

Api\local.settings.json.exampleApi\local.settings.json にリネームして Cosmos DB の Primary Connection String をコピります。

データベースとコンテナの準備

CosmosDB に gunshi-db というデータベースと persons というコンテナを作ってください。コンテナの Partition key は /job にします。

BlazorWebAssembly と AzureFunctions を同時にデバッグする

ソリューションを右クリック→「スタートアッププロジェクトの設定」にてマルチスタートアッププロジェクトを選択し、Api と Client のプロジェクトのアクションを「開始」にします。

動作

今回は Blazor 側の解説はしませんが、こんな感じで動きます。一通りの挙動は確認できると思います。

f:id:roamschemer:20220205224252g:plain

ApiTest.http について

サンプルには ApiTest.http というファイルを置いてあるのですが、こちらは Visual Studio Code拡張機能である Rest Client を用いて API を叩いたりして実験してた残骸です。まぁ良かったらこちらも試してみてください。以下に以前 LT したときの資料を置いておきます。

www.slideshare.net

ディレクトリ構成

まずディレクトリの構成ですが、私は以下のようにすることにしました。

f:id:roamschemer:20220205210739p:plain

CosmosDB であれこれしたいクラス(コンテナ)毎にディレクトリを作成し、 Post, Get, Update, Delete の .cs ファイルを配置します。これが一番わかりやすいかなーという感じです。ちなみに Get が3種類ありますがこの辺は後で説明します。

データクラス

適当ですが以下のような感じ。これを CosmosDB とやりとりします。通常の RDB では厳しい List や Class in Class のようないやらしい形も NoSQL なら丸ごと一括で扱えるのは凄く良いですね。

using Newtonsoft.Json; //Nugetからinstallが必要

namespace Data {
    public class Person {
        [JsonProperty(PropertyName = "id")]
        public Guid? Id { get; set; }
        [JsonProperty(PropertyName = "name")]
        public string Name { get; set; }
        [JsonProperty(PropertyName = "job")]
        public string Job { get; set; }
        [JsonProperty(PropertyName = "attributes")]
        public List<string> Attributes { get; set; }
        [JsonProperty(PropertyName = "items")]
        public List<Item> Items { get; set; }
        public Person(Guid? id, string name, string job, List<string> attributes, List<Item> items) {
            Id = id;
            Name = name;
            Job = job;
            Attributes = attributes;
            Items = items;
        }
        public Person(Person person) {
            Id = person.Id;
            Name = person.Name;
            Job = person.Job;
            Attributes = person.Attributes;
            Items = person.Items;
        }
        public Person() { }
    }
}
using Newtonsoft.Json; //Nugetからinstallが必要

namespace Data {
    public class Item {
        [JsonProperty(PropertyName = "name")]
        public string Name { get; set; }
        [JsonProperty(PropertyName = "content")]
        public string Content { get; set; }
        public Item(string name, string content) {
            Name = name;
            Content = content;
        }
    }
}

なお、各プロパティに設定してある JsonProperty 属性は、 CosmosDB に書き込むときの key を指定します。今回はすべて小文字に統一したかったので使用していて、別にこれを使用しなくても成立します。ただ、 id まわりで面倒なことになりそうな気はするので、id だけでも指定した方が良いかなと思います。

POST (PostPerson.cs)

Blazor 側から HttpClient.PostAsJsonAsync("api/person", person) を用いて Functions に接続し、ユニークな id を付けて DB に投げ込んでいます。本来なら id は DB 側で付けたいところで、実際 id = null で投げれば DB 側で自動で振ってくれはするんですが、その振られた id を取得する術がないので使えません。いや、たぶん方法はあるんだろうけどわからなかった…。

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;
using Data;

namespace Api.Persons {
    public static class PostPerson {
        [FunctionName("PostPerson")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = "person")] HttpRequest req,
            [CosmosDB(
                databaseName: "gunshi-db", //Database id
                collectionName: "persons", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString")]
            IAsyncCollector<Person> documentsOut,
            ILogger log) {
            try {
                string requestBody;
                using (StreamReader streamReader = new StreamReader(req.Body)) {
                    requestBody = await streamReader.ReadToEndAsync();
                }
                var person = JsonConvert.DeserializeObject<Person>(requestBody);
                person.Id = Guid.NewGuid();
                await documentsOut.AddAsync(person);
                return new OkObjectResult(person);
            } catch (Exception ex) {
                return new BadRequestObjectResult(ex);
            }
        }
    }
}

GET (GetPersons.cs)

Blazor 側から HttpClient.GetFromJsonAsync<List<Person>>("api/person") を用いて Functions に接続し、すべてのデータを呼び出します。また、クエリ文字列( api/person?name=放浪軍師 など)で渡された文字列を LINQ を用いて絞り込んだ情報を呼び出す事が可能となっています。

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

namespace Api.Persons {
    public static class GetPersons {
        [FunctionName("GetPersons")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = "person")] HttpRequest req,
            [CosmosDB(
                databaseName: "gunshi-db", //Database id
                collectionName: "persons", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString")]DocumentClient client,
            ILogger log) {
            var databaseName = "gunshi-db"; //Database id
            var collectionName = "persons"; //Container id
            try {
                var names = req.Query["name"];
                var jobs = req.Query["job"];
                var attributes = req.Query["attribute"];
                Uri collectionUri = UriFactory.CreateDocumentCollectionUri(databaseName, collectionName);
                var query = client.CreateDocumentQuery<Person>(collectionUri, new FeedOptions { EnableCrossPartitionQuery = true })
                    .Where(p => string.IsNullOrWhiteSpace(names) || p.Name.Contains(names))
                    .Where(p => string.IsNullOrWhiteSpace(jobs) || p.Job.Contains(jobs))
                    .Where(p => string.IsNullOrWhiteSpace(attributes) || p.Attributes.Contains(attributes))
                    .AsDocumentQuery();
                var persons = new List<Person>();
                while (query.HasMoreResults) {
                    foreach (Person result in await query.ExecuteNextAsync()) {
                        persons.Add(result);
                    }
                }
                return new OkObjectResult(persons);
            } catch (Exception ex) {
                return new BadRequestObjectResult(ex);
            }
        }
    }
}

GET (GetPersonFromJobAndId.cs)

Blazor 側から HttpClient.GetFromJsonAsync<Person>($"api/person/{person.Job}/{person.Id}" を用いて Functions に接続し、該当するデータをひとつ呼び出します。job は今回パーティションキーに設定しているので、この呼び出しはパーティションキー + id で呼び出せる Functions という事になります。なお、ピンポイントで呼び出すならこの形式が一番コスト的に優れているとのことです。

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;

namespace Api.Persons {
    public static class GetPersonFromJobAndId {
        [FunctionName("GetPersonFromJobAndId")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = "person/{job}/{id}")] HttpRequest req,
            [CosmosDB(
                databaseName: "gunshi-db", //Database id
                collectionName: "persons", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString",
                Id = "{id}",
                PartitionKey = "{job}")] Person person,
            ILogger log) {
            try {
                return new OkObjectResult(person);
            } catch (Exception ex) {
                return new BadRequestObjectResult(ex);
            }
        }
    }
}

GET (GetPersonFromId.cs)

Blazor 側から HttpClient.GetFromJsonAsync<Person>($"api/person/id/{person.Id}") を用いて Functions に接続し、該当するデータをひとつ呼び出します。先ほどとは違い、こちらは id だけで検索できるので便利ですが、SQL文を用いて複数のパーティションをまたがって検索をかける形なので、コスト的に優れてはいません。

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 System.Linq;

namespace Api.Persons {
    public static class GetPersonFromId {
        [FunctionName("GetPersonFromId")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = "person/id/{id}")] HttpRequest req,
            [CosmosDB(
                databaseName: "gunshi-db", //Database id
                collectionName: "persons", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString",
                SqlQuery = "select * from users r where r.id = {id}"
            )]IEnumerable<Person> persons,
            ILogger log) {
            try {
                return new OkObjectResult(persons.First());
            } catch (Exception ex) {
                return new BadRequestObjectResult(ex);
            }
        }
    }
}

UPDATE (UpdatePerson.cs)

Blazor 側から HttpClient.PutAsJsonAsync($"api/person/{person.Id}", person); を用いて Functions に接続し、該当するデータを書き換えます。ちなみに id と パーティションキーは書き換えできませんので注意してください。

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

namespace Api.Persons {
    public static class UpdatePerson {
        [FunctionName("UpdatePerson")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "put", Route = "person/{id}")] HttpRequest req,
            [CosmosDB(
                databaseName: "gunshi-db", //Database id
                collectionName: "persons", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString")]DocumentClient client,
            string id,
            ILogger log) {
            var databaseName = "gunshi-db";
            var collectionName = "persons";
            try {
                string requestBody;
                using (StreamReader streamReader = new StreamReader(req.Body)) {
                    requestBody = await streamReader.ReadToEndAsync();
                }
                var person = JsonConvert.DeserializeObject<Person>(requestBody);
                var option = new FeedOptions { EnableCrossPartitionQuery = true };
                var collectionUri = UriFactory.CreateDocumentCollectionUri(databaseName, collectionName);
                var document = client.CreateDocumentQuery(collectionUri, option).Where(t => t.Id == id).AsEnumerable().FirstOrDefault();
                if (document == null) {
                    return new NotFoundResult();
                }
                document.SetPropertyValue("name", person.Name);
                document.SetPropertyValue("attributes", person.Attributes);
                document.SetPropertyValue("items", person.Items);
                await client.ReplaceDocumentAsync(document);
            } catch (Exception ex) {
                return new BadRequestObjectResult(ex);
            }
            return new OkResult();
        }
    }
}

DELETE(DeletePerson.cs)

Blazor 側から HttpClient.DeleteAsync($"api/person/{person.Job}/{person.Id}"); を用いて Functions に接続し、該当するデータを削除します。id だけでなくパーティションキーも必須なので注意が必要です。…というか、むしろなんで UPDATE だけ id のみでいけるのだろうか?

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

namespace Api.Persons {
    public static class DeletePerson {
        [FunctionName("DeletePerson")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "delete", Route = "person/{job}/{id}")] HttpRequest req,
            [CosmosDB(
                databaseName: "gunshi-db", //Database id
                collectionName: "persons", //Container id
                ConnectionStringSetting = "CosmosDbConnectionString")]DocumentClient client,
            string job,
            string id,
            ILogger log) {
            var databaseName = "gunshi-db"; //Database id
            var collectionName = "persons"; //Container id
            try {
                var option = new FeedOptions { EnableCrossPartitionQuery = true };
                var collectionUri = UriFactory.CreateDocumentCollectionUri(databaseName, collectionName);
                var document = client.CreateDocumentQuery(collectionUri, option).Where(t => t.Id == id).AsEnumerable().FirstOrDefault();
                if (document == null) {
                    return new NotFoundResult();
                }
                await client.DeleteDocumentAsync(document.SelfLink, new RequestOptions {
                    PartitionKey = new Microsoft.Azure.Documents.PartitionKey(job)
                });
                return new OkResult();
            } catch (Exception ex) {
                return new BadRequestObjectResult(ex);
            }
        }
    }
}

UPDATE と DELETE の情報の少なさ

余談ですが、UPDATE と DELETE に関する情報はマジで少ないです。公式には GET と POST はあるんだけれども…。私が見つけられていないだけ?ちなみに探しに探して見つかった以下のページを参考にして作成しました。ほんと助かった…。

docs.microsoft.com

まとめ

かなーり雑な感じではありますが、これぐらい揃っていれば基本は抑えられたと思ってもいいよね!まだ色々解らない事はありますが、これでやっと燻製アプリを作ることができそうです。さぁ次は IoT 側だ!(絶望)