放浪軍師のアプリ開発局

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

分離ワーカーモデルを使って、Azure Functions から Azure Cosmos DB へアクセスする

最近 Web 個人開発をマジで頑張っている放浪軍師です。近くリリースして紹介出来たらなぁと思っていますのでよろしくお願いします。

さて、今回はそんな Web 個人開発で使おうと思っている、Azure Functions と Azure Cosmos DB のお話です。

分離ワーカーモデルを使って、Azure Functions から Azure Cosmos DB へアクセスする

現在、.NET クラスライブラリ関数はインプロセスモデルと、分離ワーカーモデルの2種類があるそうです。簡単に言えばインプロセスモデルが旧版で、分離ワーカーモデルが新版ということになります。そして、インプロセスモデルは2026年11月10日にサポートが終了することが確定している為、現時点で新規開発を行うのであれば分離ワーカーモデルを選択するのが妥当かと思われます。

そして、私が以前書いた以下記事では、インプロセスモデルを用いた内容である為、使用は非推奨ということになります。無念すぎる。

www.gunshi.info

まぁ仕方がないので、分離ワーカーモデルを用いた例を新たに作成しました。分離ワーカーモデルに移行推奨という文面が至る所に存在しているにもかかわらず、分離ワーカーモデルのドキュメントはインプロセスモデルよりも足りていない為、調べるのにかなり骨が折れました。いやほんとしっかりしてよ Microsoft さんよぉ・・・

開発環境

GitHub

github.com

サンプルです。このブランチを試される場合は、以下手順を行ってください。

  • Api/local.settings.json.examplelocal.settings.json にリネームして、接続文字列とデータベース名を埋める。

  • Azure Cosmos DB にデータベースを作成する。

  • companies コンテナを作成し、パーティションキーを /category とする。

こんな感じで動かせるよ!!!

前準備

Azure Functions プロジェクトの DI コンテナに CosmosClient を登録する

公式ドキュメントでは、以下のような感じで CosmosClient を DI していますが、これをそのまま使うと一部機能が正しく動かないので、DI コンテナに登録する際に指定するようにします。

Api/Program.cs

using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Fluent;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services => {
        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
        services.AddSingleton(provider => {
            var connectionString = Environment.GetEnvironmentVariable("CosmosDBConnection");
            var client = new CosmosClientBuilder(connectionString)
                .WithSerializerOptions(new() {
                    PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase  //これが必要
                })
                .Build();
            return client;
        });

    })
    .Build();

host.Run();

DI コンテナに登録する前に、WithSerializerOptions を使って、PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase を指定しています。これが何かというと、後に紹介する cosmos DB の値を linq で絞り込んだりして取得する際に発行される SQL クエリ内のキー指定をキャメルケースにしてくれます。

本来であれば、JsonPropertyName で指定したキーに変換してくれるべきなのですが、分離ワーカーが System.Text.Json.Serialization に正しく対応できていないっぽくてその補佐的記述になりますかね。この辺たぶん不具合なんじゃないかな。 issue でも議論されていました。

github.com

とりあえず、この記述により発行される SQL クエリのキーをキャメルケースにして、データクラスを以下のように JsonPropertyName を用いてキャメルケースにしてやれば一致するので正しく動きます。

Data/Company.cs

using System.Text.Json.Serialization;

namespace Data
{
    public class Company()
    {
        public enum CategoryDatas
        {
            Admin,
            User,
            PowerUser,
            Customer,
        }

        [JsonPropertyName("id")]
        public string Id { get; set; }

        [JsonPropertyName("name")]
        public string Name { get; set; }

        [JsonPropertyName("category")]
        //[JsonConverter(typeof(JsonStringEnumConverter))] //効かない様子なのでコメント化しておく
        public CategoryDatas Category { get; set; } = CategoryDatas.User;


        [JsonPropertyName("createdAt")]
        public DateTime CreatedAt { get; set; }
    }
}

なお、コメントにもあるように JsonConverter もなんか不具合があるようで利きません。調べたけどわからなかったのでとりあえずこのまま行きます。

登録

まずは登録。post api/companies を叩くと発火する Http トリガーです。コンストラクタで先ほど登録した CosmosClient を DI していますので、若干見通しがよいですね。投げ込まれた Company に Id と CratedAt を付与し、container.CreateItemAsync() を用いて companies コンテナに丸ごと登録します。なお、パーティションキーには /category を指定しています。

using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Api.Companies
{
    public class PostCompanies
    {
        private readonly ILogger<GetCompanies> _logger;
        private readonly CosmosClient _cosmosClient;

        public PostCompanies(ILogger<GetCompanies> logger, CosmosClient cosmosClient) {
            _logger = logger;
            _cosmosClient = cosmosClient;
        }

        [Function(nameof(PostCompanies))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = "companies")] HttpRequest req) {
            _logger.LogInformation("C# HTTP trigger function processed a post request.");

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var company = JsonSerializer.Deserialize<Company>(requestBody);
            if (company == null) {
                return new BadRequestObjectResult("Invalid request payload.");
            }

            company.Id = Guid.NewGuid().ToString();
            company.CreatedAt = DateTime.UtcNow;

            var container = _cosmosClient.GetContainer(Environment.GetEnvironmentVariable("CosmosDb"), "companies");

            var response = await container.CreateItemAsync(company, new PartitionKey((int)company.Category));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return new OkObjectResult(company);
        }
    }
}

一覧取得

get api/companies を叩くと発火する Http トリガーです。クエリパラメータによって、namecategory を絞り込めるようにしています。ここの container.GetItemLinqQueryable にて linq ちっくに絞り込む条件や並び替えを簡単に記述できますが、先ほど説明したエラーが発生する箇所なので注意が必要です。マジではまります。

using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Linq;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace Api.Companies
{
    public class GetCompanies
    {
        private readonly ILogger<GetCompanies> _logger;
        private readonly CosmosClient _cosmosClient;

        public GetCompanies(ILogger<GetCompanies> logger, CosmosClient cosmosClient) {
            _logger = logger;
            _cosmosClient = cosmosClient;
        }

        [Function(nameof(GetCompanies))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = "companies")] HttpRequest req) {
            _logger.LogInformation("C# HTTP trigger function processed a get request.");

            var name = req.Query["name"].ToString();
            var category = req.Query["category"].ToString();

            var container = _cosmosClient.GetContainer(Environment.GetEnvironmentVariable("CosmosDb"), "companies");
            var queryable = container.GetItemLinqQueryable<Company>()
                .Where(c => string.IsNullOrEmpty(name) || c.Name.Contains(name))
                .Where(c => string.IsNullOrEmpty(category) || c.Category.ToString() == category)
                .OrderByDescending(c => c.CreatedAt);
            var iterator = queryable.ToFeedIterator();

            var companies = new List<Company>();
            while (iterator.HasMoreResults) {
                var response = await iterator.ReadNextAsync();
                _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
                companies.AddRange(response);
            }
            return new OkObjectResult(companies);
        }
    }
}

更新

patch api/companies/{category}/{id} を叩くと発火する Http トリガーです。id だけでなく、パーティションキーも必要なので注意が必要です。更新には、一部を自在に操れる container.PatchItemAsync と、丸ごと上書きする container.ReplaceItemAsync があるので使い分けると良さそうです。

using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Api.Companies
{
    public class PatchCompanies
    {
        private readonly ILogger<PatchCompanies> _logger;
        private readonly CosmosClient _cosmosClient;

        public PatchCompanies(ILogger<PatchCompanies> logger, CosmosClient cosmosClient) {
            _logger = logger;
            _cosmosClient = cosmosClient;
        }

        [Function(nameof(PatchCompanies))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "patch", Route = "companies/{category:int}/{id}")] HttpRequest req, int category, string id) {


            _logger.LogInformation("C# HTTP trigger function processed a patch request.");

            var container = _cosmosClient.GetContainer(Environment.GetEnvironmentVariable("CosmosDb"), "companies");

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var companyData = JsonSerializer.Deserialize<Company>(requestBody);
            if (companyData == null) {
                return new BadRequestObjectResult("Invalid request payload.");
            }

            var response = await container.PatchItemAsync<Company>(
                id,
                new PartitionKey(category),
             patchOperations: [
                    PatchOperation.Replace("/name", companyData.Name),   // 更新
                    //他にも色々できる。
                    //PatchOperation.Add("/color", "silver"),          // 追加
                    //PatchOperation.Remove("/used"),                    // 削除
                    //PatchOperation.Increment("/price", 50.00),     // インクリメント
                    //PatchOperation.Set("/tags", new string[] {}),      // 空の配列を設定
                    //PatchOperation.Add("/tags/-", "featured-bikes")  // 配列の末尾に値を追加
                ]
            );
            //var response = await container.ReplaceItemAsync(companyData, id, new PartitionKey(category)); まるごと置換するならこれもあり

            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");

            return new OkObjectResult(response);
        }
    }
}

削除

delete api/companies/{category}/{id} を叩くと発火する Http トリガーです。こちらも id だけでなく、パーティションキーも必要なので注意が必要です。container.DeleteItemAsync を使って楽に削除できます。

using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Api.Companies
{
    public class DeleteCompanies
    {
        private readonly ILogger<DeleteCompanies> _logger;
        private readonly CosmosClient _cosmosClient;

        public DeleteCompanies(ILogger<DeleteCompanies> logger, CosmosClient cosmosClient) {
            _logger = logger;
            _cosmosClient = cosmosClient;
        }

        [Function(nameof(DeleteCompanies))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "delete", Route = "companies/{category:int}/{id}")] HttpRequest req, int category, string id) {


            _logger.LogInformation("C# HTTP trigger function processed a delete request.");

            var container = _cosmosClient.GetContainer(Environment.GetEnvironmentVariable("CosmosDb"), "companies");

            var response = await container.DeleteItemAsync<Company>(id, new PartitionKey(category));

            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");

            return new OkObjectResult(response);
        }
    }
}

まとめ

いやーさらっと説明しましたが、本当に本当に苦戦しました。なによりドキュメントが雑すぎるし点在しすぎているよ!!!こんなの誰だってやりたい事なんだからわかりやすくまとめておきなさいって Microsoft さんよぉ・・・。まぁ何はともあれ、これで Cosmos DB を使う準備が整った感じですね。個人 Web 開発頑張るぞー!!!!