博客 / 詳情

返回

【EF Core】實體追蹤——Entry中記錄的數據

這回終於可以 Free 了,剛好快過年了,工廠的機器也很人性化地壞了,需要供應商維修,不用測試項目了。所以老周也回家快活了幾天。其實他們自己有開發團隊,小改小測的他們完全可以自己弄,非要找老周麻煩。

咱們接着上次的話題聊,上次老周給大夥伴們胡謅了一番有關實體狀態追蹤的基礎。這一次咱們把注意力放到名為 EntityEntry 的對象上。咦,這名怎麼看着這麼奇葩?咱們不管它奇不奇葩,只要知道它負責保存實體對象的屬性值就行了。

畢竟實體類通常就是一個普通類,EF Core 需要狀態追蹤功能,總不能讓開發者自己去跟蹤吧,所以,EF 內部會用字典數據結構來保存實體的各個屬性的值。字典是個好東西,啥都能放。有時候在寫 Web API 時,一些返回 JSON 結構是動態的,為它們都定義一個類來序列化是不明智的,直接拼裝 JSON 有點麻煩,這時候用字典就很爽。

當實體從數據庫查詢出來時,EF 先為實體對象創建一個快照,表明它的原始數據。然後,當你對實體進行各種搔操作之後,調用一下 DetectChanges 方法,它會掃描實體對象各個屬性的值,並和當初創建的快照比較,以確定實體是否被修改(或刪除)。

為了讓初學的大夥伴們好理解,咱們做個對比實驗。

假設有這麼個實體類,它表示一本書的信息。

public class Book
{
    public int BookId { get; set; }
    public string Name { get; set; } = null!;
    public string ISBN { get; set; } = null!;
    public string Author { get; set; } = null!;
    public int PubYear {  get; set; }
}

然後是實現數據庫上下文。

public class MyDb : DbContext
{
    public DbSet<Book> Books { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=shop.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var bookEnt = modelBuilder.Entity<Book>();
        // 主鍵名稱
        bookEnt.HasKey(x => x.BookId).HasName("PK_Book");
        // 字符串長度
        bookEnt.Property(a => a.Name).HasMaxLength(65);
        bookEnt.Property(b => b.Author).HasMaxLength(20);
        bookEnt.Property(c => c.ISBN).HasMaxLength(15);
        bookEnt.ToTable("tb_Books", t =>
        {
            // 約束
            t.HasCheckConstraint("CK_Pubyear", "\"PubYear\" >= 2020 AND \"PubYear\" <= 2080");
        });
    }
}

上面那些都是常規操作了,大家瞄兩眼就行。下面代碼創建數據庫並插入一條數據。

using MyDb context = new();
context.Database.EnsureCreated();

        Book bb = new()
        {
            Name = "回魂術",
            Author = "老周",
            ISBN = "551269882",
            PubYear = 2028
        };
context.Books.Add(bb);
context.SaveChanges();

下面重頭戲來了。咱們從數據庫中查詢出這條記錄,然後改變 PubYear 屬性的值。

 var theBook = context.Books.FirstOrDefault();
 if (theBook == null) return;

 // 打印一次追蹤
 Console.WriteLine(context.ChangeTracker.ToDebugString());
 // 更改屬性
 theBook.PubYear = 2030;
 // 再打印一次
 Console.WriteLine(context.ChangeTracker.ToDebugString());

ChangeTracker.ToDebugString 方法方便測試,可以直接觀察框架對實體對象的更改記錄。兩次調用的輸出如下:

Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2028

Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2030 Originally 2028

很明顯,EF 並不知道咱們修改了實體,所以,調用一下 DetectChanges 方法會觸發一次掃描和比較。

// 打印一次追蹤
Console.WriteLine(context.ChangeTracker.ToDebugString());
// 更改屬性
theBook.PubYear = 2030;
context.ChangeTracker.DetectChanges();
// 再打印一次
Console.WriteLine(context.ChangeTracker.ToDebugString());

這次 EF 就知道實體被修改了。

Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2028

Book {BookId: 1} Modified
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2030 Modified Originally 2028

由於 PubYear 屬性被更新,使得實體的狀態變更為 Modified。

那,為什麼我調用 SaveChanges 方法時 EF 能順利生成更新 SQL 呢,因為這個方法會先 DetectChanges,再根據實體的狀態來生成更新語句。

但是,如果你在代碼裏面把 AutoDetectChangesEnabled 屬性設置為 false,那麼調用 SaveChanges 方法是不會更新的。

using MyDb context = new();
// 注意這一行
context.ChangeTracker.AutoDetectChangesEnabled = false;

// 找出第一條記錄
Book onebook = context.Books.First();
// 打印一次
Console.WriteLine($"{onebook.Name}, {onebook.PubYear}");
// 改一下年份
onebook.PubYear = 2031;
// 提交更改
context.SaveChanges();
// 再查詢一次
Book otherOne = context.Books.First(b => b.BookId == onebook.BookId);

// 再次打印
Console.WriteLine($"{otherOne.Name}, {otherOne.PubYear}");
// 看看實體追蹤信息
Console.WriteLine(context.Entry(otherOne).DebugView.LongView);

看看調試信息。

回魂術, 2028
回魂術, 2031
Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2031 Originally 2028

雖然 EF 追蹤到 PubYear 屬性被改為 2031,但注意它現在的狀態是 Unchanged,所以 SaveChanges 不會更新數據庫。

大夥伴們,這裏你千萬別犯糊塗,把概念搞混了。AutoDetectChangesEnabled 屬性設置為 false 只表明 EF 在 SaveChanges 方法中不會自動掃描檢測實體的狀態,可人家沒説不會追蹤實體的變更喲

你如果只是查詢數據,不更改數據,不需要追蹤實體狀態(以提升不太明顯的性能),那麼你不應該關閉 AutoDetectChangesEnabled 屬性,而應該設置 QueryTrackingBehavior 屬性。

context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

禁用追蹤後,更新數據庫就跟 Sugar 一樣,需要你開手動檔。你只需要調用一下 Update 方法,將實體狀態變為 Modified 就行,缺點是生成的 UPDATE 語句會把所有字段都 SET 一遍。

// 找出第一條記錄
Book onebook = context.Books.First();
// 打印一次
Console.WriteLine($"{onebook.Name}, {onebook.PubYear}");

// 改一下年份
onebook.PubYear = 2024;
// 改變狀態
context.Update(onebook);

// 提交更改
context.SaveChanges();

那有沒有方法讓生成的 UPDATE 語句只 SET 被改動過的字段呢?大夥伴肯定猜到,老周既然這麼寫,那説明肯定有的。但老周希望你不要死記方法,而是思路。咱們好好想一下:EF 是不是在查詢出數據到為實體建立快照,然後進行比較,以確定哪些屬性(字段)被修改了。既然這樣,咱們在查詢實體後,手動給它弄個快照,然後再修改屬性值,再提交更新不就完事了嗎?好,想幹就幹。

// 找出第一條記錄
Book onebook = context.Books.First();

context.Books.Attach(onebook);
// 記錄快照
context.Entry(onebook).OriginalValues.SetValues(onebook);

// 改一下年份
onebook.PubYear = 2021;
// 打印一下狀態信息
Console.WriteLine(context.Entry(onebook).DebugView.LongView);

// 嚴重警告:如果你把 AutoDetectChangesEnabled 屬性設置為 false,那一定要調用下面這一行以掃描更改,否則只能更新個毛線
// 如果你沒有改動 AutoDetectChangesEnabled 屬性,它默認是打開的,那下面這行可以忽略
// context.ChangeTracker.DetectChanges();
// 提交更改
context.SaveChanges();

// 再查詢一次
Book otherOne = context.Books.First(b => b.BookId == onebook.BookId);

// 再次打印狀態信息
Console.WriteLine(context.Entry(otherOne).DebugView.LongView);

結果如下:

Book {BookId: 1} Modified
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2021 Modified Originally 2025
Book {BookId: 1} Detached
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂術'
    PubYear: 2021

生成的SQL語句如下:

UPDATE "tb_Books" SET "PubYear" = @p0
WHERE "BookId" = @p1
RETURNING 1;

因為咱們設置為不追蹤實體(QueryTrackingBehavior.NoTracking),所以在查詢後,要用 Attach 方法把實體連接到追蹤器,並設定狀態為 Unchanged。然後,context.Entry 方法獲取到 EntityEntry 對象(本文的主角出場了),再往 OriginalValues 裏面放點原材料(目前查詢出來的值),這樣快照就建立了。再然後,可以大膽地修改實體了,這時候 EF 能掃描到更改。由於咱們設置的不追蹤,所以更新之後 EF 又把實體給甩了,於是實體狀態又變回 Detached。

説簡單點,在手動檔追蹤下,實體的狀態經歷了 Detached -> Unchanged -> Modified -> Detached 的生死輪迴。

-----------------------------------------------------------------------------------------------------------------------------------------------------------

Entry 是“記錄”的意思,EntityEntry 望文生義一下就是“實體記錄”,它維護着實體的狀態和各屬性的值。一句話斯基總結:它是為實體追蹤(跟蹤)服務的,管理着實體相關的數據。

在 80% 的使用場景下,我們不需要用 Entry 的,走常規流程,從數據庫中查詢數據,自動追蹤,修改後提交就完事了。不過,像影子屬性這種不在實體類中的成員,你咋辦?影子屬性的元數據在數據庫模型中,而值是保存在 EntityEntry 中。下面咱們用一個實例來説明。

咱們定義一個用户實體。

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string LogName { get; set; } = string.Empty;
    public string? Password {  get; set; }
}

然後是實現自己的數據庫上下文。

