這回終於可以 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 這樣的便捷方法。
本文到此結束。