好長時間沒有水文章了,請容老周解釋一下。因為最近老周進了兩個廠,第一個廠子呆了八天左右,第二個廠子還在調試。管理很嚴格,帶的電子設備都要登記、辦手續。當初覺得雷神筆記本的屏幕大,在車間調試代碼方便,所以登記了這個型號。但這個遊戲本功耗大,而且充電只能充到 83% 就充不進去了。只能白天在車間調試時用,其他時間玩手機。手機是那個 23800 mAH 的坦克3,所以電量多得是,充一次隨便玩。在廠裏很無聊,老周還另帶了一台某寶買的開源掌機……扯遠了。
第二個廠子的項目很詭異,老周甚至懷疑有人故意搗亂。他們工人自己測試的時候,總是報莫名其妙的錯;但是,只要老周過去和他們一起測,就一切正常。反正現在是測不出到底啥問題。從日誌中記錄的異常看,都是 Modbus TCP 連接超時。把 time out 改為 50 分鐘,也照樣在無限連接中。老周覺得是人為撥了網線的可能性更大。反正只要老周在現場就沒問題,所以耗了近一個月也沒結果。所以老周就請了四天假玩玩,不管他們同不同意,四天後老周準時回去報到。
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
記得上一篇水文中,老周説了把一個實體映射到多個表的話題。注意,一實體一數據表的原則是不變的,這種特殊情況可以用在你這幾個表可以組成一個整體,並且經常一起使用的,這樣你在查詢時就不用聯合了,一般是一對一關係的。
熟悉老周的人都知道,老周分享的都是純知識和純技術的東西。至於實際開發中怎麼用,那是你的事。實際應用是沒辦法寫教程的,你得看具體情況,靈活運用,不存在一個教程包萬能的道理。做項目我從小周做成了老周,雖然沒做過什麼大項目,但小 Case 是不少的(吹吹牛皮)。你別小看那些雜七雜八的項目,哪個不是要六邊形戰士,哪個不是軟硬結合,哪個不是既485又CAN又PLC又單片機的。別看它小,WinForms、Web、STM32(珠海極海的 APM32 也遇到過)、串口、Esp8266 全用上都是常見的事。這年頭,不學點 C 語言連小項目都搞不起,哪像那些互聯網巨頭那麼爽,天天盯着 HTML + CSS 玩。
老週一直覺得,經驗其實不重要的,跟一兩週的項目你都有經驗了,關鍵還得是基礎紮實、技術過硬,這樣才能來什麼活接什麼活。至於説基礎問題,幹活的傢伙,實戰更重要,理論的東西其實知道是啥就好,咱們又不用寫論文評職稱。你理論知識説得一套一套的,真用的時候不會用,那有啥用?不要排斥實用主義,實用主義其實是正確的,技術學了就是拿來用的,不用就沒意義了。學習是分兩種的:一種是內修——比如琴棋書畫,這是文化底藴,個人氣質。這種你不必學了就要用(但也可以用),更重要的是養心養神,自我調整;另一種就是工作幹活用的,叫技能,屬於外修。從小老師都教我們要內外兼修。
好了,不扯了。在開始今天的主題前,咱們補一個內容:既然實體能分佈到多個表中,那反過來呢?能把多個實體映射到一個表中嗎?當然可以了,官方稱作“表拆分”。同樣的道理,一般也是一對一的關係。
光説不練,慘過失戀。咱們直接用實例來説明。假設下面有兩個實體。
public class Person { public int PsID { get; set; } public string Name { get; set; } = null!; public int Age { get; set; } // 導航屬性 public PersonInfo OtherInfo { get; set; } = null!; } public class PersonInfo { private int InfoID { get; set; } // 既做主鍵也做外鍵 /// <summary> /// 體重 /// </summary> public float Weight { get; set; } /// <summary> /// 身高 /// </summary> public float Height { get; set; } /// <summary> /// 民族 /// </summary> public string? Ethnicity { get; set; } }
待會兒咱們要做的是把這兩個實體映射到一個表中,所以為了安全,你可以讓 PersonInfo 實體的 InfoID 屬性變成私有成員,這可以防止三隻手的人意外修改主鍵值。因為這個實體的 ID 值必須始終與 Person 的 ID 一致。
下面代碼是數據庫上下文類。
public class DemoDbContext : DbContext { public DbSet<Person> People { get; set; } public DbSet<PersonInfo> PersonInfos { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite("data source=恭喜發財.db") .LogTo( // 輸出日誌的委託 action: msg => Console.WriteLine(msg), // 過濾器,只顯示即將執行的命令日誌,可以看到SQL語句 filter: (eventId, _) => eventId.Id == RelationalEventId.CommandExecuting ); } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 配置實體 modelBuilder.Entity<Person>(pse => { pse.Property(e => e.PsID).HasColumnName("person_id"); pse.Property(b => b.Name).HasMaxLength(16).IsRequired().HasColumnName("person_name"); pse.Property(d => d.Age).HasColumnName("person_age"); // 主鍵 pse.HasKey(w => w.PsID).HasName("PK_Person"); // 表名 pse.ToTable("tb_people"); }); modelBuilder.Entity<PersonInfo>(pie => { pie.Property("InfoID").HasColumnName("person_id").ValueGeneratedNever(); pie.Property(r => r.Height).HasColumnName("info_height"); pie.Property(i => i.Weight).HasColumnName("info_weight"); pie.Property(k => k.Ethnicity).HasMaxLength(10).HasColumnName("info_ethnic"); // 主鍵 pie.HasKey("InfoID").HasName("PK_Person"); // 同一個表名 pie.ToTable("tb_people"); }); // 兩實體的關係 modelBuilder.Entity<Person>().HasOne(n => n.OtherInfo) .WithOne() // info --> person .HasForeignKey<PersonInfo>("InfoID").HasConstraintName("FK_PersonInfo") // person --> info .HasPrincipalKey<Person>(p => p.PsID); } }
基本代碼相信各位能看懂的。和配置一般實體區別不大,但要注意幾點:
1、兩個實體所映射的表名要相同。這是F話了,都説映射到同一個表了,表名能不一樣的?
2、兩個實體中作為主鍵的屬性名可以不同,但類型要相同(可以減少翻車事故);更重要的是:一定要映射到同一個列名。因為映射後,兩個實體作為主鍵的屬性會合並;再者,主鍵的約束名稱也要相同,不解釋了,一樣的道理。
modelBuilder.Entity<Person>(pse => { pse.Property(e => e.PsID).HasColumnName("person_id"); …… // 主鍵 pse.HasKey(w => w.PsID).HasName("PK_Person"); // 表名 pse.ToTable("tb_people"); }); modelBuilder.Entity<PersonInfo>(pie => { pie.Property("InfoID").HasColumnName("person_id").ValueGeneratedNever(); …… // 主鍵 pie.HasKey("InfoID").HasName("PK_Person"); // 同一個表名 pie.ToTable("tb_people"); });
對於第二個實體,ValueGeneratedNever 方法可以不調用,EF 會自動感知到不需要自動生成列值。
3、兩個實體配置為一對一關係,這個和常規實體操作一樣。
然後在 Main 方法中測試一下。
static void Main(string[] args) { using var context = new DemoDbContext(); context.Database.EnsureDeleted(); context.Database.EnsureCreated(); // 打印數據庫模型 Console.WriteLine(context.Model.ToDebugString()); }
運行結果:
Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30'] CREATE TABLE "tb_people" ( "person_id" INTEGER NOT NULL CONSTRAINT "PK_Person" PRIMARY KEY AUTOINCREMENT, "person_name" TEXT NOT NULL, "person_age" INTEGER NOT NULL, "info_weight" REAL NOT NULL, "info_height" REAL NOT NULL, "info_ethnic" TEXT NULL ); // 以下是數據庫模型 Model: EntityType: Person Properties: PsID (int) Required PK AfterSave:Throw ValueGenerated.OnAdd Age (int) Required Name (string) Required MaxLength(16) Navigations: OtherInfo (PersonInfo) Required ToDependent PersonInfo Keys: PsID PK EntityType: PersonInfo Properties: InfoID (int) Required PK FK AfterSave:Throw Ethnicity (string) MaxLength(10) Height (float) Required Weight (float) Required Keys: InfoID PK Foreign keys: PersonInfo {'InfoID'} -> Person {'PsID'} Unique Required RequiredDependent Cascade ToDependent: OtherInfo
--------------------------------------------------------------------------------------------------------------------------------------
下面正片開始。今天咱們説説 EF Core 中幾大主要功能模塊之一——追蹤(叫跟蹤也行)。正常情況下,EF Core 從實體被查詢出來的時候開始跟蹤。跟蹤前會為實體的各個屬性/字段的值創建一個快照(就備份一下,不是拷貝對象,而是用一個字典來存放)。然後在特定條件下,會觸發比較,即比較實體引用當前各屬性的值與當初快照中的值,從而確定實體的狀態。
為了方便訪問,DbContext 類會公開 ChangeTracker 屬性,通過它你能訪問到由 EF Core 創建的 ChangeTracker 實例(在Microsoft.EntityFrameworkCore.ChangeTracking 命名空間)。該類包含與實體追蹤有關的信息。調用 DetectChanges 方法會觸發實體的追蹤掃描,方法只負責觸發狀態檢查,不返回任何結果,調用後實體的狀態自動更新。實體的狀態由 EntityState 枚舉表示。
1、Unchanged:實體從數據庫中查詢出來後就是這個狀態,前提是這個實體是從數據庫中查出來的,也就是説它已經在數據庫中了。
2、Added:當你用 DbContext.Add 或 DbSet.Add 方法添加新實體後,實體就處在這個狀態。實體只存在 EF Core 中,還沒保存到數據庫。提交時生成 INSERT 語句。
3、Modified:已修改。實體自從數據庫中查詢出來到目前為止,它的某些屬性或全部屬性被修改過。提交時生成 UPDATE 語句。
4、Deleted:已刪除。實體已從 DbSet 中刪除(還在數據庫中)就是這個狀態,提交後生成 DELETE 語句。
5、Detached:失蹤人口,EF Core 未追蹤其狀態。
EF Core 內部有個名為 IStateManager 的服務接口,默認實現類是 StateManager。該類可以修改實體的狀態,也可以控制開始/停止追蹤實體的狀態。咱們在寫代碼時不需要直接訪問它,DbContext 以及 DbContext.ChangeTracker、DbSet 已經封裝了相關訪問入口。
對 DbSet 對象來説,你調用 Add、Remove、Update 等方法只是更改了實體的狀態,並沒有真正更新到數據庫,除非你調用 SaveChanges 方法。SaveChanges 方法內部會先調用 DetectChanges 方法觸發狀態變更掃描,然後再根據實體的最新狀態生成相應的 SQL 語句,再發送到數據庫中執行。
下面以插入新實體為例,演示一下。本示例在插入新實體前、後,以及提交到數據庫後都打印一次實體的狀態。
先定義實體類。
public class Pet { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } public string? Category { get; set; } }
正規流程,寫數據庫上下文類。
public class TestDbContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite("data source=天宮賜福.db"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Pet>(et => { et.ToTable("tb_pets"); et.Property(g => g.Name).HasMaxLength(20); et.Property(k => k.Description).HasMaxLength(200); et.Property(q => q.Category).HasMaxLength(15); et.HasKey(m => m.Id).HasName("PK_PetID"); }); } }
好,現在進入測試環節。
static void Main(string[] args) { using var context = new TestDbContext(); context.Database.EnsureCreated(); // 添加一個實體 Pet p = new() { Name = "Jack", Description = "不會游泳的巴西龜", Category = "爬行動物" }; // 打印一下狀態 Console.WriteLine("----------- 添加前 -------------"); Console.WriteLine(context.ChangeTracker.DebugView.LongView); context.Add(p); // 再打印一下狀態 Console.WriteLine("\n---------- 添加後 ------------"); Console.WriteLine(context.ChangeTracker.DebugView.LongView); // 提交 context.SaveChanges(); // 再打印狀態 Console.WriteLine("\n---------- 提交後 ------------"); Console.WriteLine(context.ChangeTracker.DebugView.LongView); }
和 Model 類似,ChangeTracker 對象也有個 DebugView,用於獲取調試用的信息。這個能打印出實體以及它的各個屬性的狀態。
運行一遍,結果如下:
----------- 添加前 ------------- ---------- 添加後 ------------ Pet {Id: -2147482647} Added Id: -2147482647 PK Temporary Category: '爬行動物' Description: '不會游泳的巴西龜' Name: 'Jack' ---------- 提交後 ------------ Pet {Id: 1} Unchanged Id: 1 PK Category: '爬行動物' Description: '不會游泳的巴西龜' Name: 'Jack'
新實體被 Add 之前,它是沒有被追蹤的,所以打印狀態信息空白。調用 Add 方法後,它的狀態就變成 Added 了。此時,你不需要調用 DetectChanges 方法,因為 Add 方法本身就會修改實體的狀態。新實體還未存入數據庫,所以主鍵 ID 賦了個負值,且是臨時的。當調用 SaveChanges 方法後,提交數據庫保存,並取回數據庫生成的ID值,故此時 ID 的值是 1。而且,實體的狀態被改回 Unchanged。這是合理的,現在新的實體已經在數據庫了,而且自從插入後沒有修改過,狀態應當是 Unchaged。
如果你有其他想法,希望在 SaveChanges 之後實體的狀態不變回 Unchaged,可以這樣調用 SaveChanges 方法。
context.SaveChanges(acceptAllChangesOnSuccess: false);
acceptAllChangesOnSuccess 參數設置為 false 後,數據庫執行成功後不會改變實體的當前狀態。於是,數據庫中插入新記錄後,實體狀態還是 Added。
---------- 添加後 ------------ Pet {Id: -2147482647} Added Id: -2147482647 PK Temporary Category: '爬行動物' Description: '不會游泳的巴西龜' Name: 'Jack' ---------- 提交後 ------------ Pet {Id: 1} Added Id: 1 PK Category: '爬行動物' Description: '不會游泳的巴西龜' Name: 'Jack'
這樣做可能會導致邏輯錯誤,除非你有特殊用途,比如這樣用。
using var context = new TestDbContext(); context.Database.EnsureDeleted(); context.Database.EnsureCreated(); // 處理事件 context.ChangeTracker.Tracked += (_, e) => { var backupcolor = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine($"實體被追蹤:\n{e.Entry.DebugView.LongView}\n"); Console.ForegroundColor = backupcolor; }; context.ChangeTracker.StateChanged += (_, e) => { var bkColor = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Blue; Console.WriteLine($"實體(ID={e.Entry.Property(nameof(Pet.Id)).CurrentValue})狀態改變:{e.OldState} --> {e.NewState}\n"); Console.ForegroundColor = bkColor; }; // 新實體 Pet p = new Pet { Name = "Tom", Description = "會游泳的鳥", Category = "猛禽" }; context.Add(p); // 保存,但狀態不改變 context.SaveChanges(false); // 因為是 Added 狀態,所以還可以繼續insert p.Name = "Simum"; p.Description = "三手青蛙"; p.Category = "兩棲動物"; // 保存,狀態改變 context.SaveChanges(); // 把它們查詢出來看看 var set = context.Set<Pet>(); Console.WriteLine("\n數據庫中的記錄:"); foreach(var pp in set) { Console.WriteLine($"{pp.Id} {pp.Name} {pp.Description} {pp.Category}"); }
上面代碼中,偵聽了兩個事件:Tracked——當 EF Core 開始跟蹤某個實體時發生;當有實體的狀態改變之後發生。其實還有一個 StateChanging 事件,是在實體狀態即將改變時發生。總結來説就是:狀態改變之前發生 StateChanging 事件,改變之後發生 StateChanged 事件。要注意,StateChanged 和 StateChanging 事件在 EF Core 首次追蹤實體時不會引發。比如,剛開始追蹤時狀態為 Unchanged,不會引發事件,而之後狀態變為 Added,就會引發事件(最開始那個狀態不會觸發事件)。
上面代碼處理 Tracked 事件,當開始追蹤某實體時,打印一下調試信息,記錄某狀態;處理 StateChanged 事件,在開始追蹤狀態後,狀態發生改變之後打印變化前後的狀態。
代碼運行結果如下:
首先,new 了一個 Pet 對象,賦值,再調用 Add 方法添加到數據集合中,此時狀態會被改為 Added。Tracked 事件輸出第一塊綠色字體,表示實體開始追蹤的狀態為 Added,ID 值是隨機分配的負值,並説明是臨時主鍵值。
然後調用 SaveChanges 方法並傳遞 false 給acceptAllChangesOnSuccess 參數,表明 INSERT 進數據庫後,狀態不改變,還是 Added。
然後,還是用那個實體實例,改變一下屬性值,由於它的狀態依舊是 Added,調用 SaveChanges() 方法時未傳參數,它會調用 SaveChanges(acceptAllChangesOnSuccess: true),結果是這次實體的狀態變成了 Unchanged。就是輸出結果中藍色字體那一行。此時實體的 ID=2,記住這個值,待會兒用到。
再往後,咱們 foreach 語句給 DbSet 會觸發 EF Core 去查詢數據庫,於是,我們看到,控制枱在“數據庫中的記錄:”一行之後又發生了 Tracked 事件,有一個 ID=1 的實體被追蹤了,它剛從數據庫中查詢出來,就是第二塊綠色字體那裏。
這時候你是不是迷乎了?不是從數據庫查出兩條記錄嗎,為什麼只有 ID=1 的被追蹤了,ID=2 呢?其實,ID = 2 已經被追蹤了。忘了嗎?它前面不是從 Added 狀態變為 Unchanged 狀態嗎。這是因為咱們這一連串操作都在同一個 DbContext 實例的生命週期進行的,EF Core 對實體的追蹤不會斷開。
如果你把上面的代碼改成這樣,那就明白了。
static void Main(string[] args) { using (var context = new TestDbContext()) { context.Database.EnsureDeleted(); context.Database.EnsureCreated(); // 處理事件 context.ChangeTracker.Tracked += OnTracked; context.ChangeTracker.StateChanged += OnStateChanged; // 新實體 Pet p = new Pet { Name = "Tom", Description = "會游泳的鳥", Category = "猛禽" }; context.Add(p); // 保存,但狀態不改變 context.SaveChanges(false); // 因為是 Added 狀態,所以還可以繼續insert p.Name = "Simum"; p.Description = "三手青蛙"; p.Category = "兩棲動物"; // 保存,狀態改變 context.SaveChanges(); } // 把它們查詢出來看看 using(var context2 = new TestDbContext()) { // 依舊要處理事件 context2.ChangeTracker.Tracked += OnTracked; context2.ChangeTracker.StateChanged += OnStateChanged; var set = context2.Set<Pet>(); Console.WriteLine("\n數據庫中的記錄:"); foreach (var pp in set) { Console.WriteLine($"{pp.Id} {pp.Name} {pp.Description} {pp.Category}"); } } } // 下面兩個方法處理事件 static void OnTracked(object? _, EntityTrackedEventArgs e) { var backupcolor = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine($"實體被追蹤:\n{e.Entry.DebugView.LongView}\n"); Console.ForegroundColor = backupcolor; } static void OnStateChanged(object? _, EntityStateChangedEventArgs e) { var bkColor = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Blue; Console.WriteLine($"實體(ID={e.Entry.Property(nameof(Pet.Id)).CurrentValue})狀態改變:{e.OldState} --> {e.NewState}\n"); Console.ForegroundColor = bkColor; }
現在再次運行,看看結果是不是符合你當初的期望。
現在的情況是:向數據庫插入記錄是第一個 DbContext 實例,完事後就釋放了,實體追蹤器自然就掛了;隨後創建了第二個 DbContext 實例,這時候從數據庫中查詢出兩條記錄都是沒有被追蹤的,所以要啓動追蹤,自然就能引發兩次 Tracked 事件了。
好了,各位,今天咱們就粗淺地聊到這裏。後面老周還會繼續討論實體追蹤的話題,本文主要是讓大夥伴們瞭解一下實體的狀態變化。