博客 / 詳情

返回

【EF Core】實體狀態與變更追蹤

好長時間沒有水文章了,請容老周解釋一下。因為最近老周進了兩個廠,第一個廠子呆了八天左右,第二個廠子還在調試。管理很嚴格,帶的電子設備都要登記、辦手續。當初覺得雷神筆記本的屏幕大,在車間調試代碼方便,所以登記了這個型號。但這個遊戲本功耗大,而且充電只能充到 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 事件,在開始追蹤狀態後,狀態發生改變之後打印變化前後的狀態。

代碼運行結果如下:

image

首先,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;
    }

現在再次運行,看看結果是不是符合你當初的期望。

image

現在的情況是:向數據庫插入記錄是第一個 DbContext 實例,完事後就釋放了,實體追蹤器自然就掛了;隨後創建了第二個 DbContext 實例,這時候從數據庫中查詢出兩條記錄都是沒有被追蹤的,所以要啓動追蹤,自然就能引發兩次 Tracked 事件了。

好了,各位,今天咱們就粗淺地聊到這裏。後面老周還會繼續討論實體追蹤的話題,本文主要是讓大夥伴們瞭解一下實體的狀態變化。

 

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

發佈 評論

Some HTML is okay.