実務Webアプリ開発編です。今回は、Seedデータ(初期データ)をどう入れるかを、EF Coreの例で整理します。

前回は、CQRSのクエリ側の書き方をみました。今回は、永続化とCQRSのパートの締めくくりとして、DBに最初に入れておくデータを扱います。

以下のような方に役立つ内容となっています。

この記事が役に立つ方

  • 最初の管理者や開発用のサンプルデータを、どこでどう入れるか迷っている
  • 起動のたびにSeedを実行して、データが増えたり例外になったりしないか不安
  • 本番に入れるデータと、開発用のデータをどう分けるか整理したい

以下のようなMentorAppを題材として進めます。

題材とするMentorApp

GitHubにドキュメント・コードの一式があります。

prota-p/MentorApp: Blazor Server × クリーンアーキテクチャ × DDD の学習用リファレンス実装(実務Webアプリ開発編の題材)

初期データは、要件定義の回で要件の一つとして挙げていました。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (11) 要件定義の書き方 ~ロール・機能・非機能・対象外をどう整理するか 実務Webアプリ開発編です。 前回の「ソフトウェア開発の全体像」では、要件定義からデプロイまでの流れを俯瞰しました。 https...
プロ太

Seedは地味ですが、大事な仕組みです。本番は最初の管理者がいて、はじめて使い始められます。開発は試すデータがあれば、すぐ画面で確かめられます。

Seedデータは目的で2つに分ける

まず、Seedデータとは何か、何のために入れるのかを整理します。

Seedデータとは

MentorAppを開発環境で起動して、ユーザー一覧を開いてみます。誰も登録していないのに、ユーザーが7人並んでいます。

MentorAppのユーザー一覧画面(開発環境。初期管理者は設定例の仮の値)

このユーザーたちは、アプリの起動時に入れたものです。このように最初に入れておくデータを、Seedデータと呼びます。「初期データ」と呼ぶこともあります。

よく見ると、管理者が2人います。「初期管理者」はアプリの設定で指定した1人で、本番でも作られます。READMEの手順で設定ファイルをコピーすると、仮の値が入ります。

「管理者(ダミー)」は、開発中に管理画面を試すためのユーザーで、本番にはいません。同じ管理者でも、入れる目的が違うのです。

アプリが動くのに必要なデータと、開発用データ

Seedデータは、入れる目的で次の表の2つに分けられます。

アプリが動くのに必要なデータ開発用データ
例マスタ・既定の設定値・最初の管理者画面を動かすためのダミーのユーザーや投稿
目的アプリを使い始められるようにする開発中に画面を動かして確かめる
入る環境本番を含め、アプリを動かす環境開発環境など(本番には入れない)

アプリが動くのに必要なデータは、マスタや既定の設定値、最初の管理者です。これがないと、本番でもアプリを使い始められません。先ほどの初期管理者は、こちらです。

開発用データは、開発中に画面を動かして確かめるためのものです。先ほどの管理者(ダミー)は、こちらです。

2つは、入る環境が違います。必要なデータは本番を含めて入れ、開発用データは本番には入れません。

プロ美

開発用のデータまで、わざわざSeedで入れるものなの?

プロ太

よくあるやり方です。開発中はDBをよく作り直しますが、起動するだけでデータがそろいます。全員が同じデータで試せるので、不具合も再現しやすくなります。

補足:テストのデータはSeedと分ける

「初期データ」と聞くと、テスト用のデータを思い浮かべる方もいるでしょう。テストのデータをSeedで用意する流儀もあります。

ただ、この連載ではSeedと別に扱います。各テストが自分でデータを用意し、Seedには頼りません。テストは後続のパートで扱います。

最初の管理者は誰が作るのか

必要なデータのうち、最初の管理者には「鶏と卵」の問題があります。

人を管理者にできるのが管理者だけなら、最初の1人を管理者にする人がいません。最初の管理者は、画面からは作れないのです。

そこで、最初の管理者は画面の外から作ります。誰を管理者にするかを設定で指定し、Seedで用意するのがその一つの方法です。

