Upgrade to Pro — share decks privately, control downloads, hide ads and more …

UPDATE をやめる — EF Core でマスタをバージョン管理する

UPDATE をやめる — EF Core でマスタをバージョン管理する

C# Kaigi 2026 登壇資料

Avatar for HideyukiKitao

HideyukiKitao PRO

September 19, 2026

More Decks by HideyukiKitao

Other Decks in Programming

Transcript

  1. 自己紹介(つづき) : 北尾英之 NuGet C# ライブラリを公開(主に自分用)。 直近の FlatXlsx は クラスをエクセル(.xlsx)化

    Cysharp の WebSerializer の設計に倣ったもの X @kitaohx 技術書の感想を投稿 ドメイン駆動設計など設計関連の学びを中心に 3
  2. (従来)WHEREをつけると最新版が取得できる 以前、構築したシステムは 開始日・終了日で判定する方式(SCD Type2) select ... from MasterShipper s join

    MasterBillTo b on b.BillToCode = s.BillToCode and b.StartDate <= @asOf and @asOf < b.EndDate -- JOIN 先にも where s.StartDate <= @asOf and @asOf < s.EndDate -- 本体にも -- 削除は日付で表現する ここで気を使うのは、検索条件の漏れ 書き漏れにより、行の重複やデータの取得につながってしまう SCD Type 2(Slowly Changing Dimension Type 2) 14
  3. 設計の比較 : 従来型/バージョン管理型 従来型:1テーブルで完結 従来のマスタ(SCD Type2) コード 荷主名 2列の役割は 請求先

    履歴が果たす 開始⽇ 終了⽇ 読む側が毎回 WHERE + 基準⽇を書く ※いわゆる削除フラグの場合も同じ構図 全体 バージョン管理型:3テーブルで分担 ‧マスタは従来どおりの形。 マスタ(最新版のみ) 最新版の取得に WHERE 不要 コード ‧開始⽇‧終了⽇はマスタから消え、 荷主名 履歴側で捌く 請求先 ‧未来⽇付の改定は、 (従来どおりの形) タイムラインの分岐で再現する 反映 (Git の Refs を輸⼊) (履歴から最新版を作る) 履歴(追記専⽤) タイムライン(Git 的 Refs) 最新を指す ID(連番) main ⽇時‧担当 操作: 登録‧変更‧削除‧復元 apply-20260901(予約) 変更後の値の⼀式 UPDATE も DELETE もしない 予約=分岐。当⽇に main へ合流 16
  4. 左側 従来の設計 従来型:1テーブルで完結 従来のマスタ(SCD Type2) コード 荷主名 請求先 開始⽇ 終了⽇

    読む側が毎回 WHERE + 基準⽇を書く ※いわゆる削除フラグの場合も同じ構図 2列の役割は 履歴が果たす 17
  5. 採用した設計1 : 最新版マスタ 右側・上 バージョン管理型:3テーブルで分担 ‧マスタは従来どおりの形。 マスタ(最新版のみ) 最新版の取得に WHERE 不要

    コード ‧開始⽇‧終了⽇はマスタから消え、 荷主名 履歴側で捌く 請求先 ‧未来⽇付の改定は、 (従来どおりの形) タイムラインの分岐で再現する 反映 (Git の Refs を輸⼊) (履歴から最新版を作る) 18
  6. 採用した設計2 : 履歴 + タイムライン 反映 右側・下 (Git の Refs

    を輸⼊) (履歴から最新版を作る) 履歴(追記専⽤) タイムライン(Git 的 Refs) 最新を指す ID(連番) main ⽇時‧担当 操作: 登録‧変更‧削除‧復元 apply-20260901(予約) 変更後の値の⼀式 UPDATE も DELETE もしない 予約=分岐。当⽇に main へ合流 19
  7. 手続き一式(6操作) 分類 メソッド 備考 追加・更新 CommitAsync 新規と更新(デモ2.) 削除 RemoveAsync 削除(デモ2.)

    復元 RevertAsync 履歴の任意時点へ復元。削除後復元も可(デモ3.) ブランチ BranchAsync 別タイムライン作成(予約登録など) 〃 ブランチを main へ MergeAsync ブランチの起点替え ReapplyAsync 〃 (ブランチ中に変更が起きた場合) 22
  8. EF Core とは : テーブルの1行 = C# のクラス1つ まずは一般論として //

    mdm.MasterShipper の1行 = Shipper クラスのインスタンス1つ var shipper = new Shipper { ShipperCode = "S001", ShipperName = "サンプル商事" }; // 1. オブジェクトを渡す(INSERT の予約) db.Add(shipper); // 2. ここで INSERT 文が組み立てられ、実行される await db.SaveChangesAsync(); SQL 文は書かない。クラスの操作を記述する SQL 列名の記述は、クラスのプロパティから EF Core が解決 23
  9. 実装 : 最新一覧の読み取り(Demo 1.画面の実装) // Shippers.razor 最新一覧タブの読み取り(抜粋) // 1. DBへの接続を用意

    await using var db = await DbFactory.CreateDbContextAsync(); // 2. 最新版テーブルから読む(読み取り専用) var q = db.Shippers.AsNoTracking(); // 3. 画面に検索語があれば、絞り込み if (_search != "") q = q.Where(x => x.ShipperCode.Contains(_search) || x.ShipperName.Contains(_search)); // 4. コード順に並べ、先頭200件 _list = await q.OrderBy(x => x.ShipperCode) .Take(200).ToListAsync(); // ここで SELECT が流れる 24
  10. 実装 : 削除済みを含む一覧(Demo 3・左側) // Shippers.razor 1. 履歴から、キーごとの最新行(削除の行も並ぶ) var latestIds

    = db.ShipperCommits.AsNoTracking() .Where(c => c.TimelineName == "main") .GroupBy(c => c.ShipperCode) .Select(g => g.Max(c => c.CommitId)); var rows = await db.ShipperCommits.AsNoTracking() .Where(c => latestIds.Contains(c.CommitId)) .OrderBy(c => c.ShipperCode).Take(500).ToListAsync(); // 2. 参照(main)に無ければ「削除済み」。削除フラグの列は無い var mainCodes = (await db.ShipperRefs.AsNoTracking() .Where(r => r.TimelineName == "main") .Select(r => r.MasterCode).ToListAsync()).ToHashSet(); _ledger = rows.Select(c => new LedgerRow(c, IsDeleted: !mainCodes.Contains(c.ShipperCode), …)).ToList(); デモ用に、削除済みを含むすべてのキーを取得 27
  11. 再掲 : 復元は、履歴への追記と HEAD の付け替え 従来型:1テーブルで完結 従来のマスタ(SCD Type2) コード 荷主名

    2列の役割は 請求先 履歴が果たす 開始⽇ 終了⽇ 読む側が毎回 WHERE + 基準⽇を書く ※いわゆる削除フラグの場合も同じ構図 全体 バージョン管理型:3テーブルで分担 ‧マスタは従来どおりの形。 マスタ(最新版のみ) 最新版の取得に WHERE 不要 コード ‧開始⽇‧終了⽇はマスタから消え、 荷主名 履歴側で捌く 請求先 ‧未来⽇付の改定は、 (従来どおりの形) タイムラインの分岐で再現する 反映 (Git の Refs を輸⼊) (履歴から最新版を作る) 履歴(追記専⽤) タイムライン(Git 的 Refs) 最新を指す ID(連番) main ⽇時‧担当 操作: 登録‧変更‧削除‧復元 apply-20260901(予約) 変更後の値の⼀式 UPDATE も DELETE もしない 予約=分岐。当⽇に main へ合流 29
  12. 再掲 : 復元は、履歴への追記と HEAD の付け替え 反映 右側・下 (Git の Refs

    を輸⼊) (履歴から最新版を作る) 履歴(追記専⽤) タイムライン(Git 的 Refs) 最新を指す ID(連番) main ⽇時‧担当 操作: 登録‧変更‧削除‧復元 apply-20260901(予約) 変更後の値の⼀式 UPDATE も DELETE もしない 予約=分岐。当⽇に main へ合流 30
  13. 実装 : 復元(Demo 3の実装) // RevertAsync(抜粋) target は「復元先のバージョン」の行 // 1.

    過去の版の値で、新しい行を作る(マスタ固有の列情報) var revert = _adapter.NewSnapshotFrom(target); // 2. 復元先のクラスを生成(いつ・誰が・どの操作で、などの管理情報を編集) FillEvent(revert, MasterEventType.Revert, …); // 3. 追記(EF Core: Add = INSERT の予約) _db.Add(revert); await SaveAsync(ct); // ここで INSERT が1本だけ流れる // 4. HEAD を新しい行へ付け替える AdvanceRef(@ref, key, timeline, revert.CommitId); // 5. 最新版テーブルへ反映 await UpsertEntityAsync(key, revert, ct); await SaveAsync(ct); 32
  14. 実装 : 復元は、履歴から(削除済みでもよい) // 4. AdvanceRef 参照が無ければ、作り直す if (@ref is

    null) _db.Add(new TRef { MasterCode = key, TimelineName = timeline, HeadCommitId = newHeadId }); else @ref.HeadCommitId = newHeadId; // 同時実行トークン付き UPDATE // 5. UpsertEntityAsync 最新版に無ければ、作り直す var entity = await FindEntityAsync(key, ct); if (entity is null) _db.Add(_adapter.NewEntityFrom(commit)); else _adapter.ApplyToEntity(commit, entity); 復元操作1本で、削除済みも生きている行も扱うことができる 34
  15. 実装 : 履歴の1行に、経緯を「管理項目」として残す // FillEvent 復元実装2.の中身。履歴の行に書き添える列(抜粋) commit.EventTypeId = eventTypeId; //

    登録・変更・削除・復元 commit.TimelineName = timelineName; // どのタイムラインで commit.ParentId = parentId; // 直前の HEAD(何の次か) commit.CopiedFromId = copiedFromId; // 何を元に(復元先のバージョン) commit.ChangedBy = cmd.ChangedBy; // 誰が commit.ChangedAt = now; // いつ commit.BizIntentType = cmd.BizIntentType; // 業務上の意図 保守担当が調べていたことが、管理項目のデータとして残る 35
  16. 検証機能:発行される SQL の取り方 optionsBuilder.LogTo(output.WriteLine, // テスト側の DbContext 構成 new[] {

    RelationalEventId.CommandExecuted }); -- 復元時の INSERT(テストログから採取・整形のみ) INSERT INTO [mdm].[MasterShipperCommits] ([BillToCode], [BizIntentType], [ChangedAt], [ChangedBy], [CopiedFromId], [EventTypeId], [MergeParentId], [ParentId], [ShipperCode], [ShipperName], [ShipperNameShort], [TimelineName]) OUTPUT INSERTED.[CommitId] VALUES (@p0, ..., @p11); 36
  17. ストアドと EF Core の比較 EF Core(全マスタ共通の書き込み。これだけ) _db.Add(snapshot); await SaveAsync(ct); //

    列名は、共通処理のどこにも現れない ストアド(マスタごとに複製。列の一覧を人が書き写す) insert into mdm.MasterShipperCommits ( ChangedBy, ChangedAt, EventTypeId, ..., CopiedFromId, ShipperCode, ShipperName, ShipperNameShort, BillToCode ) values ( @changedBy, @now, ..., @shipperName, @shipperNameShort, @billToCode ); -- 同じ列挙が、この下の update と insert にも続く 37
  18. マスタを1つ追加するとき(横展開)再掲 従来型:1テーブルで完結 従来のマスタ(SCD Type2) コード 荷主名 2列の役割は 請求先 履歴が果たす 開始⽇

    終了⽇ 読む側が毎回 WHERE + 基準⽇を書く ※いわゆる削除フラグの場合も同じ構図 全体 バージョン管理型:3テーブルで分担 ‧マスタは従来どおりの形。 マスタ(最新版のみ) 最新版の取得に WHERE 不要 コード ‧開始⽇‧終了⽇はマスタから消え、 荷主名 履歴側で捌く 請求先 ‧未来⽇付の改定は、 (従来どおりの形) タイムラインの分岐で再現する 反映 (Git の Refs を輸⼊) (履歴から最新版を作る) 履歴(追記専⽤) タイムライン(Git 的 Refs) 最新を指す ID(連番) main ⽇時‧担当 操作: 登録‧変更‧削除‧復元 apply-20260901(予約) 変更後の値の⼀式 UPDATE も DELETE もしない 予約=分岐。当⽇に main へ合流 38
  19. DB側で扱うとして残したもの 1. GC(履歴の整理) = 古い履歴を Archive テーブルへ移す処理 履歴テーブルには archive テーブルがある

    (モデルでは省略したが) 定期的に、古いデータや不要になった履歴を、 archive へ移す DBとEF Core を使い分けられると安上がりになる ※集合で扱う処理は、DB 内で完結する方が安価 40
  20. 少し深掘り : 残り時間しだいで A1 楽観ロック : 同時に操作がぶつかったら壊れないか A2 適用範囲 :

    どこまで使えるか・Code First は A3 計測の前後 : 計測を足す前はどう見えるのか A4 背景 : なぜ、マスタ管理に手間をかけるのか? 時間の許す範囲で 46
  21. 深掘り A1 | 楽観ロック : メソッドが、WHERE 句になる // ShipperMaster.cs b.Property(x

    => x.HeadCommitId).IsConcurrencyToken(); -- 実行時の UPDATE(テストログから採取・整形のみ) UPDATE [mdm].[MasterShipperRefs] SET [HeadCommitId] = @p5 WHERE [ShipperCode] = @p6 AND [TimelineName] = @p7 AND [HeadCommitId] = @p8; -- ★ @p8 = 読み取ったときの HEAD(expectedId) 他者が先に HEAD を進めていれば0件更新 → DbUpdateConcurrencyException → 業務例外 [50002] ↩ 本編「復元実装」— RevertAsync(key, targetCommitId, expectedId, cmd) の expectedId 48
  22. 深掘り A2 | 適用範囲2:Code First スキーマ変更(列の追加など)の実行者はコードの外側 「EF Core は追従するだけ」という使い方もできる。 だが「データベース変更も含めて自動化の対象」

    とする考え方は重要で、別途ツールを検討(例:flyway) (参考書籍)マイクロサービスアーキテクチャ 第2版 (Sam Newman著) ↩ 本編「1人ハッカソン」の制約(DDL は既存を活用)と「まとめ」 50
  23. 深掘り A3 | 計測を足す前の姿 : SQL が平らに並ぶだけ ↩ 本編「Aspire が便利」:

    同じ「復元」1回を、アプリ側の計測を外して撮ったもの 左側・上 51
  24. 深掘り A3 | 計測を足す前の姿 : SQL が平らに並ぶだけ ↩ 本編「Aspire が便利」:

    同じ「復元」1回を、アプリ側の計測を外して撮ったもの 全体 53