放浪軍師のアプリ開発局

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

ぼくがかんがえたさいきょうの C# + Azure Functions + Azure Cosmos DB + Azure Static Web Apps テンプレート完成

こんにちは。放浪軍師です。

C# + Azure Functions + Azure Cosmos DB + Azure Static Web Apps 自作テンプレート

今回は、私が考えた最強の C# + Azure Functions + Azure Cosmos DB + Azure Static Web Apps テンプレートをご紹介します。そこそこの時間をかけて、私の知識と経験をぶっこみましたので、一見の価値ありじゃないかと思います。・・・とは言え、まだまだ C# そのものが初心者ですし、実際にこのテンプレートで開発を進めてみた訳でもないので、色んなつっこみどころがあるかもしれません。その際は是非ご指摘ください。

なお、フロントは Blazor Web Assembly で作成していますが、こちらはかなり適当なのでご注意ください。説明も特にしません。

構成

  • Microsoft Visual Studio Community 2022 (64 ビット) - Current Version 17.10.2
  • Microsoft.Net 8.0 v4
  • Microsoft.AspNetCore.Components.WebAssembly 8.0.5
  • Microsoft.Azure.Functions.Worker 1.21.0
  • Microsoft.Azure.Functions.Worker.Extensions.CosmosDB 4.9.0
  • Bogus 35.5.1
  • MSTest.TestFramework 3.1.1

GitHub

結構魂込めました。見てやってください。

github.com

前準備

  1. Azure Cosmos DB を用意します。Azure 上でも良いけど、お金がかかる可能性がありますので、Cosmos DB Emurator などでローカルに準備した方が良いです。

  2. Api プロジェクトの local.settings.json.example を local.settings.json にリネームし、Cosmos DB の接続文字列とデータベース名を記載します。なお、データベースは自動で作ってくれるので、あらかじめ用意する必要はないです。

  3. Test プロジェクトの .runsettings.example を .runsettings にリネームし、Cosmos DB の接続文字列とデータベース名を記載します。データベース名は上とは別の方が分かりやすいと思います。私は、上の データベース名-test にしています。

  4. Visual Studio のメニューより「プロジェクト」→「プロパティ」を開き、「共通プロパティ」→「スタートアッププロジェクト」にて、マルチスタートアッププロジェクトを選択。Api プロジェクトと Client プロジェクトを「開始」、そのほかは「なし」にします。

動作確認

開始ボタンでブラウザが立ち上がりますので、適当に操作してみてください。

構成図

以下のような構成で作成しています。バックエンドに関してはクリーンアーキテクチャを徹底採用していて、テストしやすいようにしています。テスト大事。マジで大事。

/root
    /Api: Azure Functions によって構成されたバックエンド
        /Properties: 設定
        /Controllers: Azure Functions の Httpトリガー
        /Middlewares: Httpトリガー発火前後に動作するミドルウェア
        /Repositories: Cosmos DB へのアクセス
        /UseCases: ビジネスロジック
        /Utils: その他便利ツール
        /Validators: バリデーション
        Program.cs : 初期動作を担う起点

    /Client: Blazor Web Assembly によって構成されたフロントエンド
        省略

    /Data: 共通で使用するデータモデル
        省略

    /Test: MSTest によるテスト
        /Properties: 設定
        /Api: Api プロジェクトのテスト
            /Controllers: Azure Functions の Httpトリガーテスト
            /Repositories: Cosmos DB へのアクセステスト
            /Validators: バリデーションテスト
        /Factories: ダミーデーター生成

Api プロジェクト

Azure Functions を用いたバックエンドになります。なお、今回の構成は Azure Static Web Apps の認証と認可に頼る部分がある為、標準の Azure Functions に適用する場合はその辺を考慮してください。

Program.cs

バックエンドの起点となるファイルで、 DB とコンテナーの作成、ミドルウェアの登録、DI コンテナへの登録を行っています。Utils に置いてあるクラスを呼び出すだけの記述にしてあるので、詳しくはそちらで説明しますが、DB やコンテナーが無ければ作成してくれるようになっていて便利です。

using Api.Utils;
using Microsoft.Extensions.Hosting;

var connectionString = Environment.GetEnvironmentVariable("CosmosDBConnection");
var databaseId = Environment.GetEnvironmentVariable("CosmosDb");
var dbInitializer = new CosmosDbInitializer(connectionString, databaseId);

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication(worker => Startup.ConfigureFunctionsWebApplication(worker))
    .ConfigureServices(services => Startup.ConfigureServices(services, dbInitializer))
    .Build();

host.Run();

Middlewares

Azure Functions の Http トリガー発火前後に動作するミドルウェアです。各 Http トリガー別ではなく、すべての Http トリガーが対象となるので注意が必要です。ここで個別のミドルウェアを実装するなら、httpRequestData.Method で分岐させる感じになるかなと思いますが、それならバリデーション側でやってもいいかもしれません。まぁ内容次第ですかね。とりあえずここではリクエストとレスポンスのログを取得できるようにしています。

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Logging;

namespace Api.Middlewares
{
    public class LoggingMiddleware(ILogger<LoggingMiddleware> logger) : IFunctionsWorkerMiddleware
    {
        private readonly ILogger<LoggingMiddleware> _logger = logger;

        public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) {
            // リクエスト情報のログ出力
            var httpRequestData = await context.GetHttpRequestDataAsync();
            if (httpRequestData != null) {
                _logger.LogInformation("Handling request: {Method} {Url}", httpRequestData.Method, httpRequestData.Url);
            }

            // 次のミドルウェアまたは関数の実行
            await next(context);

            // レスポンス情報のログ出力
            var httpResponseData = context.GetHttpResponseData();
            if (httpResponseData != null) {
                _logger.LogInformation("Response status: {StatusCode}", httpResponseData.StatusCode);
            }
        }
    }
}

Controllers

Azure Functions の Http トリガーです。フロントエンドからここにある各 Api を叩くような感じになりますね。ここにはあまりロジックを書きたくないので、ビジネスロジックやバリデーションは切り離して見通しよくしてあります。

今回はサンプルとして、一覧取得、作成、更新、削除を用意しました。長くなっちゃうのでここでは更新だけ表示しておきます。他も見たいという方は GitHub を参照してください。

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

namespace Api.Controllers.Companies
{
    public interface IPostCompany
    {
        public Task<IActionResult> Run(HttpRequest req);
    }

    public class PostCompany(ILogger<GetCompanies> logger, IPostCompanyValidator validator, ICompanyUsecase companyUsecase) : IPostCompany
    {
        private readonly ILogger<GetCompanies> _logger = logger;
        private readonly IPostCompanyValidator _validator = validator;
        private readonly ICompanyUsecase _companyUsecase = companyUsecase;

        [Function(nameof(PostCompany))]
        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.");
            }

            var validationResults = _validator.Validate(company);
            if (validationResults.Any()) {
                return new BadRequestObjectResult(validationResults);
            }

            var createCompany = await _companyUsecase.CreateAsync(company);

            return new OkObjectResult(createCompany);
        }
    }
}

Validators

controller から リクエストの内容を受け取って、その内容次第ではエラーを返すバリデーションです。System.ComponentModel.DataAnnotations というそこそこ便利なバリデーション機能があるにはあるのですが、正直微妙な気がするので使わずに愚直に書いています。実際に運用を行うとまた違ってくるかもしれませんね。この辺の詳しい話は 前回の記事 を参照してください。

なお、今回は使っていない Repository を DI しているのは、バリデーションで使う可能性が高いからです。例えば、登録する Name が重複していた場合は弾くとかの場合ですね。

using Api.Repositories;
using Data;
using Microsoft.Extensions.Logging;
using System.ComponentModel.DataAnnotations;

namespace Api.Validators.Companies
{
    public interface IPostCompanyValidator
    {
        public IReadOnlyList<ValidationResult> Validate(Company company);
    }

    public class PostCompanyValidator : IPostCompanyValidator
    {
        private readonly ILogger<IPostCompanyValidator> _logger;
        private readonly ICompanyRepository _companyRepository;

        public PostCompanyValidator(ILogger<IPostCompanyValidator> logger, ICompanyRepository companyRepository) {
            _logger = logger;
            _companyRepository = companyRepository;
        }

        public IReadOnlyList<ValidationResult> Validate(Company company) {
            var results = new List<ValidationResult>();


            if (string.IsNullOrWhiteSpace(company.Name)) {
                var message = $"{nameof(Company.Name)} は必須です。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            if (company.Category == null) {
                var message = $"{nameof(Company.Category)} は必須です。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            if (company.Name == "つけもの") {
                var message = $"ただし{company.Name} テメーはダメだ。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            if (company.Name == "キマリ") {
                var message = $"{company.Name} は通さない。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            return results;
        }
    }
}

Usecases

ビジネスロジックです。今回はあまり書くことがありませんが、実際に運用する場合はここが肥大化するはずですので、controller とは明確に切り離しておきましょう。

using Api.Repositories;
using Data;
using Microsoft.Extensions.Logging;
using static Data.Company;

namespace Api.Usecases
{
    public interface ICompanyUsecase
    {
        public Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions);
        public Task<Company> DeleteAsync(string id, CategoryDatas? category);
        public Task<Company> CreateAsync(Company company);
        public Task<Company> PatchAsync(Company company);
    }

    public class CompanyUsecase(ILogger<ICompanyUsecase> logger, ICompanyRepository companyRepository) : ICompanyUsecase
    {
        private readonly ILogger<ICompanyUsecase> _logger = logger;
        private readonly ICompanyRepository _companyRepository = companyRepository;

        public async Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions) => await _companyRepository.SelectConditionsAsync(conditions);
        public async Task<Company> DeleteAsync(string id, CategoryDatas? category) => await _companyRepository.DeleteAsync(id, category);
        public async Task<Company> CreateAsync(Company company) => await companyRepository.CreateAsync(company);
        public async Task<Company> PatchAsync(Company company) => await companyRepository.PatchAsync(company);
    }
}

Repositories

DB へのアクセスを担います。Usecase や Validator から呼び出して使用する感じですね。ここに統一しておくことで、DB へのアクセス内容を制限したり、便利にしたりできます。なお、ここ以外からの DB アクセスは禁止です。

using Api.Utils;
using Data;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Linq;
using Microsoft.Extensions.Logging;
using static Data.Company;

namespace Api.Repositories
{
    public interface ICompanyRepository
    {
        public Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions);
        public Task<Company> DeleteAsync(string id, CategoryDatas? category);
        public Task<Company> CreateAsync(Company company);
        public Task<Company> PatchAsync(Company company);
    }

    public class CompanyRepository(ILogger<ICompanyRepository> logger, ICompanyContainer container) : ICompanyRepository
    {
        private readonly ILogger<ICompanyRepository> _logger = logger;
        private readonly Container _container = container.Container;

        /// <summary>
        /// 検索条件に従いコンテナの値を取得する
        /// </summary>
        /// <param name="conditions">検索条件</param>
        public async Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions) {

            conditions.TryGetValue("name", out var name);
            conditions.TryGetValue("category", out var category);

            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();
                companies.AddRange(response);
                _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            }
            return companies;
        }

        /// <summary>
        /// 指定されたオブジェクトを削除する
        /// </summary>
        public async Task<Company> DeleteAsync(string id, CategoryDatas? category) {
            var response = await _container.DeleteItemAsync<Company>(id, new PartitionKey(category.ToString()));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return response;
        }

        /// <summary>
        /// 指定されたオブジェクトを作成する
        /// </summary>
        public async Task<Company> CreateAsync(Company company) {
            company.Id = Guid.NewGuid().ToString();
            company.CreatedAt = DateTime.UtcNow;
            company.UpdatedAt = company.CreatedAt;
            var response = await _container.CreateItemAsync(company, new PartitionKey(company.Category.ToString()));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return response;
        }

        /// <summary>
        /// 指定されたオブジェクトを更新する
        /// </summary>
        public async Task<Company> PatchAsync(Company company) {
            var response = await _container.PatchItemAsync<Company>(
                company.Id,
                new PartitionKey(company.Category.ToString()),
             patchOperations: [
                    PatchOperation.Set("/name", company.Name),
                    PatchOperation.Set("/updatedAt", DateTime.UtcNow),

                    //色々できる。詳しくは、https://learn.microsoft.com/ja-jp/azure/cosmos-db/partial-document-update
                    //更新の際はSetを使い、配列への更新時はAddを使うのが良いと思う。Replaceは後から追加したプロパティでこけるので基本的には使わない方が良い。

                    //PatchOperation.Replace("/name", company.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(name)); まるごと置換するならこれもあり

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

    }
}

Utils

その他の機能を持ちます。現在、CosmosDB セットアップに関するクラスをいくつか所有している形になっているので、この辺は別の場所にまとめたほうがいいかもしれませんね。

CosmosDbInitializer.cs

Cosmos DB のデータベースやコンテナーを準備するクラスです。テストの際も使用しますので、program.cs から切り離しておく必要があります。

using Microsoft.Azure.Cosmos;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Api.Utils
{
    public class CosmosDbInitializer
    {
        public enum ThroughputMode
        {
            AutoScale,
            Manual,
        }

        private readonly Database _database;

        /// <summary>
        /// データベースを取得または作成して、コンテナ取得準備をします
        /// </summary>
        /// <param name="connectionString">DB接続文字列</param>
        /// <param name="databaseId">データベース名</param>
        public CosmosDbInitializer(string connectionString, string databaseId) {
            var jsonSerializerOptions = new JsonSerializerOptions() {
                DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
            };
            var cosmosSystemTextJsonSerializer = new CosmosSystemTextJsonSerializer(jsonSerializerOptions);
            var cosmosClientOptions = new CosmosClientOptions() {
                Serializer = cosmosSystemTextJsonSerializer
            };
            var client = new CosmosClient(connectionString, cosmosClientOptions);
            var databaseResponse = client.CreateDatabaseIfNotExistsAsync(databaseId).GetAwaiter().GetResult();
            _database = databaseResponse.Database;
        }

        /// <summary>
        /// コンテナを取得または作成します。mode などの設定は作成時のみ反映されます。
        /// </summary>
        /// <param name="containerId">コンテナID</param>
        /// <param name="partitionKeyPath">/から始まるパーティションキー</param>
        /// <param name="mode">スループットのモード</param>
        /// <param name="maxThroughput">最大スループット 未指定の場合最小で作成される</param>
        /// <param name="isCleanUp">初期化する?</param>
        /// <returns>コンテナ</returns>
        public async Task<Container> GetContainerAsync(string containerId, string partitionKeyPath, ThroughputMode mode = ThroughputMode.Manual, int? maxThroughput = null, bool isCleanUp = false) {
            var throughputProperties = mode switch {
                ThroughputMode.AutoScale => ThroughputProperties.CreateAutoscaleThroughput(maxThroughput ?? 1000),
                ThroughputMode.Manual => ThroughputProperties.CreateManualThroughput(maxThroughput ?? 400),
                _ => throw new ArgumentOutOfRangeException(nameof(mode), $"不正なモード mode: {mode}")
            };
            var containerResponse = await _database.CreateContainerIfNotExistsAsync(new ContainerProperties(containerId, partitionKeyPath), throughputProperties);
            if (isCleanUp && containerResponse.StatusCode != System.Net.HttpStatusCode.Created) {
                await containerResponse.Container.DeleteContainerAsync();
                containerResponse = await _database.CreateContainerAsync(new ContainerProperties(containerId, partitionKeyPath), throughputProperties);
            }
            return containerResponse.Container;
        }

    }
}

CosmosSystemTextJsonSerializer.cs

前述の CosmosDbInitializer クラスで CosmosClientOptions の Serializer を指定するために使用するクラスです。これ何のために必要かと言うと、どうやら System.Text.Json で実装を進めると想定しない動きをしてしまうようで、それを防ぐためのものになります。具体的には、GetItemLinqQueryable で Enum を指定した際になんだか上手く動かなかったりします。この辺ほんとに意味が解らなくて調べまくったところ、issue でこれ使うと動くよという情報が見つかったので、そのまま使用することで助かりました。何をやってるのかは知らん。これ不具合な気もするので、そのうち不要になるかもしれませんね。他人のコードなので表示は割愛します。

github.com

CosmosContainerWrapper.cs

このテンプレート一番の謎クラスです。Microsoft.Azure.Cosmos.Container をコンテナー毎に DI コンテナに登録したかったのですが、同じクラスの別内容を DI コンテナに登録する手段がないようなので、このラッパーを介して Microsoft.Azure.Cosmos.Container 毎にインターフェースを作成することにしました。今後、新しいコンテナーを作るたびに、IHogeContainer が増えていくことになるのでかなり辛い気がしていますが、ほかの手段が思いつかないです。

普段業務で使っている PHP の laravel では別名で登録することができるのでかなり困惑しました。もしかしたら私が見つけきらなかっただけで、実は他に良い方法があるのかもしれません。その時はご一報ください。ほんと待ってます。

using Microsoft.Azure.Cosmos;

namespace Api.Utils
{
    public interface ICompanyContainer
    {
        Container Container { get; set; }
    }

    // public interface IHogeContainer← コンテナーが増えるたびにこれを書くのかよ・・・辛い
    // {
    //     Container Container { get; set; }
    // }

    public class CosmosContainerWrapper(Container container) : ICompanyContainer //,IHogeContainer これも・・・
    {
        public Container Container { get; set; } = container;

    }
}

SetUp.cs

DI コンテナやミドルウェアへの登録を行います。こちらもテストで使用するので切り離しています。先ほど説明したラッパーは、services.AddSingleton<ICompanyContainer>() で使用していますが・・・。なんかもうちょっと良い方法はないものか・・・

using Api.Controllers.Companies;
using Api.Middlewares;
using Api.Repositories;
using Api.Usecases;
using Api.Validators.Companies;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using static Api.Utils.CosmosDbInitializer;

namespace Api.Utils
{
    public class Startup
    {
        /// <summary>
        /// DIコンテナへの登録
        /// </summary>
        /// <param name="services"></param>
        /// <param name="dbInitializer"></param>
        public static void ConfigureServices(IServiceCollection services, CosmosDbInitializer dbInitializer, ThroughputMode mode = ThroughputMode.Manual, int? maxThroughput = null, bool isCleanUp = false) {
            services.AddApplicationInsightsTelemetryWorkerService();
            services.ConfigureFunctionsApplicationInsights();
            //Container
            services.AddSingleton<ICompanyContainer>(provider => new CosmosContainerWrapper(dbInitializer.GetContainerAsync("companies", "/" + "category", mode, maxThroughput, isCleanUp).GetAwaiter().GetResult()));
            //Controller
            services.AddSingleton<IGetCompanies, GetCompanies>();
            services.AddSingleton<IDeleteCompany, DeleteCompany>();
            services.AddSingleton<IPatchCompany, PatchCompany>();
            services.AddSingleton<IPostCompany, PostCompany>();
            //Usecase
            services.AddSingleton<ICompanyUsecase, CompanyUsecase>();
            //Repository
            services.AddSingleton<ICompanyRepository, CompanyRepository>();
            //Validator
            services.AddSingleton<IPostCompanyValidator, PostCompanyValidator>();
        }

        /// <summary>
        /// ミドルウェアの登録
        /// </summary>
        /// <param name="builder"></param>
        /// <returns></returns>
        public static void ConfigureFunctionsWebApplication(IFunctionsWorkerApplicationBuilder worker) {
            worker.UseMiddleware<LoggingMiddleware>();
        }
    }
}

Test プロジェクト

MSTest を用いたテストを記述しています。内容ついてはここでは省略しますが、ポイントとしてはまず厳密な意味で言えば単体テストは行っていません。全部インターフェースを切っているにも関わらず、すべてのテストでデータベースへアクセスさせています。

理由としては、やっぱり結果が気になるからですね。データベースをモックして値を偽装してしまうと、ちゃんと動いているかどうかの確認が取れないので。

まぁもっとも、それは構成や状況にもよると思うので、一応インターフェースをすべて切っておいて、いつでも単体テストできるようにしておいた方が良いとの判断になります。クラスをまるごと差し替えなんかも可能にはなりますしね。折角のテンプレートだし贅沢にいきましょう。また、UseCase のテストも書いていませんが、これは今回の場合は Controller のテストとほぼ重複してしまうからです。これも適宜書くべき時に書けばいいと思います。

また、以下のようにテストでも DI コンテナへ登録して使用していますが、これは単純にモックを用意するより楽だからです。今後データベースコンテナーが増えてくるとテストが重くなってくるはずなので、場合によっては DI コンテナに登録せずに使用する方がパフォーマンス的には優れてますが、それもまぁ良し悪しですね。状況によって使い分ければよいと思います。

[TestInitialize]
public async Task Setup() {
    // ここでDBを作って
    var dbInitializer = new CosmosDbInitializer(TestContext.Properties["CosmosDBConnection"]?.ToString(), TestContext.Properties["CosmosDb"]?.ToString() + nameof(CompanyRepositoryTest));
    // DIコンテナに登録
    var host = new HostBuilder()
        .ConfigureFunctionsWebApplication(worker => Startup.ConfigureFunctionsWebApplication(worker))
        .ConfigureServices(services => Startup.ConfigureServices(services, dbInitializer, isCleanUp: true))
        .Build();
    var serviceProvider = host.Services;
    // DIコンテナから引っ張ってきて使用する
    _container = serviceProvider.GetRequiredService<ICompanyContainer>().Container;
    _repository = serviceProvider.GetRequiredService<ICompanyRepository>();
}

なお、並列テストに対応するために、テストクラス別にデータベースを分けるようにしています。これをやっておかないとデータベースの情報が入り交ざってテストがうまくいきません。

Factories

Bogus というパッケージを用いて任意のデータクラスを用意してくれる優れものです。これを使ってテスト時のデータを作成します。

using Bogus;
using Data;

namespace Test.Factories
{
    public static class CompanyFactory
    {
        public static List<Company> Generate(int count = 1) {
            var fakerCompany = new Faker<Company>("ja")
                .StrictMode(true)
                .RuleFor(o => o.Id, f => Guid.NewGuid().ToString())
                .RuleFor(o => o.Name, f => f.Company.CompanyName())
                .RuleFor(o => o.Category, f => f.PickRandom<Company.CategoryDatas>())
                .RuleFor(o => o.CreatedAt, f => f.Date.Past(2))
                .RuleFor(o => o.UpdatedAt, f => f.Date.Past(2));

            return fakerCompany.Generate(count);
        }
    }
}

使う時はこんな感じ

var targetCompanies = CompanyFactory.Generate(10); // 10 個の Company を作成する
await Task.WhenAll(targetCompanies.Select(company => _companyContainer.CreateItemAsync(company, new PartitionKey(company.Category.ToString())))); //DBに差し込む

// 以下テストを記述

ランダムに値を詰め込んでくれるので便利ですが、なんか値が変なので注意が必要です。日本語だからかも?まぁテストに使う分には申し分ないですね。

まとめ

最強の~なんてうたってしまって大丈夫なのかと言う気もしますが、なかなか贅沢な仕上がりになったんじゃないかと個人的には満足しております。現時点で私がお出しできる最強であることには間違いないかな。ただ、あくまで現段階なので、これからこのテンプレートを使用して開発をしていくにあたりいろんな改善点が見つかるかと思いますので、その時は随時更新できたらと思います。

最初にも書きましたが、もしここはこうした方が良いなどの話がありましたら、是非ともお知らせください。よろしくお願いします。

ふぅ・・・やっと個人開発に入れるぞ!!!!

Azure Functions の HttpTrigger で受け取った値に対して自在にバリデーションをする

お疲れ様です。放浪軍師です。今回は Azure Functions の Http Trigger のバリデーションについてです。Webアプリを早く作りたいのに色々と調べる事が多くて進まねーな!!!

Http Trigger のバリデーション

受け取った値を検査して、通してよいかどうかを判別することをバリデーションと呼んだりするのですが、Http Trigger で受け取った値はどうやればバリデーションを行う事ができるのかがわからなかったので調べてみました。

System.ComponentModel.DataAnnotations を使用する

一般的には以下のように System.ComponentModel.DataAnnotations を用いるようです。簡単で良いですね。

using System.ComponentModel.DataAnnotations; // ←これを追加
using System.Text.Json.Serialization;

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

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

        [Required] // ←これを追加
        [JsonPropertyName("name")]
        public string? Name { get; set; }
    }
}
using Api.Validators.Companies;
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.ComponentModel.DataAnnotations; // 追加
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

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

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

        [Function(nameof(PostCompany))]
        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.");
            }

            // -- 追加 --
            var validationResults = new List<ValidationResult>();
            var validationContext = new ValidationContext(company, null, null);
            if (!Validator.TryValidateObject(company, validationContext, validationResults, true)) {
                return new BadRequestObjectResult(validationResults);
            }
            // -- ここまで --

            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);
        }
    }
}

これだけでバリデーションを行ってくれます。レスポンスにメッセージを自動で付けてくれるので、非常に便利ですね!!!また、Required (必須) 以外にもいろいろ用意されているようです。

もっと自在にバリデーションしたい

しかし上記のやり方だと、クラスではない値 をバリデーションする場合や、トリガーによってバリデーション内容を変える なんて事が対応できないはずです。その場合どうするんだろうと思って調べてみました。…が、よさげな記事が見つからなかったので自分で考えてみました。

GitHub

前回使用したブランチを改修しました。

github.com

構成

まずディレクトリ構成を変更しました。こんな感じ。

HttpTrigers ディレクトリと Validators ディレクトリを作成し、それぞれを管理するようにしています。

Validator

Validator クラスでは、Validate メソッドにて検証したい値を渡して、その内容によってバリデーションを行い、問題がある場合は IReadOnlyList にメッセージを返します。

using Data;
using System.ComponentModel.DataAnnotations;

namespace Api.Validators.Companies
{
    public class PostCompanyValidator
    {
        public IReadOnlyList<ValidationResult> Validate(Company company) {
            var results = new List<ValidationResult>();

            if (string.IsNullOrWhiteSpace(company.Name)) {
                results.Add(new ValidationResult($"{nameof(Company.Name)} は必須です。"));
            }
            if (company.Category == null) {
                results.Add(new ValidationResult($"{nameof(Company.Category)} は必須です。"));
            }
            if (company.Name == "つけもの") {
                results.Add(new ValidationResult($"ただし{company.Name} テメーはダメだ。"));
            }
            if (company.Name == "キマリ") {
                results.Add(new ValidationResult($"{company.Name} は通さない。"));
            }

            return results;
        }
    }
}

DI コンテナに登録

前述の Validator クラスは Program.cs にて DI コンテナに登録しておきます。かなり増えそうなので自動でやってくれると嬉しいんですけどね。情報お待ちしております。

using Api.Validators.Companies;
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;
        });
        services.AddSingleton<PostCompanyValidator>(); //追加
    })
    .Build();

host.Run();

HttpTrigger

コンストラクタで先ほど登録した Validator クラスを DI して Validate メソッドを使用します。レスポンスに値が含まれていれば処理を中断してバリデーション内容を返す仕組みです。

using Api.Validators.Companies;
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.HttpTriggers.Companies
{
    public class PostCompany
    {
        private readonly ILogger<GetCompanies> _logger;
        private readonly CosmosClient _cosmosClient;
        private readonly PostCompanyValidator _validator;

        public PostCompany(ILogger<GetCompanies> logger, CosmosClient cosmosClient, PostCompanyValidator validator) { // コンストラクタで Validator を DI する
            _logger = logger;
            _cosmosClient = cosmosClient;
            _validator = validator;
        }

        [Function(nameof(PostCompany))]
        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.");
            }

            // -- 追加 --
            var validationResults = _validator.Validate(company);
            if (validationResults.Any()) {
                return new BadRequestObjectResult(validationResults);
            }
            // -- ここまで --

            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);
        }
    }
}