何度実行しても同じ状態にする(冪等)

Seedデータでもう一つ大事なのが、冪等(べきとう)にすることです。

プロ美

冪等ってなに?

プロ太

何度実行しても、同じ状態になることです。Seedなら、同じSeedデータで1回目でも10回目でも、DBの中身が同じになるように書きます。

Seedは何度も実行される

Seedは、起動のたびに実行されます。ネットショップの商品カテゴリを毎回追加すると、3件ずつ増えてしまいます。

// 起動のたびに3件ずつ増える
db.Categories.AddRange(
    new Category("食品"),
    new Category("日用品"),
    new Category("家電"));
await db.SaveChangesAsync();

カテゴリ名に一意制約があれば、増えはしません。そのかわり、2回目の起動で例外になり、アプリが止まってしまいます。

冪等にする3つの書き方

冪等にするには、入れる前に、もうあるかを確かめます。カテゴリを名前で探し、なければ追加します。これで、何度実行しても3件のままです。

foreach (var name in new[] { "食品", "日用品", "家電" })
{
    // 名前で、もうあるかを確かめる
    if (!await db.Categories.AnyAsync(c => c.Name == name))
        db.Categories.Add(new Category(name));
}
await db.SaveChangesAsync();

冪等にする書き方は、大きく分けて次の表の3つです。どれも冪等ですが、Seedデータを変えたときや、DBの行を消したときに違いが出ます。

書き方やり方弱点
空なら全部追加テーブルが空かを確かめ、空ならまとめて追加する1件でもあれば何もしない。足したSeedデータも、消えた行も入らない
なければ追加キーで1件ずつ探し、なければ追加する(あれば何もしない)変えたSeedデータが、既存の行に反映されない。画面で消した行も、また入る
あれば更新、なければ追加(upsert)キーで1件ずつ探し、なければ追加、あればSeedデータどおりに更新する画面で直した値も、実行するたびにSeedデータへ戻る。画面で消した行も、また入る

「空なら全部追加」は、テーブルが空かだけを見ます。1件でもあれば何もしないので、最初に一度入れれば済むデータに向きます。

「なければ追加」は、1件ずつ探し、あれば何もしません。入れたあとは、人が画面で直すデータに向きます。

upsertは、あればSeedデータどおりに更新し、いつもSeedデータと同じにします。値をずっとSeedデータで決めるデータに向きます。

何で同じものを見分けるか

「なければ追加」にも、upsertにも、同じものを見分ける手がかりが要ります。手がかりは、次の表の3つから選びます。

手がかり例使い方
代理キー(サロゲートキー)意味を持たないIDやGUIDSeedの側で値を固定するときだけ使える。DBが自動で振るなら、前もってわからない
自然キー(ビジネスキー)カテゴリの名前、ユーザーのメールアドレス業務の上で同じものを表す。一意制約を付けられる
一意でない値の組トピックのメンタリングと題名重複しうるが、Seedデータの中では見分けられる。一意制約は付けられない

代理キーは、Seedの側でIDを固定したときだけ探すのに使えます。IDまで固定して入れる、変わらないマスタに向きます。

自然キーは、業務の上で同じものを表す値です。一意制約で守れるので、重複が許されないデータに向きます。

modelBuilder.Entity<Category>()
    .HasIndex(c => c.Name)
    .IsUnique();

一意でない値の組は、業務では重複しうる値です。一意制約では守れないので、Seedデータの中で見分けられれば足りるデータに向きます。

EF CoreでのSeedの入れ方

ここまでの考え方は、どのフレームワークでも同じです。EF Coreには、Seedを入れる方法が主に次の表の3つあります。

方法いつ実行されるか向いているもの
UseSeeding(EF Core 9から)EnsureCreatedやマイグレーションの適用のたび汎用のSeed(公式の推奨)
HasDataマイグレーションで反映(EnsureCreatedでは新しく作るときだけ)マイグレーション以外では変わらないマスタ(主キーの指定が必須)
自前の初期化処理アプリの起動時など、自分で呼んだときアプリの構造に合わせたい初期化

