这回终于可以 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 这样的便捷方法。
本文到此结束。