【EF Core】实体状态与变更追踪
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
记得上一篇水文中,老周说了把一个实体映射到多个表的话题。注意,一实体一数据表的原则是不变的,这种特殊情况可以用在你这几个表可以组成一个整体,并且经常一起使用的,这样你在查询时就不用联合了,一般是一对一关系的。 既然实体能分布到多个表中,那反过来呢?能把多个实体映射到一个表中吗?当然可以了,官方称作“表拆分”。同样的道理,一般也是一对一的关系。 咱们直接用实例来说明。假设下面有两个实体。 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 事件了。 转自https://www.cnblogs.com/tcjiaan/p/19528796 该文章在 2026/2/4 10:16:42 编辑过 |
关键字查询
相关文章
正在查询... |