public class AppContext : DbContext
{
    public DbSet<User> Users { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=demo996.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        EntityTypeBuilder<User> entUser = modelBuilder.Entity<User>();
        // 常規配置
        entUser.Property(x => x.Name).HasColumnName("u_name");
        entUser.Property(w => w.LogName).HasColumnName("log_name");
        entUser.Property(t => t.Password).HasColumnName("u_pwd");
        entUser.Property(m => m.Id).HasColumnName("u_id");
        entUser.HasKey(x => x.Id).HasName("PK_Userid");
        // 影子屬性
        entUser.Property<DateTime?>("LastLog").HasColumnName("_last_log");
    }
}

User 實體有一個影子屬性 LastLog,記錄用户上一次登錄的時間。在實體中不需要使用,或不希望被訪問的屬性,在建立數據庫模型時可以作為影子屬性。

在查詢表達式中可以使用 EF.Property 方法來獲取影子屬性的值,EF 類中的方法成員都會拋出異常,沒有真正實現,而是通過表達式樹來翻譯處理。因此,在非查詢語句中訪問影子屬性,或要修改影子屬性就不能使用 EF 類了。影子屬性的值保存在 Entry 中,可以用以下代碼來設置 LastLog 屬性的值。

// 先查詢出實體
User? u = context.Users.FirstOrDefault(x => x.Name == "Teto");
if (u is null)
{
    Console.WriteLine("用户不存在");
    return;
}
// 通過 Entry 修改影子屬性
DateTime theTime = DateTime.Now;
context.Entry(u).Property("LastLog").CurrentValue = theTime;
// 打印追蹤信息
Console.WriteLine(context.ChangeTracker.DebugView.LongView);
// 提交保存
context.SaveChanges();

直接使用 Entry 修改屬性值會自動應用實體的狀態。User 實體變為 Modeified 狀態。

User {Id: 3} Modified
    Id: 3 PK
    LastLog: '2026/2/12 18:25:01' Modified Originally '2026/2/12 17:41:20'
    LogName: 'teto'
    Name: 'Teto'
    Password: 'balabala'

想驗證是否更新數據庫,可以查詢一遍整個表。

using(var context = new AppContext())
{
    Console.WriteLine("\n------ 所有用户 ------");
    foreach(User usr in context.Users)
    {
        Console.WriteLine($"""
            用户ID:{usr.Id}
            用户名:{usr.Name}
            上次登錄時間:{context.Entry(usr).Property("LastLog").CurrentValue}
            """);
        Console.Write("\n");
    }
}
------ 所有用户 ------
用户ID:1
用户名:Kaito
上次登錄時間:2026/2/12 17:40:45

用户ID:2
用户名:Gumi
上次登錄時間:2026/2/12 17:40:13

用户ID:3
用户名:Teto
上次登錄時間:2026/2/12 18:25:01

上面的做法要先查詢一次,然後更新,即數據庫往返兩回。99.99965% 的情況下也沒啥影響的,而且很多時候用户編輯數據時確實得先查後改的,畢竟編輯界面你得先顯示現有的數據才方便用户去修改。如果你不想 SELECT 只想直接 UPDATE 也可以的。

 // 直接實例化
 // Id 是主鍵,必須賦值,明確要更新的記錄
 User data = new() { Id = 2 };
 var entry = context.Entry(data);
 // 標記實體的狀態為已修改
 entry.State = EntityState.Modified;
 // 先改變實體的狀態再去改變某個屬性的狀態
 // 因為在設置實體為 Modified 時會把所有屬性都設置為 Modified
 // 先設置實體再設置屬性成員就不會被覆蓋
 foreach (var p in entry.Properties)
 {
     if (p.Metadata.Name == "LastLog")
     {
         // 只修改這個屬性
         p.IsModified = true;
         p.CurrentValue = DateTime.Now;
     }
     else
     {
         // 其他屬性不改,標記為非修改狀態
         p.IsModified = false;
     }
 }

 // 打印追蹤信息
 Console.WriteLine(context.ChangeTracker.DebugView.LongView);
 // 提交保存
 context.SaveChanges();

在改變實體狀態時,先設置整個實體為 Modified,此時由於沒有初始快照做比較,實體的所有屬性(不含主鍵)都被標記為 Modified,如果這樣更新數據庫的話,會把 Name、LogName、Password 等屬性都更改為 null 了。所以,咱們在設置實體為 Modified 後,還要對各個屬性做做手腳。用 foreach 枚舉各個屬性,只有 LastLog 屬性才設置為 Modified,其他的屬性設置為未更改,這樣發送到數據庫的 UPDATE 語句只會 SET LastLog 屬性。

UPDATE "Users" SET "_last_log" = @p0
WHERE "u_id" = @p1
RETURNING 1;

由於咱們要更新的是影子屬性,不能使用 ExecuteUpdate 這樣的便捷方法。

本文到此結束。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.