UseSeedingは、EnsureCreatedやマイグレーションの適用のたびに呼ばれます。公式の推奨で、汎用のSeedに向きます。

HasDataは、マイグレーションで反映します。主キーの指定が必須で、マイグレーション以外では変わらないマスタに向きます。

自前の処理は、自分で呼んだときに動きます。MentorAppは、環境ごとに入れるものを変え、Seedでリポジトリや設定も使うので、こちらにしています。

補足:起動時のSeedについて

公式は、Seedを通常の起動処理に含めないよう注意しています。MentorAppは学習用で、1台で動かす前提なので、起動時に入れています。

MentorAppの実装をみる

ここからはMentorAppの実装です。MentorAppはロールをenumで持ち、マスタのテーブルがないので、必要なデータは初期管理者だけです。

Seedに関わるファイルは、次の5つです。

src/
├── MentorApp.Infrastructure/
│   ├── Persistence/
│   │   ├── DatabaseInitializer.cs  ← DB作成
│   │   ├── DevelopmentDataSeeder.cs  ← 開発用データ
│   │   └── InitialAdminSeeder.cs  ← 必要なデータ
│   └── InfrastructureInitialization.cs  ← 窓口
└── MentorApp.Web/
    └── Program.cs  ← 呼び分け

Seedのクラスは、Seedデータの2つの種類ごとに分けています。初期管理者はInitialAdminSeeder、開発用データはDevelopmentDataSeederです。

この章では、次の順に確かめていきます。

この章でみること

  1. 本番・開発・テストでSeedを呼び分ける
  2. 初期管理者:設定から作り、管理者がいなければ昇格する
  3. 開発用データ:なければ追加し、ドメインのルールを通す

本番・開発・テストでSeedを呼び分ける

起動時の処理は、Program.csの最後にあります。

