如何使用 ChangeTracking 追蹤物件變化再透過 EF Core 存到資料庫

上一篇提到了 ChangeTracking + EFCore.BulkExtensions.BatchUpdate,可以很輕易的幫我們產生出有異動的 Update SQL 語法,如何使用 ChangeTracking 追蹤物件變化再透過 EFCore.BulkExtensions 存到資料庫 

很可惜的是 EFCore.BulkExtensions.BatchUpdate 沒有支援異動多張資料表,對於資料庫命令的往返會隨著異動的資料表而增加,這次我想要改使用 EF Core 原生的異動追蹤。

追蹤異動變化使用 ChangeTracking ,消化異動並存放到操作資料庫則使用 EF / EF Core,當然,這不受限,你可以挑選妳喜歡的控制方式,接著,來看看怎麼實現它吧。

當我們需要用 Model (DTO) 異動資料庫某一筆資料的某些欄位,有幾種策略:

  1. 整筆更新,不追蹤欄位的變化,最簡單無腦,但存放到 DB 也最花費效能
  2. 使用 EF 的追蹤,更新前必須先從資料庫讀取一次,然後再把狀態蓋過去,由 EF 會幫你產生異動語法
  3. 根據追蹤紀錄組合 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);
}

 

處理的手段很簡單,步驟如下:

  1. DbContext.Set<T>.Attach,列入 DbContext 追蹤
  2. 處理異動的欄位,跟 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

 

複雜型別處理異動過程如下

  1. 把異動狀態抓出來 source.CastToIChangeTrackable();
  2. 取出有哪一些欄位被異動了,sourceTrackable.ChangedProperties;
  3. 根據異動欄位跑迴圈,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;
    }
}

 

集合複雜型別處理異動過程如下

  1. 把集合異動狀態抓出來 sources.CastToIChangeTrackableCollection();
  2. 異動狀態都被分類到targetsTrackable.ChangedItemstargetsTrackable.AddedItemstargetsTrackable.DeletedItems
  3. 針對不同的類型個別處理
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,下篇,我再來分享我的作法,今天先這樣了,謝謝大家。

 

範例位置

sample.dotblog/Property Change Tracking/ChangeTracking2 at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo