【C#】Entity Framework Core -ChangeTracker-

changeTracker

こんにちは、えいたです。

過去、EF Core におけるリレーションがどのように成立するのかについて整理しました。

Laravel(Eloquent)を触ってきた自分にとって、EF Core の「明示していなくても関係が推論される」という仕組みは少し違和感がありました。

前回の記事では、その違和感をきっかけに、EF Core がどのようにエンティティ同士の関係を解釈し、内部モデルとして扱っているのかを整理しました。

【C#】Entity Framework Core -リレーション成立条件-

【C#】Entity Framework Core -リレーション成立条件-

それを踏まえて、EF Core がエンティティの変更をどのように管理しているのか、つまり ChangeTracker について整理します。

実務の中で、想定外のコミットが発生しそうになったことをきっかけに、

  • EF Core は何を変更として覚えているのか
  • DbContextChangeTracker の管理単位はどこなのか
  • 実装時に注意すべきタイミング

を、自分の備忘録として残しておきます。

誰かの整理のお役に立てれば幸いです。

ChangeTracker とは

まず、ChangeTracker とは、DbContext が現在追跡しているエンティティの状態を管理する仕組みです。

EF Core では、DBから取得したエンティティや、Add / Update / Remove したエンティティを DbContext が覚えています。

そして、SaveChanges() が呼ばれたときに、ChangeTracker が管理している状態をもとに、INSERT / UPDATE / DELETE が実行されます。

ざっくり言うと、以下のような関係です。

  • DbContext:DBとの作業単位
  • ChangeTracker:その作業単位の中で、何が変更されたかを覚えるもの
  • SaveChanges():覚えている変更をDBに反映するもの

つまり EF Core は、毎回SQLを手書きするのではなく、今の DbContext が覚えている状態をもとに、必要なSQLを判断しているということです。

エンティティの状態

ChangeTracker は、エンティティをいくつかの状態で管理します。

代表的な状態は以下の通りです。

状態 意味 SaveChanges 実行時の動き
Detached DbContext に追跡されていない 何もしない
Unchanged DB取得後、変更されていない 何もしない
Added 新規追加された INSERT
Modified 変更された UPDATE
Deleted 削除予定 DELETE

例えば、DBから取得したエンティティの値を変更して SaveChanges() を呼び出すと、EF Core はその変更を検知して UPDATE を実行します。

// DBから取得:Unchanged
var user = context.Users.First();

// プロパティ更新:Modified
user.Name = "変更後";

// SaveChanges() を呼んだタイミングで変更が検知され、UPDATE が実行される
context.SaveChanges();

ここで重要なのは、SaveChanges() を呼ぶまで、DBにはまだ反映されていないという点です。

ChangeTracker は、あくまで DbContext の中で「変更予定」として覚えている だけです。

ChangeTracker の状態が変わるタイミング

次に、エンティティの状態が変わる瞬間を整理します。

タイミング 状態
DBから通常のクエリで取得したとき context.Users.FirstAsync() Unchanged
新規追加したとき context.Users.Add(user) Added
new で作ったオブジェクトを「DBにすでにある」と教えたとき context.Users.Attach(user) Unchanged
追跡中のエンティティのプロパティを変更したとき user.Name = "変更後" Modified
Detached なオブジェクトを強制的に更新対象にしたいとき context.Users.Update(user) Modified
削除対象にしたとき context.Users.Remove(user) Deleted

基本的に、EF Core の通常クエリは 追跡あり です。

そのため、以下のように取得したエンティティは DbContext に追跡されます。

var users = context.Users.ToList();

一方で、AsNoTracking() を使った場合は追跡されません。

var users = context.Users
    .AsNoTracking()
    .ToList();

この場合、取得した users を変更しても、ChangeTracker はその変更を覚えていません。

そのため、参照だけで更新しないデータであれば、AsNoTracking() を使うことで不要な追跡を避けることができます。

いつ ChangeTracker から外れるのか

ChangeTracker に追跡されたエンティティは、ずっと残り続けるわけではありません。

主に以下のタイミングで追跡対象から外れます。

タイミング 内容
DbContextDispose されたとき 通常の追跡終了タイミング
ChangeTracker.Clear() を呼んだとき 今の DbContext が追跡している全エンティティを外す
Entry(entity).State = EntityState.Detached にしたとき 特定のエンティティだけ追跡から外す
SaveChanges() で削除が成功したとき Deleted のエンティティは Detached になる

SaveChanges() 後の状態

SaveChanges() 成功後の状態は、基本的には以下のようになります。

SaveChanges 前 SaveChanges 後
Added Unchanged
Modified Unchanged
Deleted Detached

つまり、追加や更新をしたエンティティは、DBに反映されたあとも Unchanged として追跡され続けます。

一方で、削除されたエンティティは DB上から消えたものとして扱われるため、Detached になります。

状態遷移の一覧

ここまでの内容を踏まえ、基本的な状態遷移を整理します。

操作 状態遷移 への影響 / 補足
new Entity Detached new した直後。まだ DbContext が存在を知らない状態
Add(entity) Detached → Added SaveChanges()INSERT される
DBから通常取得 Detached → Unchanged データを取得した直後の、変更がない状態
取得データの値を変更 Unchanged → Modified 値が変わったので、自動的に UPDATE 対象になる
Remove(entity) Unchanged → Deleted DBから DELETE する予定にする
SaveChanges() 成功(追加・更新) Added / Modified → Unchanged 保存が完了し、DBとの変更差分がない状態になる
SaveChanges() 成功(削除) Deleted → Detached DBから消えて、管理対象から外れる
Update(entity) Detached → Modified 一度もSELECTせず、new したデータを UPDATE 対象にする

備考:DbContext が知らない状態を Detached と言います。例えば、自分で new しただけのオブジェクトや、AsNoTracking() で取得したエンティティは、追跡されておらず、 DbContext が知らない状態です。

ChangeTracker を扱う上で知っておいた方がいいこと

追跡対象に含めない(AsNoTracking)

前述の通り、参照だけで更新しないデータであれば、AsNoTracking() を使うことで ChangeTracker の追跡対象から外すことができます。

var users = context.Users
    .AsNoTracking()
    .ToList();

読み取り専用の処理では、不要な追跡を避けることでメモリの無駄に消費をせず、意図しない更新の防止にもつながります。

DbContext 単位で管理される

ChangeTracker はアプリ全体で共有されるものではなく、DbContext インスタンスごとに存在します。

そのため、同じエンティティでも以下は別物です。

  • Aの DbContext で追跡されている状態
  • Bの DbContext で追跡されている状態

この単位を意識していないと、

  • どこまでの変更が覚えられているのか
  • なぜ SaveChanges() でこの変更まで反映されたのか

が分かりにくくなります。

備考:DbContext のライフタイム

DbContext のライフタイムは、DI登録時の設定によって決まります。

ASP.NET Core の Web アプリで AddDbContext を使って登録した場合、DbContext はデフォルトで Scoped として登録されます。

そのため、通常は1リクエスト内で同じ DbContext インスタンスが使われます。

公式でも、AddDbContext は既定で Scoped として登録され、ASP.NET Core の多くのアプリではリクエストごとに別スコープ、つまり別 DbContext になると説明されています。

DbContext のライフタイム

自身のコードで DbContext がどのように登録されているか確認する際は、以下のファイルなどにある登録箇所を参照してください。

  • Startup.cs
  • Program.cs

実装上気を付けた方がいいタイミング

ChangeTracker は便利な仕組みですが、何を追跡しているかを意識していないと、想定外の更新につながることがあります。

特に気を付けた方がいいタイミングは個人的には以下だと思っています。

タイミング 起きる問題
ロールバック / 例外発生時 DB側のトランザクションは元に戻っても、ChangeTracker が自動でロールバック前の状態に戻るわけではないため、DB上の状態と DbContext が覚えている状態にズレが生じる可能性がある
長時間のバッチ処理など 過去に扱ったデータが追跡リストに溜まり続け、無駄なメモリの消費や意図しない更新の巻き込みが起きる可能性がある。
読み取り専用のクエリ 画面表示するだけなのに追跡をオンにしていると、無駄なメモリの消費や、誤更新の元になる可能性がある。

またこれらに対して意識すべきこととしては、以下だと思います。

  • 使い終わったら:不必要に使い回さず、 Dispose させる。ライフタイムへの意識。
  • 分けられないなら Clear():同じContext内ででエラーハンドリングする場合など、ロールバック直後に手動で追跡状態をリセットする。
  • 更新しないなら AsNoTracking():一覧表示など、保存しない箇所に付けて無駄な追跡を避ける。

そもそも ChangeTracker の何が便利なのか?

ここまで、ChangeTracker の状態管理や注意点に目を向けて整理してきました。

そのため、ChangeTracker のいいところも整理しておきます。

1. 同じエンティティを同じインスタンスとして扱える

同じDbContext 内で ChangeTracker によって管理されますが、同じ主キーを持つエンティティが DbContext 内に複数存在してしまうと、どのインスタンスの状態を「正」として保存すべきかが曖昧になります。

このような状態を避けるために、EF Core では、同じ DbContext 内で同じ主キーのエンティティを1つのインスタンスとして扱います(Identity Resolution)。

Identity Resolution は、ChangeTracker がエンティティの状態を矛盾なく管理するために必要な仕組みですが、

同じ DbContext 内で同じデータを何度扱っても、同じインスタンスとして扱えるという意味では、同じデータを一貫した状態として扱いやすくなる点は便利だと感じました。
(もちろん、どのエンティティが追跡されているかを意識する必要はありますが、、)

2. エンティティの状態や関連をもとに、必要なSQLを自動で判断してくれる

上記で説明してきたように、INSERT / UPDATE / DELETE のSQLを手動で組み立てなくても、EF Core がエンティティの状態に応じたSQLを生成・実行してくれます。

また、エンティティ同士の関連性が正しく構築されていれば、親エンティティに紐づく子エンティティも含めて、EF Core が必要なSQLの発行順序や外部キーの紐付けを処理してくれます。

var user = new User
{
    Name = "山田太郎",
    Orders = new List<Order>
    {
        new Order { ProductName = "商品A" },
        new Order { ProductName = "商品B" }
    }
};
context.Users.Add(user);
context.SaveChanges();

この例では、UserOrder の関連が正しく IModel に構築されていれば、EF Core はユーザーと注文データの関係を理解できます。

そのため、ユーザーを登録したあと、採番されたユーザーIDを注文データの外部キーとして紐付けたうえで、注文データを登録してくれます。

つまり、ChangeTracker はエンティティ単体の変更だけでなく、関連するエンティティも含めて状態を管理します。

そして、 SaveChanges() を呼ぶと、EF Core はその状態情報やモデル情報をもとに、必要なSQLや実行順序を判断して実行してくれます。

ChangeTracker の流れをざっくり整理する

最後に、ChangeTracker がどのように変更を覚えて、DBへ反映するのかを流れで整理しておきます。

ChangeTracker による変更管理の流れ

DbContext 経由でエンティティを取得する
↓
ChangeTracker がエンティティを追跡する
(基本は Unchanged)
↓
プロパティ変更 / Add / Remove などを行う
↓
ChangeTracker 上の状態が変わる
(Modified / Added / Deleted)
↓
SaveChanges() を呼ぶ
↓
ChangeTracker が持っている状態をもとに
EF Core が UPDATE / INSERT / DELETE を実行する
↓
保存後の状態に更新される
(Modified / Added は Unchanged、Deleted は Detached)

まとめ

ここまで、EF Core における ChangeTracker の役割や、エンティティの状態管理について整理しました。

ChangeTracker は、エンティティの状態を管理し、SaveChanges() 時に必要な変更をDBへ反映するための仕組みです。

取得したエンティティの変更を自動で検知し、必要なSQLを生成してくれるため、明示的に更新処理を細かく書かなくても、オブジェクトの変更をDBへ反映できます。

一方で、何が追跡されているかを意識していないと、SaveChanges() のタイミングで想定外の変更がDBに反映される可能性があります。

そのため、EF Core を扱う上では、

  • DBに何があるか
  • 今の DbContext が何を覚えているか

の両方を見る意識が大事だと感じました。

以上です!!

この記事がお役に立てば【 いいね 】のご協力をお願いいたします!
1
1
7
X facebook はてなブックマーク pocket

この記事をかいた人

About the author

えいた

WEB開発エンジニア
とりあえず外に出たい