動作確認

こうすることによりトリガー別にバリデーションを自在に扱う事ができました。

単体テスト

ついでなので、validator の単体テストも書いてみましょう。

using Api.Validators.Companies;
using Data;

namespace Test.Api.Validators.Companies
{
    [TestClass]
    public class PostCompanyValidatorTest
    {
        private PostCompanyValidator _validator = new();

        [TestInitialize]
        public void Setup() {

        }

        [TestMethod]
        public void Validate_正常() {
            var company = new Company { Name = "Company", Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(0, results.Count);
        }

        [TestMethod]
        public void Validate_Nameが空() {
            var company = new Company { Name = string.Empty, Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

        [TestMethod]
        public void Validate_Categoryがnull() {
            var company = new Company { Name = "Company", Category = null };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

        [TestMethod]
        public void Validate_ただしつけものテメーはダメだ() {
            var company = new Company { Name = "つけもの", Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

        [TestMethod]
        public void Validate_キマリは通さないi() {
            var company = new Company { Name = "キマリ", Category = Company.CategoryDatas.User };
            var results = _validator.Validate(company);
            Assert.AreEqual(1, results.Count);
        }

    }
}

簡単ですね。

まとめ

こういう事は必須だと思うんですけど、なんで記事が見当たらないんでしょうか?もしかしたら私が見つけられていないだけで、もっと良い方法があるかもしれないのですね。ご存じの方は掲示板にでもご一報ください。

分離ワーカーモデルを使って、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.example を local.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 トリガーです。クエリパラメータによって、name や category を絞り込めるようにしています。ここの 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 開発頑張るぞー!!!!

何故、人はテスト駆動開発を行うのか?

こんにちは。そろそろこの恥ずかしいハンドルネームから解放されたいと思っている放浪軍師です。

さて本日は、タイトルにあるように 何故テスト駆動開発を行うのか? について書いてみようと思います。前々から書いてみたかったこの話題なのですが、結構マサカリが飛びそうで怖いです。でも勇気をもって公開させていただきたいと思います。反論や訂正などがございましたらどうぞご指摘ください。暴言じゃ無い限りは歓迎致します。

そもそも何故テストを書くのか?

さて、本題の前にそもそも 何故テストを書くのか? という事から抑えていきたいと思います。

コスト面から考える自動テスト

先日 X (twitter) にて、 何故手動テストの方がコストがかからない場合でも自動テストを書くんですか? というようなツイートがバズっているのを見かけました。様々な意見があるとは思いますが、私が出す答えは、そもそも手動テストはコスト激高で手動テストの方がコストがかからないなんてことはありえないので、書ける限り自動テストを書くべき になります。

テストは動かし続けてこそ意味がある

テストというのはそもそも対象がどんなに簡単な機能であったとしても、それが 1 回だけで済むなんてことは絶対にありえません。何故なら、どの作業がその機能に影響を与えるかは誰にもわからないから です。

例えば 1 + 1 の解を返すような単純なメソッドであったとしても、そのメソッドが急に想定外の動きをするという可能性は十分にありえます。たとえば 該当メソッドのあるファイルを誤って消してしまったら どうでしょう?当然対象メソッドは動かなくなりますよね。誤って消したのにそのメソッドを手動でテストしますか?しないはずです。だって誤って消したんですから。

そう、テストというのは 1 回きりでは意味がなく、何かを変更するたびにすべての機能が正しいことを確認し続けて初めて意味があります 。何かを変更するたびにすべての機能を手動でテストし続けますか?そのコストは計り知れないものになるでしょう。よって、コスト面から考えた場合でもテストは可能な限り書くべき という事になると私は考えています。

余談ですが、手動でテストしてその結果をキャプチャして Excel に貼り付けて提出みたいなのが嫌われる理由はこれです。その Excel は その瞬間には正しかった(っぽい) ということの証明にしかなりません。エンジニアの心を折る以外の意味はないので本当にやめましょう。

開発面から考える自動テスト

開発側の観点としては、保守性と拡張性が高まるというのが非常に重要な役目だと考えます。

テストコードは要件そのものである

ちょっとわかりにくいと思うので例を出すと、ある機能に対するテストに、 Aならαを返す Bならβを返す という2つの単体テストを書いているとします。これはまさにこの機能が満たすべき要件そのものであり、しかもそれが非常に解りやすい形で残されることになります。例えばこんな感じ。

<?php

public function test_method(): void
{
    $hoge = new Hoge();
    $result = $hoge->method('A'); // Aなら
    $this->assertEquals('α', $result); // αを返す
    $result = $hoge->method('B'); // Bなら
    $this->assertEquals('β', $result); // βを返す
}

この解りやすい形で残されるというのが非常に重要で、例えば 1 年後この機能に Cならγを返す という要件が追加されるとしましょう。この場合、この機能の要件は、Cならγを返す だけではなく、元からあった Aならαを返す Bならβを返す も満たすになります。この際にテストコードがあれば、安全に 3 つの要件を抑えたコードが書けるはずです。

<?php

public function test_method(): void
{
    $hoge = new Hoge();
    $result = $hoge->method('A'); // Aなら
    $this->assertEquals('α', $result); // αを返す
    $result = $hoge->method('B'); // Bなら
    $this->assertEquals('β', $result); // βを返す
    // 追加するだけ
    $result = $hoge->method('C'); // Cなら
    $this->assertEquals('γ', $result); // γを返す
}

ですがもしテストコードが無かったら、1 年前に定義された元々の要件をちゃんと把握しきれずに、Cならγを返す は満たしているけど、Aならαを返す Bならβを返す が満たされないような実装をしてしまうかもしれません。もちろん実装コードから元の要件を読み取ることも可能ではあるのでしょうが、それが簡単では無いことも少なくは無いはずです。

このようにテストコードを書くということは、機能に対して要件を安全に追加できるということであり非常に大きなメリットとなります。

単体テストを書きやすくすると最終的にはクリーンアーキテクチャになる

単体テストを書く際に気をつけなければならないのは、クラス同士を祖に保つことです。たとえば、以下のような実装は避けなければなりません。

<?php

class Hoge {
     public function hoge() : string 
     {
          $fuga = new Fuga();
          return 'hoge' . $fuga->fuga();
     }
}

この場合、 hoge メソッドは Fuga クラスに依存してしまっているため、Fuga クラスに変更が入った際にもろに影響を受けてしまうし、なにより Fuga クラスがデータベースやストレージなどの外部サービスを参照している場合はその外部の状況までも再現しておく必要が出てきたりして非常に面倒です。そのため、以下のように外部から依存するクラスを注入してやる必要があります。これを Dependency Injection (DI:依存性の注入)といいます。

<?php

class Hoge {
     public function hoge(Fuga $fuga) : string 
     {
          return 'hoge' . $fuga->fuga();
     }
}

上記のやり方は、メソッドで注入していますが、コンストラクタで注入してやる方法もあります。

<?php

class Hoge {

     public function __construct(private Fuga $fuga) {
     }

     public function hoge() : string 
     {
          return 'hoge' . $this->fuga->fuga();
     }
}

ただ、これではまだ足りません。結局は外部のクラスに依存しているからです。そこで、インターフェースを使い、そちらに依存させるように書き換えます。

<?php

class Hoge {

     public function hoge(IFuga $fuga) : string 
     {
          return 'hoge' . $fuga->fuga();
     }
}

こうするとクラスではなくインターフェースに依存するので、別のクラスを用意して挙動をでっちあげる事ができるようになります。

<?php

class MockFuga implements IFuga
{
     public function fuga(): string
     {
          return 'fuga';
     }
}

public function test_hoge() : void
{
     $hoge = new Hoge();
     $fuga = new MockFuga();
     $result = $hoge->hoge($fuga);
     $this->assertEquals('hogefuga', $result);
}

こうすることで、注入されたクラスに依存する事なくテストを行う事ができます。やったね!

さて、ここで勘の良い人なら気が付きます。あれ?これって・・・クリーンアーキテクチャができ上がってね?

はい。そのとおりです。

クラス同士をインターフェースを用いて疎結合して関心を分離し、差し替えやすくするということはそれ即ちクリーンアーキテクチャです。

そう、クリーンアーキテクチャとは、単体テストを突き詰めていくと辿り着ける設計だったんだよ! (凸凸凸凸<な、なんだってー!!!)

いやまぁこれは実際は逆の話で、クリーンアーキテクチャだとテストが書きやすいというのが正しいですし、色々と要素が足りないのでちょっと強引な物言いです。ごめんなさい。しかし、テストの観点からもクリーンアーキテクチャの良さがわかるという感じの説明だと捉えていただけると幸いです。

なお、クリーンアーキテクチャに関しては @nuits_jp さんが非常にわかりやすく説明されていますので参考にされてください。

www.nuits.jp

まぁ尤も、私はクリーンアーキテクチャを厳密に守る必要はなく、インターフェースは使わずにクラスを注入して、テストではモックを使うという程度に留めて良いと考えています。依存の向きは守るべきですけどね。

<?php

public function test_hoge() : void
{
     // モックを立てて
     $fuga = Mockery::mock(Fuga::class);
     $fuga->shouldReceive('fuga')
          ->once()
          ->andReturn('fuga'); // `レスポンスを偽装して

     $hoge = new Hoge();
     $result = $hoge->hoge($fuga); // DI する
     $this->assertEquals('hogefuga', $result);
}

なんせ毎回インターフェースを切るのは超面倒ですし、今日日モックが存在しない環境というのもまぁ無いでしょう。一部の実装をまるっと差し替えるなんてことも滅多に無いことでしょうしね。その辺は割り切っても良いかなと思います。

何故テスト駆動開発を行うのか?

さて、長くなりましたがここからが本題。何故テスト駆動開発を行うのか? です。なお、テスト駆動開発というのはざっくり言うと、先にテストコードを書いた後に実装コードを書いていく開発手法 のことを指します。

テストを先に書くと作業に集中できる

機能を実装する際に考えること。それはこの機能がどのような要件を果たすべきなのかという事になるはずです。

例として、とあるメソッドを実装するとしましょう。このメソッドが満たすべき要件は、 Aならαを返す と Bならβを返す です。

この際、先に実装する場合ならこんな感じになるはずです。

【手順1】凸<(このメソッドの要件は、えーっと、Aならαを返す と Bならβを返す だよなぁ・・・実装してみよう)

<?php

class Hoge{
     public function method(string $value): string
     {
          return match ($value) {
               'a' => 'α',
               'b' => 'β',
          } 
     }
}

【手順2】凸<(よし、次はテストだな。要件は、、、そうそう、Aならαを返す と Bならβを返す だったわ)

<?php

public function test_method():void
{
     $hoge = new Hoge();
     $result = $hoge->method('A'); //Aなら
     $this->assertEquals('α', $result); //αを返す
     $result = $hoge->method('B'); //Bなら
     $this->assertEquals('β', $result); //βを返す
}

【手順3】凸<(完成した!)

こんな感じです。一見なんの問題ないようにも見えますね。次に、先にテストから書く場合を考えてみましょう。

【手順1】凸<(このメソッドの要件は、えーっと、Aならαを返す と Bならβを返す だよなぁ・・・テストを書いてみよう)

<?php

public function test_method():void
{
     $hoge = new Hoge();
     $result = $hoge->method('A'); //Aなら
     $this->assertEquals('α', $result); //αを返す
     $result = $hoge->method('B'); //Bなら
     $this->assertEquals('β', $result); //βを返す
}

【手順2】凸<(よし、次は実装だな。要件はテストに書いてある、Aならαを返す と Bならβを返す だね)

<?php

class Hoge{
     public function method(string $value): string
     {
          return match ($value) {
               'a' => 'α',
               'b' => 'β',
          } 
     }
}

【手順3】凸<(完成した!)

どうでしょう?違いは分かりましたでしょうか?

実装を先に行う場合であっても、要件を出さなければ実装はできません。そのため、頭の中に要件を思い描きながら実装を行う事になるので、要件出しと実装を同時に進める という並列作業になってしまいます。そうなると要件出しだけに集中するという事にはならないので、必要だった要件が抜け落ちてしまう可能性が高まります。要件が複雑になればなるほどその可能性は高くなるでしょう。また、テストコードを作成する際も頭に思い描いただけの要件をすべて正しく並べきる事ができるかは正直怪しいところです。

一方テストを先に書く場合は、初めに この機能を実装するにあたり必要な要件は何なのか? に対してだけ集中する事が可能となります。この時点では実装方法とかどうでもいいからです。そして、その要件はテストコードに記述するので、後から思い出す必要もありません。実装中はテストが通る事だけ考えて実装に集中する 事ができます。つまり、並列作業にはならないのです。

これは大変に大きな違いで、テスト駆動開発であれば、要件出しと実装それぞれに対して真摯に向き合える のです。これがテスト駆動開発の1番のメリットだと私は考えています。

どっちにせよテストを書くのですから、実装から先に行なっている方は一度このテスト駆動開発を試してみてはいかがでしょうか?

まとめ

本題の方が短いやんけ!

Azure Static Web Apps の認証と認可をローカル開発環境で試したい

こんにちは。最近個人開発に励んでいる、そこそこ忙しい身の放浪軍師です。さて今回は、その個人開発でも使いたい Azure Static Web Apps のローカル環境準備についてです。

Azure Static Web Apps の認証と認可をローカル開発環境で試したい

サーバーレスなサービスを気軽に提供できる Azure Static Web Apps には、認証と認可の機能が実装されています。非常に簡単に使用できるので是非とも採用したい機能です。

learn.microsoft.com

ただ、これをローカルで試すにはどうすればよいのでしょうか?

幸いにも以下に公式ドキュメントがありますので、Blazor Web Assembly と Azure Functions で構成した Web サービスで試してみたいと思います。

learn.microsoft.com

手順

Blazor Web Assembly と Azure Functions で構成した Web アプリケーションを準備する

以前やった事があるので割愛します。こちらを参照してください。

www.gunshi.info

Azure Static Web Apps CLI のインストール

Azure Static Web Apps をローカルで再現するためのツールです。

learn.microsoft.com

私の場合は、nmp を使ってインストールを行いました。コマンドは以下です。

npm install -g @azure/static-web-apps-cli

Azure Static Web Apps CLI で Web アプリを起動する

手順は以下になります。

  1. Visual Studio で Blazor Web Assembly と Azure Functions をマルチスタートアップで起動する

    方法は以下を参考にしてください。

    www.gunshi.info

  2. コンソールから CLI を立ち上げる

    以下コマンドで起動できます。

    swa start http://localhost:5030 --api-location http://localhost:7071 --verbose

    この時指定する二つのポートですが、最初の 5030 が Blazor Web Assembly のポートで、7071 が Azure Functions のポートとなりますが、Blazor Web Assembly の方は実は https と http で起動していることが手順1.で開くコンソールでわかります。

    このうち、http の方を指定しないとエラーとなりますので注意が必要です。起動が成功すると成功すると、以下のようなメッセージがコンソールに表示されます。

     [swa] - Waiting for http://localhost:7071 to be ready
     [swa] √ http://localhost:7071 validated successfully
     [swa]
     [swa] Using dev server for static content:
     [swa]   http://localhost:5030
     [swa]
     [swa] Using dev server for API:
     [swa]   http://localhost:7071
     [swa]
     [swa] Azure Static Web Apps emulator started at http://localhost:4280. Press CTRL+C to exit.
     [swa]
     [swa]
    

    表示されている http://localhost:4280 にブラウザでアクセスすると、Azure Static Web Apps にデプロイされた風のエミュレータが起動します。

  3. 認証を試してみる。

    ブラウザで、http://localhost:4280/.auth/login/aad にアクセスしてみます。そうすると以下のような画面に遷移します。

    この画面で認証を通すユーザーを作ってそのままログインするイメージです。Login ボタンを押すと、Web アプリケーションに戻ります。ここで、http://localhost:4280/.auth/me にアクセスしてみると、以下のような json を取得できます。

     {"clientPrincipal":{"userId":"1fbf62934cb3d4d0493fdf8025a0c42b","userRoles":["anonymous","authenticated"],"identityProvider":"aad","userDetails":"aaaaaa","claims":[]}}
    

    ログインしたユーザーやロールを識別したりできるわけです。なお、http://localhost:4280/.auth/logout にアクセスするとログアウトとなり、http://localhost:4280/.auth/me にアクセスしても、

     {"clientPrincipal":null}
    

    という返答を受け取ることになります。

まとめ

これでローカル開発環境で Azure Static Web Apps の認証と認可を試すことができるようになります。便利ですね!

実はかつて Blazor Web Assembly の記事を量産していた頃にこの Azure Static Web Apps CLI は試していたはずなのですが、記事にするのをすっかり忘れていたようで、再度一から調べなおすことになってしまいました。いやぁ、やっぱり記事にしてアウトプットしておくというのは非常に大事ですねぇ。みんなも忘れないようにレッツアウトプット!ですよ!

何もしてないのに Docker が壊れた (failed: port is already allocated)

こんにちは。実は最近、新しく個人で AI を使った Web サービスをリリースしようかなと企んで頑張っている放浪軍師です。今回は備忘録です。

何もしてないのに Docker が壊れた (failed: port is already allocated)

何もしてないのに Docker が壊れる事。よくありますよね!先日、コーディング中なんか調子が悪かったので、docker compose restart をやってみると以下メッセージが発生。

Error response from daemon: driver failed programming external connectivity on endpoint container1 (-----): Bind for 0.0.0.0:4443 failed: port is already allocated

ふむふむ、ポートが既に既に割り当てられている。と。。。んなわけあるかーい!今の今まで普通に使えてたポートなのになんで他で使われてるなんてことになるんじゃーい!って事ですよ。

一応使用中のポートを調べてみましたが該当ポートは使用中ではありませんでした。まぁ恐らくなんか調子が悪くて、半端に停止してしまったのが邪魔しているんだと推測されます。

ただこやつが結構強情で、コンテナなどを一旦全削除して作り直したりしても同じエラーがでます。なんやかんや試してみて、結局以下の手法で解決しました。

解決方法

  1. コンテナを全部消す(不要かもしれんが一応 images や volumes も消す)

  2. OS再起動(たぶんこの時に宙に浮いた port 使用情報がリセットされるので必須)

  3. docker compose build --no-cache

  4. docker compose up -d

まとめ

何もしてないのに壊れることはあるよ!

Laravel でバックエンド 【N+1問題編】

こんにちは。ブログをさぼりにさぼっている放浪軍師です。最近業務で N+1 問題解決のタスクが発生したのでまとめてみました。

N+1 問題とは?

まずそもそも N+1 問題とは何ぞやという話ですが、似たような SQL クエリを連発するような状態の事を指します。例えば、以下で簡単に発生させることが可能。

<?php

public function test_example(): void
{
    //company 1:N user の関係
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    DB::enableQueryLog(); //ここからのSQLを確認
    $companies = Company::get();
    foreach ($companies as $company) {
        $users = $company->users;
    }
    dump(DB::getQueryLog()); //ここまでのSQLを確認
}

この際、発行される SQL は以下のようになります。

"query" => "select * from `companies`"
"query" => "select * from `users` where `users`.`company_id` = 1 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 2 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 3 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 4 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 5 and `users`.`company_id` is not null"

$users = $company->users; の部分で select * from `users` where `users`.`company_id` = ? and `users`.`company_id` is not null が連打されているのがわかりますね。これが N+1 問題です。

eager loading で N+1 を回避する

さて、前述の例の場合、以下のようにすると N+1 を回避できます。

<?php

public function test_example(): void
{
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    DB::enableQueryLog();
    $companies = Company::with(['users'])->get(); //← with() でリレーション先までを読み込む。
    foreach ($companies as $company) {
        $users = $company->users;
    }
    dump(DB::getQueryLog());
}

この際、発行される SQL は以下のようになります。

select * from `companies`
select * from `users` where `users`.`company_id` in (1, 2, 3, 4, 5)

with() でリレーション先まで読んでおく事によって、N+1問題を解決できます。この先読み込みを eager (イーガー) loading と言います。

まぁ、これは基本中の基本でして、色んなブログに説明が転がっていますね。Resource を用いている場合なんかは特に見落としがちなので気を付けましょう。

複数 update で N+1 を回避する

さてここからが微妙に見かけない N+1 問題になります。複数のレコードをアップデートする際は、以下のように書いていませんでしょうか?

<?php

public function test_example2(): void
{
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    // PATCH API で投げ込まれたアップデートするデータ
    $updateUsers = [
        ['id' => 1, 'name' => 'ア'],
        ['id' => 2, 'name' => 'イ'],
        ['id' => 3, 'name' => 'ウ'],
        ['id' => 4, 'name' => 'エ'],
        ['id' => 5, 'name' => 'オ'],
    ];

    DB::enableQueryLog();
    foreach ($updateUsers as $updateUser) {
        $user = User::find($updateUser['id']);
        $user->update($updateUser);
    }
    dump(DB::getQueryLog());
}

この際、発行される SQL は以下のようになります。

select * from `users` where `users`.`id` = 1 limit 1
update `users` set `name` = `ア`, `users`.`updated_at` = ? where `id` = 1
select * from `users` where `users`.`id` = 2 limit 1
update `users` set `name` = `イ`, `users`.`updated_at` = ? where `id` = 2
select * from `users` where `users`.`id` = 3 limit 1
update `users` set `name` = `ウ`, `users`.`updated_at` = ? where `id` = 3
select * from `users` where `users`.`id` = 4 limit 1
update `users` set `name` = `エ`, `users`.`updated_at` = ? where `id` = 4
select * from `users` where `users`.`id` = 5 limit 1
update `users` set `name` = `オ`, `users`.`updated_at` = ? where `id` = 5

select * from `users` where `users`.`id` = ? limit 1 が N+1 してますね。まぁ明らかに User::find($updateUser['id']) がループしてますからね。でも意外と見逃しがちだったりしますので注意が必要です(戒め)。この場合、以下のように書くべきです。

<?php

public function test_example2(): void
{
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    // PATCH API で投げ込まれたアップデートするデータ
    $updateUsers = [
        ['id' => 1, 'name' => 'ア'],
        ['id' => 2, 'name' => 'イ'],
        ['id' => 3, 'name' => 'ウ'],
        ['id' => 4, 'name' => 'エ'],
        ['id' => 5, 'name' => 'オ'],
    ];

    DB::enableQueryLog();
    $users = User::whereIn('id', collect($updateUsers)->pluck('id')->toArray())->get()->keyBy('id');
    foreach ($updateUsers as $updateUser) {
        $users[$updateUser['id']]->update($updateUser);
    }
    dump(DB::getQueryLog());
}

発行される SQL は以下のようになります。

select * from `users` where `id` in (1, 2, 3, 4, 5)
update `users` set `name` = `ア`, `users`.`updated_at` = ? where `id` = 1
update `users` set `name` = `イ`, `users`.`updated_at` = ? where `id` = 2
update `users` set `name` = `ウ`, `users`.`updated_at` = ? where `id` = 3
update `users` set `name` = `エ`, `users`.`updated_at` = ? where `id` = 4
update `users` set `name` = `オ`, `users`.`updated_at` = ? where `id` = 5

先に whereIn() を使って更新する User をすべて取得しておく訳ですね。

ちなみに、keyBy('id') をしている理由ですが、foreach 内で簡単に id で取得できるようにするためです。これがない場合は以下のようになります。

<?php

$users = User::whereIn('id', collect($updateUsers)->pluck('id')->toArray())->get();
foreach ($updateUsers as $updateUser) {
    $users->firstWhere('id', $updateUser['id'])->update($updateUser);
}

ただ、この書き方だと、$users->firstWhere() が $users を全舐めするので、実質 foreach in foreach となってしまい高負荷です。こういう小技は後々効いてきますので積極的に採用しましょう。

FormRequest で N+1 を回避する

正直これを書きたくて今回記事を書きました。FormRequest は API で受け取ったデータを便利にバリデーションしてくれる素敵な機能です。例えば以下のように書きます。

<?php

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;

class UserUpdateRequest extends FormRequest
{

    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'user_type_master_id' => ['required', 'exists:user_type_masters,id']
        ];
    }
}

ユーザーのタイプを変更できる api のバリデーションでこういうのを書くことがあると思います。以下でこの api のテストをしてみます。

<?php

public function test_example3(): void
{
    //user_type_masters テーブルに値を入れる
    $master = UserTypeMaster::create(['name' => "master"]);
    $powerUser = UserTypeMaster::create(['name' => "power_user"]);
    $general = UserTypeMaster::create(['name' => "general"]);
    //会社作る
    $company = Company::create(['name' => "company_name"]);
    //その会社にユーザーを所属させる。ユーザータイプは master
    $user = User::create(['company_id' => $company->id, 'user_type_master_id' => $master->id, 'name' => "user_name"]);

    DB::enableQueryLog();
    //ユーザーのタイプを power_user に変更する
    $body = [
        'user_type_master_id' => $powerUser->id
    ];
    $response = $this->patch("/api/users/$user->id", $body);
    dump(DB::getQueryLog());
}

バリデーション時に発行される SQL は以下のようになります。

select count(*) as aggregate from `user_type_masters` where `id` = 2

user_type_masters テーブルに id = 2 が存在することを確認しているのがわかります。ここまでは問題ありませんね。では、以下のようなパターンはどうでしょう?

<?php

class UserBatchUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'users.*.user_type_master_id' => ['required', 'exists:user_type_masters,id']
        ];
    }
}

複数名のユーザーのタイプを変更できる api のバリデーションだとこう書くかなと思いますよね。以下でこの api のテストをしてみます。

<?php

public function test_example4(): void
{
    //user_type_masters テーブルに値を入れる
    $master = UserTypeMaster::create(['name' => "master"]);
    $powerUser = UserTypeMaster::create(['name' => "power_user"]);
    $general = UserTypeMaster::create(['name' => "general"]);

    //会社作る
    $company = Company::create(['name' => "company_name"]);
    //その会社にユーザーを複数所属させる。ユーザータイプは全員 master。
    for ($i = 0; $i < 5; $i++) {
        $users[] = User::create(['company_id' => $company->id, 'user_type_master_id' => $master->id, 'name' => "user_name_$i"]);
    }

    DB::enableQueryLog();
    //ユーザー全員のタイプを power_user に変更する
    $body = [
        'users' => collect($users)->map(fn ($user) => [
            'id' => $user->id,
            'user_type_master_id' => $powerUser->id
        ])->toArray()
    ];
    $response = $this->patch("/api/users", $body);
    dump(DB::getQueryLog());
}

バリデーション時に発行される SQL は以下のようになります。

select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2

はい、N+1 ですね。ご丁寧にも受け取った user_type_master_id 一つ一つに対して確認を取っています。/(^o^)\ナンテコッタイ!正直な話、これは laravel がよしなにやってよと思いますが、仕方ないので以下のように書き直しましょう。

<?php

class UserBatchUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        $userTypeMasters = UserTypeMaster::get(); //ここでレコードを取得しておく
        return [
            'users.*.user_type_master_id' => ['required', Rule::in($userTypeMasters->pluck('id'))] //Rule::in() を使って id のいずれかと一致しているかを確認。
        ];
    }
}

こうすれば各 user_type_master_id のバリデーションでは SQL を発行しませんので、

select * from `user_type_masters

だけで済みます。

いやぁ、これ私は今の今まで完全に見落としていましたよ。なお、同様に配列で受け取った値に対してカスタムバリデーションを行う場合も似たような工夫が必要になるので注意してください。

まとめ

ということで、N+1 問題編でした。laravel で発生する N+1 問題はだいたいこの 3 パターンなんじゃないかなぁと思います。みんなも確認してみてね!約束だよ!