// …(サービスの登録など・88 行省略)
// ========================================
// フェーズ3: 起動時処理
// ========================================
try
{
    Log.Information("================================================================================");
    Log.Information($"MentorApp 起動プロセス開始({app.Environment.EnvironmentName})");
    Log.Information("================================================================================");

    // 環境に応じたDB初期化
    if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
    {
        Log.Information("データベースを初期化しています");
        await InfrastructureInitialization.InitializeRequiredDataAsync(app.Services);

        if (app.Environment.IsDevelopment())
        {
            Log.Information("開発環境: サンプルデータを生成しています");
            await InfrastructureInitialization.SeedDevelopmentDataAsync(app.Services);
        }
    }
    else if (app.Environment.IsEnvironment("Testing"))
    {
        // Testing環境:DB初期化はスキップ
        // テストコード(WebApplicationFactory)が明示的にDB作成・削除を管理する
        Log.Information("Testing環境: DB初期化はテストフィクスチャーで管理されます");
    }
    else
    {
        // 想定外の環境名
        Log.Warning("未定義の環境 '{EnvironmentName}' が指定されています。DB初期化をスキップします。", app.Environment.EnvironmentName);
    }

    app.Run();
// …(例外処理・5 行省略)
finally
{
    Log.CloseAndFlush();
}

環境ごとに、Infrastructure層の初期化の入口(窓口)であるInfrastructureInitializationの、どのメソッドを呼ぶかを分けています。図1が、その全体です。

図1: 起動時の流れ(環境ごと)
図1:起動時の流れ(環境ごと)

本番(Production)では、InitializeRequiredDataAsyncだけを呼びます。DBを作り、アプリが動くのに必要なデータ(初期管理者)を用意します。

開発(Development)では、本番と同じ処理に続けて、SeedDevelopmentDataAsyncで開発用データも入れます。

テスト用のTesting環境では、起動時に何もしません。DBはテスト側が作り、終わったら消します。

初期管理者:設定から作り、管理者がいなければ昇格する

MentorAppの初期管理者は、設定のExternalIdで指定した、特定の1人です。ExternalIdは、ログインに使う外部の認証サービス(IdP)でのIDです。

初期管理者は、Adminロールを持つユーザー全般のことではありません。管理者(ダミー)も、後から画面でAdminにした人も、初期管理者ではありません。

初期管理者は、設定(InitialAdmin)のExternalIdがあるときだけ作ります。設定がなければ、何もしません。

    /// <summary>
    /// データベースの作成と初期管理者のシードを実行する
    /// </summary>
    /// <remarks>
    /// Production/Development 環境の両方で実行される。
    /// - データベースの作成(マイグレーション未使用時)
    /// - 初期管理者のシード(設定されている場合)
    /// Web層(Program.cs)から明示的に呼び出す。
    /// </remarks>
    public static async Task InitializeRequiredDataAsync(IServiceProvider services)
    {
        using var scope = services.CreateScope();
        var scopedServices = scope.ServiceProvider;

        // データベース作成
        await DatabaseInitializer.EnsureCreatedAsync(scopedServices);

        // 初期管理者のシード(UoW / Repository 経由)
        var authOptions = scopedServices.GetRequiredService<IOptions<AuthenticationOptions>>().Value;
        if (authOptions.InitialAdmin.IsConfigured)
        {
            var uowFactory = scopedServices.GetRequiredService<IUnitOfWorkFactory>();
            var timeProvider = scopedServices.GetRequiredService<TimeProvider>();
            var loggerFactory = scopedServices.GetRequiredService<ILoggerFactory>();
            var logger = loggerFactory.CreateLogger(typeof(InfrastructureInitialization));

            await InitialAdminSeeder.SeedAsync(
                uowFactory,
                timeProvider,
                authOptions.InitialAdmin.ExternalId!,
                authOptions.InitialAdmin.DisplayName,
                authOptions.InitialAdmin.Email!,
                logger);
        }
    }
{
    …(DBの設定・4 行省略)
    "Authentication": {
        "Provider": "Mock",
        "InitialAdmin": {
            "ExternalId": "YOUR_ENTRA_OBJECT_ID",
            "DisplayName": "初期管理者",
            "Email": "admin@example.com"
        },
        …(認証サービス・ログの設定・20 行省略)
}

初期管理者を用意するのは、InitialAdminSeederのSeedAsyncです。

    /// <summary>
    /// 初期管理者をシードする
    /// </summary>
    /// <remarks>
    /// appsettings.jsonの設定値に基づき、指定されたExternalIdを持つ
    /// Admin権限のユーザーを作成する。本番環境でも使用する。
    /// 対象ユーザーが既に存在する場合はロールを変更しない(意図して降格した管理者を、再起動で戻さないため)。
    /// ただし管理者が1人もいない場合は、画面から回復できないため Admin に昇格する。
    /// </remarks>
    public static async Task SeedAsync(
        IUnitOfWorkFactory uowFactory,
        TimeProvider timeProvider,
        string externalId,
        string displayName,
        string email,
        ILogger logger)
    {
        if (string.IsNullOrWhiteSpace(externalId))
        {
            logger.LogWarning("初期管理者のExternalIdが設定されていません。スキップします。");
            return;
        }

        if (string.IsNullOrWhiteSpace(email))
        {
            logger.LogWarning("初期管理者のEmailが設定されていません。スキップします。");
            return;
        }

        await using var uow = await uowFactory.CreateAsync();

        var existingUser = await uow.Users.FindByExternalIdAsync(externalId);
        if (existingUser != null)
        {
            if (existingUser.Role == Role.Admin)
            {
                logger.LogInformation("初期管理者は既にAdminとして存在します: {DisplayName}", existingUser.DisplayName);
                return;
            }

            if (await uow.Users.HasAnyByRoleAsync(Role.Admin))
            {
                logger.LogInformation("初期管理者のユーザーはAdminではありませんが、ほかにAdminがいるためロールは変更しません: {DisplayName} (Role: {Role})",
                    existingUser.DisplayName, existingUser.Role);
                return;
            }

            var previousRole = existingUser.Role;
            existingUser.ChangeRole(Role.Admin);
            await uow.SaveChangesAsync();

            logger.LogWarning("管理者が1人もいないため、初期管理者のユーザーをAdminに昇格しました: {DisplayName} (ExternalId: {ExternalId}, PreviousRole: {PreviousRole})",
                existingUser.DisplayName, externalId, previousRole);
            return;
        }

        var now = timeProvider.GetUtcNow();
        var admin = new User(externalId, displayName, now, email, Role.Admin);
        await uow.Users.AddAsync(admin);
        await uow.SaveChangesAsync();

        logger.LogInformation("初期管理者を作成しました: {DisplayName} (ExternalId: {ExternalId}, Email: {Email})",
            displayName, externalId, email);
    }

初期管理者にはEmailも要ります。設定のEmailが空なら、警告をログに出して何もせずに戻ります。

判定の流れは図2のとおりです。まず、ExternalIdでユーザーを探し、初期管理者がもういるかを確かめます。

図2: 初期管理者の判定
図2:初期管理者の判定

ExternalIdには一意制約もあり、同じユーザーが二重に登録されるのを、DBが最後に防ぎます。

    private static void ConfigureUser(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>(entity =>
        {
            // Id はアプリ側で生成されるため、ValueGeneratedNeverを指定
            entity.Property(x => x.Id).ValueGeneratedNever();

            entity.Property(e => e.ExternalId)
                .HasMaxLength(User.ExternalIdMaxLength);

            // ExternalIdはIdPが発行するユーザー識別子。
            // 同一ユーザーの二重登録を防ぐため、DB側でも一意性を保証する
            entity.HasIndex(e => e.ExternalId)
                .IsUnique();

            entity.Property(e => e.DisplayName)
                .HasMaxLength(User.DisplayNameMaxLength);

            entity.Property(e => e.Email)
                .HasMaxLength(Email.MaxLength)
                .HasConversion(
                    email => email.Value,
                    value => new Email(value));
        });
    }

見つからなければ、Adminとして作ります。2回目の起動からは見つかるので、作り直しません。

見つかって、すでにAdminなら何もしません。

Admin以外で見つかったときは、ほかにAdminがいればロールを変えません。Seedとしては、Adminが1人でもいれば足りるからです(開発環境では、管理者(ダミー)でもOKです)。

Adminが1人もいなければ、Adminに昇格します。Adminがいないと、画面からは誰もロールを変えられないからです。

プロ美

設定さえあれば、どんなときでもAdminが1人はいるようにしているんだね。

プロ太

冪等の章の書き方でいうと、基本は「なければ追加」で、Adminが0人のときだけロールを直す、という感じですね。

開発用データ:なければ追加し、ドメインのルールを通す

開発用データは、DevelopmentDataSeederが入れます。入れる順は、ユーザー・メンタリング・トピックです。

    /// <summary>
    /// 開発用データをシードする
    /// </summary>
    /// <remarks>
    /// 6名のモックユーザー(admin×1, mentor×2, mentee×3)と関連データを作成する。
    /// </remarks>
    public static async Task SeedAsync(
        IUnitOfWorkFactory uowFactory,
        DateTimeOffset baseTime,
        ILogger logger)
    {
        var timeSeq = new TimeSequence(baseTime);

        var users = await SeedUsersAsync(uowFactory, timeSeq, logger);
        var mentorships = await SeedMentorshipsAsync(uowFactory, users, timeSeq, logger);
        await SeedTopicsAndMessagesAsync(uowFactory, mentorships, users, timeSeq, logger);
    }

メンタリングはユーザーを、トピックはメンタリングを参照するので、この順に入れます。

3つとも「なければ追加」です。それぞれ別に保存するので、途中で失敗すると一部だけ入りますが、次の起動で足りない分が入ります。

upsertにしないのは、画面で直した表示名やロールなどを、起動のたびに戻さないためです。

Seedデータは、3つともrecordの配列で書いています。ユーザーなら、この配列です。

// …(usingと定数・24 行省略)
    private static readonly DevUserDefinition[] DevelopmentUsers =
    [
        new(DevUserIds.Admin1, "管理者(ダミー)", "admin1@example.com", Role.Admin),
        new(DevUserIds.Mentor1, "メンター太郎(ダミー)", "mentor1@example.com", Role.Mentor),
        new(DevUserIds.Mentor2, "メンター次郎(ダミー)", "mentor2@example.com", Role.Mentor),
        new(DevUserIds.Mentee1, "メンティー花子(ダミー)", "mentee1@example.com", Role.Mentee),
        new(DevUserIds.Mentee2, "メンティー桜(ダミー)", "mentee2@example.com", Role.Mentee),
        new(DevUserIds.Mentee3, "メンティー梅(ダミー)", "mentee3@example.com", Role.Mentee)
    ];

    // …(メンタリング・トピックのSeedデータと処理・252 行省略)

冒頭のユーザー一覧のダミーの6人は、この配列から入ったものです。

開発環境のログイン画面には、IdPを通さずに入るボタンがあります。管理者・メンター・メンティーの3人分で、この配列と同じExternalIdを使います。

開発環境のログイン画面

「なければ追加」で探すときの手がかりは、3つで違います。ユーザーは、自然キーのExternalIdで探します。

    private static async Task<UserLookup> SeedUsersAsync(
        IUnitOfWorkFactory uowFactory,
        TimeSequence timeSeq,
        ILogger logger)
    {
        await using var uow = await uowFactory.CreateAsync();
        var addedCount = 0;
        var userDict = new Dictionary<string, User>();

        foreach (var def in DevelopmentUsers)
        {
            var existingUser = await uow.Users.FindByExternalIdAsync(def.ExternalId);
            if (existingUser != null)
            {
                userDict[def.ExternalId] = existingUser;
                logger.LogDebug("開発用ユーザーは既に存在します: {ExternalId}", def.ExternalId);
                continue;
            }

            var user = new User(def.ExternalId, def.DisplayName, timeSeq.Next(), def.Email, def.Role);
            await uow.Users.AddAsync(user);
            userDict[def.ExternalId] = user;
            addedCount++;

            logger.LogDebug("開発用ユーザーを追加しました: {DisplayName} (ExternalId: {ExternalId})",
                def.DisplayName, def.ExternalId);
        }

        if (addedCount > 0)
        {
            await uow.SaveChangesAsync();
            logger.LogInformation("開発用ユーザーをシードしました: {Count}名のユーザーを追加", addedCount);
        }
        else
        {
            logger.LogInformation("開発用ユーザーは全て存在しています。追加なし。");
        }

        return new UserLookup(userDict);
    }

メンタリングは、メンターとメンティーの組で探します。

    private static async Task<MentorshipLookup> SeedMentorshipsAsync(
        IUnitOfWorkFactory uowFactory,
        UserLookup users,
        TimeSequence timeSeq,
        ILogger logger)
    {
        await using var uow = await uowFactory.CreateAsync();
        var mentorshipDict = new Dictionary<(string, string), Mentorship>();
        var addedCount = 0;

        foreach (var def in DevelopmentMentorships)
        {
            var mentorId = users.GetId(def.MentorExternalId);
            var menteeId = users.GetId(def.MenteeExternalId);

            var existingMentorship = await uow.Mentorships.FindByMentorAndMenteeAsync(mentorId, menteeId);
            if (existingMentorship != null)
            {
                mentorshipDict[(def.MentorExternalId, def.MenteeExternalId)] = existingMentorship;
                logger.LogDebug("開発用 Mentorship は既に存在します: {MentorId} - {MenteeId}",
                    def.MentorExternalId, def.MenteeExternalId);
                continue;
            }

            var mentorship = new Mentorship(mentorId, menteeId, timeSeq.Next());
            await uow.Mentorships.AddAsync(mentorship);
            mentorshipDict[(def.MentorExternalId, def.MenteeExternalId)] = mentorship;
            addedCount++;

            logger.LogDebug("開発用 Mentorship を追加しました: {MentorId} - {MenteeId}",
                def.MentorExternalId, def.MenteeExternalId);
        }

        if (addedCount > 0)
        {
            await uow.SaveChangesAsync();
            logger.LogInformation("開発用 Mentorship をシードしました: {Count}件の関係を追加", addedCount);
        }
        else
        {
            logger.LogInformation("開発用 Mentorship は全て存在しています。追加なし。");
        }

        return new MentorshipLookup(mentorshipDict);
    }

トピックは、メンタリングと題名の組で探します。同じメンタリングで題名が重複しうる(一意制約もない)ので、冪等の章でみた「一意でない値の組」です。

    private static async Task SeedTopicsAndMessagesAsync(
        IUnitOfWorkFactory uowFactory,
        MentorshipLookup mentorships,
        UserLookup users,
        TimeSequence timeSeq,
        ILogger logger)
    {
        await using var uow = await uowFactory.CreateAsync();
        var addedCount = 0;

        foreach (var def in DevelopmentTopics)
        {
            var mentorship = mentorships.Get(def.MentorExternalId, def.MenteeExternalId);

            var existingTopic = await uow.Topics.FindByMentorshipAndTitleAsync(mentorship.Id, def.Title);
            if (existingTopic != null)
            {
                logger.LogDebug("開発用 Topic は既に存在します: {Title}", def.Title);
                continue;
            }

            var topic = new Topic(mentorship.Id, def.Title, timeSeq.Next(TopicTimeIncrement));

            foreach (var msgDef in def.Messages)
            {
                topic.PostMessage(users.GetId(msgDef.SenderExternalId), msgDef.Content, timeSeq.Next());
            }

            await uow.Topics.AddAsync(topic);
            addedCount++;

            logger.LogDebug("開発用 Topic を追加しました: {Title} ({MessageCount}件のメッセージ)",
                def.Title, topic.Messages.Count);
        }

        if (addedCount > 0)
        {
            await uow.SaveChangesAsync();
            logger.LogInformation("開発用 Topic と Message をシードしました: {Count}件のトピックを追加", addedCount);
        }
        else
        {
            logger.LogInformation("開発用 Topic は全て存在しています。追加なし。");
        }
    }

3つとも、エンティティのコンストラクタとメソッドを通して作ります。トピックなら、new Topicで作り、PostMessageでメッセージを足します。

そのため、題名やメッセージの長さといった、エンティティのルールは、Seedでも守られます。

TopicとMessageのルールは、第21回で扱っています。

日時は、今の時刻を使わず、固定した基準の時刻から少しずつ進めます。冒頭のユーザー一覧でも、ダミーの6人の登録日時は1分刻みです。

空のDBから作れば、開発用データの名前や日時は、毎回同じになります。

Seedを2回目に実行したときのログです。初期管理者も開発用データも、すでにあるので何も足していません。

[17:35:28 INF] 初期管理者は既にAdminとして存在します: 初期管理者
[17:35:28 INF] 開発環境: サンプルデータを生成しています
[17:35:28 INF] 開発用ユーザーは全て存在しています。追加なし。
[17:35:28 INF] 開発用 Mentorship は全て存在しています。追加なし。
[17:35:28 INF] 開発用 Topic は全て存在しています。追加なし。
プロ美

何度Seedを実行しても、データが増えたり変わったりしないんだね。

まとめ

今回は、Seedデータの分け方と、何度実行しても同じ状態にする書き方を整理しました。重要なポイントは以下です。

今回のポイント

  • Seedデータは、アプリが動くのに必要なデータと開発用データに分け、環境ごとに入れるものを変える
  • Seedは起動のたびに実行されるので、何度実行しても同じ状態になるように(冪等に)書く
  • 書き方は、入れたあとの値を誰が決めるかで選ぶ。画面で直すなら「なければ追加」、Seedデータで決め続けるならupsert
  • 既存を探す手がかり(代理キー・自然キー・一意でない値の組)を、データに合わせて選ぶ

次回からは認証のパートに入り、OIDCによる認証を扱います。今回、初期管理者の指定に使ったExternalIdが、どこから来るのかもみていきます。

プロ太

Seedは目的で2つに分け、どちらも何度実行しても同じ状態になるように書く。これで安心して起動できますね。

引き続き、実務的なWebアプリの開発について一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。