上一篇提到了 ChangeTracking + EFCore.BulkExtensions.BatchUpdate,可以很輕易的幫我們產生出有異動的 Update SQL 語法,如何使用 ChangeTracking 追蹤物件變化再透過 EFCore.BulkExtensions 存到資料庫
很可惜的是 EFCore.BulkExtensions.BatchUpdate 沒有支援異動多張資料表,對於資料庫命令的往返會隨著異動的資料表而增加,這次我想要改使用 EF Core 原生的異動追蹤。
追蹤異動變化使用 ChangeTracking ,消化異動並存放到操作資料庫則使用 EF / EF Core,當然,這不受限,你可以挑選妳喜歡的控制方式,接著,來看看怎麼實現它吧。
當我們需要用 Model (DTO) 異動資料庫某一筆資料的某些欄位,有幾種策略:
- 整筆更新,不追蹤欄位的變化,最簡單無腦,但存放到 DB 也最花費效能
- 使用 EF 的追蹤,更新前必須先從資料庫讀取一次,然後再把狀態蓋過去,由 EF 會幫你產生異動語法
- 根據追蹤紀錄組合 SQL 更新語法
這次我的實作,想要用 ChangeTrack 異動追蹤 + EF/ EF Core 的異動追蹤,來完成部分更新,Layer 的職責如下:
- Domain Layer 的職責:處理狀態的追蹤
- Repository Layer 的職責:根據狀態變化異動到資料庫
假若,使用 EF/EF Core 的查詢追蹤,再透過 db.Entry(original).CurrentValues.SetValues(updatedUser),也可以很輕易的處理異動,但缺點就是會多一段查詢。
var original = db.Users.Find(updatedUser.UserId);
if (original != null)
{
db.Entry(original).CurrentValues.SetValues(updatedUser);
db.SaveChanges();
}
另外,ChangeTracking 一定得用開放屬性才能使用,如果為了要必免外部改變物件的狀態,可以選用深複製回傳給外部。
開發環境
- Windows 11
- .NET 6
- Rider 2021.3.3
- ChangeTracking 2.2.17
- Microsoft.EntityFrameworkCore 6.0.1
開發
ChangeTracking 的使用方式上篇已經交代過了,不會使用的可以參考
如何使用 ChangeTracking 追蹤物件變化再透過 EFCore.BulkExtensions 存到資料庫 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
我希望在 _employeeRepository.SaveChangeAsync 這個方法,幫我把 trackable 追蹤到異動的結果寫入到資料庫
調用端的代碼如下:
[TestMethod]
public void 異動追蹤後存檔()
{
var employeeEntity = Insert().To();
var trackable = employeeEntity.AsTrackable();
trackable.Age = 20;
trackable.Name = "小章";
trackable.Remark = "我變了";
trackable.Identity.Remark = "我變了";
trackable.Addresses[0].Remark = "我變了";
trackable.Addresses.RemoveAt(1);
trackable.Addresses.Add(new AddressEntity()
{
Id = Guid.NewGuid(),
Employee_Id = employeeEntity.Id,
CreatedAt = DateTimeOffset.Now,
CreatedBy = "sys",
Country = "Taipei",
Street = "Street",
Remark = "我新的"
});
var count = this._employeeRepository.SaveChangeAsync(trackable).Result;
var dbContext = this._employeeDbContextFactory.CreateDbContext();
var actual = dbContext.Employees
.Where(p => p.Id == employeeEntity.Id)
.Include(p => p.Identity)
.Include(p => p.Addresses)
.First()
;
Assert.AreEqual("我變了",actual.Remark);
Assert.AreEqual("我變了",actual.Identity.Remark);
Assert.AreEqual("我變了",actual.Addresses[0].Remark);
Assert.AreEqual("我新的",actual.Addresses[1].Remark);
}
處理的手段很簡單,步驟如下:
- DbContext.Set<T>.Attach,列入 DbContext 追蹤
- 處理異動的欄位,跟 EF / EF Core 講,有哪一些欄位需要被翻譯 Update 語法
DbContext.Entry(targetInstance).Property(changedProperty).IsModified = true;
EmployEntity 的結構包含了兩個複雜型別,我在處理的時候排除了他們,不然 EF / EF Core 可能會噴錯
public class EmployeeRepository : RepositoryBase, IEmployeeRepository
{
private readonly IDbContextFactory<EmployeeDbContext> _memberContextFactory;
public EmployeeRepository(IDbContextFactory<EmployeeDbContext> memberContextFactory)
{
this._memberContextFactory = memberContextFactory;
}
public async Task<int> SaveChangeAsync(EmployeeEntity srcEmployee,
CancellationToken cancel = default)
{
await using var dbContext = await this._memberContextFactory.CreateDbContextAsync(cancel);
this.ApplyModify<EmployeeEntity, Employee>(dbContext, srcEmployee, new List<string>
{
"Identity",
"Addresses"
}
);
this.ApplyModify<IdentityEntity, Identity>(dbContext, srcEmployee.Identity);
this.ApplyChanges<AddressEntity, Address>(dbContext, srcEmployee.Addresses);
return await dbContext.SaveChangesAsync(cancel);
}
}
假若,沒有排除特定欄位,會導致 DbContext.Set<T>.Add 會出錯,得到例外訊息描述該筆 Entity 已存在 DbContext
System.AggregateException: One or more errors occurred. (The property 'Employee.Identity' is being accessed using the 'Property' method, but is defined in the model as a navigation. Use either the 'Reference' or 'Collection' method to access navigations.)
---> System.InvalidOperationException: The property 'Employee.Identity' is being accessed using the 'Property' method, but is defined in the model as a navigation. Use either the 'Reference' or 'Collection' method to access navigations.
at Microsoft.EntityFrameworkCore.Metadata.IReadOnlyEntityType.GetProperty(String name)
at Microsoft.EntityFrameworkCore.Metadata.IEntityType.GetProperty(String name)
at Microsoft.EntityFrameworkCore.ChangeTracking.PropertyEntry..ctor(InternalEntityEntry internalEntry, String name)
at Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry.Property(String propertyName)
at Lab.ChangeTracking.Domain.RepositoryBase.ApplyModify[TSource,TTarget](DbContext dbContext, TSource source, IEnumerable`1 excludeProperties) in D:\src\sample.dotblog\Property Change Tracking\ChangeTracking2\src\Lab.ChangeTracking.Domain\Employee\Repository\RepositoryBase.cs:line 77
at Lab.ChangeTracking.Domain.EmployeeRepository.SaveChangeAsync(EmployeeEntity srcEmployee, CancellationToken cancel) in D:\src\sample.dotblog\Property Change Tracking\ChangeTracking2\src\Lab.ChangeTracking.Domain\Employee\Repository\EmployeeRepository.cs:line 19
at Lab.ChangeTracking.Domain.EmployeeRepository.SaveChangeAsync(EmployeeEntity srcEmployee, CancellationToken cancel) in D:\src\sample.dotblog\Property Change Tracking\ChangeTracking2\src\Lab.ChangeTracking.Domain\Employee\Repository\EmployeeRepository.cs:line 27
--- End of inner exception stack trace ---
at System.Threading.Tasks.Task`1.GetResultCore(Boolean waitCompletionNotification)
at System.Threading.Tasks.Task`1.get_Result()
at Lab.ChangeTracking.Domain.UnitTest.ChangeTrackingUnitTest.異動追蹤後存檔() in D:\src\sample.dotblog\Property Change Tracking\ChangeTracking2\src\Lab.ChangeTracking.Domain.UnitTest\ChangeTrackingUnitTest.cs:line 45
複雜型別處理異動過程如下
- 把異動狀態抓出來
source.CastToIChangeTrackable();
- 取出有哪一些欄位被異動了,
sourceTrackable.ChangedProperties;
- 根據異動欄位跑迴圈,
dbContext.Entry(targetInstance).Property(changedProperty).IsModified = true;
protected void ApplyModify<TSource, TTarget>(DbContext dbContext,
TSource source,
IEnumerable<string> excludeProperties = null)
where TSource : class
where TTarget : class
{
var sourceTrackable = source.CastToIChangeTrackable();
var targetInstance = CreateNewInstance<TSource, TTarget>(source, excludeProperties);
dbContext.Set<TTarget>().Attach(targetInstance);
var changedProperties = sourceTrackable.ChangedProperties;
foreach (var changedProperty in changedProperties)
{
if (excludeProperties != null
&& excludeProperties.Any(p => p == changedProperty))
{
continue;
}
dbContext.Entry(targetInstance).Property(changedProperty).IsModified = true;
}
}
集合複雜型別處理異動過程如下
- 把集合異動狀態抓出來
sources.CastToIChangeTrackableCollection();
- 異動狀態都被分類到
targetsTrackable.ChangedItems
、targetsTrackable.AddedItems
、targetsTrackable.DeletedItems
- 針對不同的類型個別處理
protected void ApplyChanges<TSource, TTarget>(DbContext dbContext,
IList<TSource> sources,
IEnumerable<string> excludeProperties = null)
where TSource : class
where TTarget : class
{
var targetsTrackable = sources.CastToIChangeTrackableCollection();
if (targetsTrackable == null)
{
return;
}
var modifyItems = targetsTrackable.ChangedItems;
var addedItems = targetsTrackable.AddedItems;
var deletedItems = targetsTrackable.DeletedItems;
foreach (var source in modifyItems)
{
this.ApplyModify<TSource, TTarget>(dbContext, source, excludeProperties);
}
foreach (var addedItem in addedItems)
{
this.ApplyAdd<TSource, TTarget>(dbContext, addedItem, excludeProperties);
}
foreach (var source in deletedItems)
{
this.ApplyAdd<TSource, TTarget>(dbContext, source);
}
}
protected void ApplyAdd<TSource, TTarget>(DbContext dbContext,
TSource sourceInstance,
IEnumerable<string> excludeProperties = null)
where TSource : class
where TTarget : class
{
var targetInstance = CreateNewInstance<TSource, TTarget>(sourceInstance, excludeProperties);
dbContext.Entry(targetInstance).State = EntityState.Added;
}
protected void ApplyAdd<TSource, TTarget>(DbContext dbContext, TSource source)
where TSource : class where TTarget : class
{
var targetInstance = CreateDeleteInstance<TSource, TTarget>(source, "Id");
dbContext.Set<TTarget>().Attach(targetInstance);
dbContext.Entry(targetInstance).State = EntityState.Deleted;
}
結論
這裡用的是反射,對於效能有疑慮的,可以透過 Expression 提升反射的效能
[C#.NET] 利用 Expression Tree 提昇反射效率-動態屬性 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
[C#.NET] 利用 Expression Tree 提昇反射效率 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
會想要用 ChangeTracking 的用意,主要就是把狀態的異動職責從 Repository Layer 分離出來,這次的做法不需要 EF/ EF Core 的查詢追蹤也能產生出有異動的 SQL 語法,省掉了一段查詢,也讓狀態異動的職責落在外層,Repository Layer 則根據異動結果自行實作異動語法,當 EF / EF Core 不能用的時候,要組合出 SQL Raw 語法也不是太大的問題的,接下來我應該會採用本篇的概念,由外部注入將異動狀態,假若有甚麼狀況再跟各位分享。
ChangeTracking 只能追蹤 Property,當你想要產生出不可變的物件時,可以搭配深複製,缺點就是會額外多了一份相同的資源開銷。假使你的團隊使用的是充血模型,物件狀態本身就不從外部改變,這時你可能需要追蹤 field,下篇,我再來分享我的作法,今天先這樣了,謝謝大家。
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET