自訂追蹤物件變化再透過 EF Core 存到資料庫

前面幾篇使用 ChangeTracking 來幫我們追蹤物件狀態,但他必須公開狀態讓外部可以修改,為了解決不被外部隨意修改的問題,可以利用深複製回傳一份不同實例的物件,這樣就可以不被外部影響;操作資料庫仍是使用 EF / EF Core,當然這不受限,你可以挑選妳喜歡的控制方式,接著,來看看怎麼實現它吧。

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

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

這次我的實作,想要自己實作追蹤紀錄,來完成部分更新,Layer 的職責如下: 

  • Domain Layer 的職責:處理狀態的追蹤
  • Repository Layer 的職責:根據狀態變化異動到資料庫
     

開發環境

  • Windows 11
  • .NET 6
  • Rider 2021.3.3
  • Microsoft.EntityFrameworkCore 6.0.1

實作

先決定調用端的代碼執行順序,先把它描述出來,這時候還沒有任何的物件所以會是紅色的毛毛蟲

Insert:建立資料表並回傳資料結構,表示從資料庫傳回來的狀態

AsTrackable:轉換成追蹤物件

SetProfile:改變狀態,這個操作建議給他一個明確的命名

AcceptChanges:同意變更,這裡我使用方法注入,為了讓 EmployeeEntity 中止變化,版號跳號。

[TestMethod]
public void 更新一筆資料()
{
    var fromDb = Insert();
    var employeeEntity = new EmployeeEntity();
    employeeEntity.AsTrackable(fromDb)
        .SetProfile("小章", 19, "我變了")
        .AcceptChanges(this._systemClock, _accessContext, _uuIdProvider);

    var count = this._employeeRepository.SaveChangeAsync(employeeEntity).Result;

    Assert.AreEqual(1, count);
    var dbContext = s_employeeDbContextFactory.CreateDbContext();

    var actual = dbContext.Employees
            .Where(p => p.Id == fromDb.Id)
            .Include(p => p.Identity)
            .Include(p => p.Addresses)
            .First()
        ;
    Assert.AreEqual("小章", actual.Name);
    Assert.AreEqual(19, actual.Age);
    Assert.AreEqual("我變了", actual.Remark);
}

 

異動追蹤的合約

讓 Domain、Repository Layer 兩邊共同遵守

ChangeTrack:每次的異動都會呼叫,並且把異動的欄位及狀態放進去集合,透過GetChangedProperties取得異動資訊

AcceptChanges:認可異動,這裡採用方法注入,而不是建構函數注入,當不需要管理 Entity 狀態的時候,仍可以輕鬆的建立

public interface IChangeTrackable : IChangeContent, IChangeTime, IChangeState
{
    public (Error<string> err, bool changed) AcceptChanges(ISystemClock systemClock,
                                                           IAccessContext accessContext,
                                                           IUUIdProvider idProvider);

    void ChangeTrack(string propertyName, object value);

    void RejectChanges();
}

 

變更時間

public interface IChangeTime
{
    DateTimeOffset CreatedAt { get; init; }

    string CreatedBy { get; init; }

    DateTimeOffset? ModifiedAt { get; init; }

    string? ModifiedBy { get; init; }
}

 

異動狀態及版本

public interface IChangeState
{
    EntityState EntityState { get; init; }

    CommitState CommitState { get; init; }

    int Version { get; init; }
}
public enum EntityState
{
    Unchanged = 0,
    Added = 1,
    Modified = 2,
    Deleted = 3,
}
public enum CommitState
{
    Unchanged = 0,
    Accepted = 1,
    Rejected = 2,
}

 

EntityBase實作IChangeTrackableChangeTrack管理異動集合_changedProperties,有變化的才會被放進去,搭配_originalValues讓沒有變化的狀態的從_changedProperties移走。

除此之外,還判斷 Entity 的 CommitState 若以已經是CommitState.Accepted則不能再異動狀態

public abstract record EntityBase : IChangeTrackable
{
    public Guid Id
    {
        get => this._id;
        init => this._id = value;
    }

    protected readonly Dictionary<string, object> _changedProperties = new();
    protected readonly Dictionary<string, object> _originalValues = new();
    protected CommitState _commitState;
    protected DateTimeOffset _createdAt;
    protected string _createdBy;
    protected EntityState _entityState;
    protected Guid _id;
    protected DateTimeOffset? _modifiedAt;
    protected string? _modifiedBy;
    protected int _version;

    public EntityBase AsTrackable()
    {
        this.Validate();

        // this._entityState = EntityState.Added;
        // this._commitState = CommitState.Unchanged;
        // this._version = 1;
        var properties = this.GetType().GetProperties();
        foreach (var property in properties)
        {
            this._originalValues.Add(property.Name, property.GetValue(this));
        }

        return this;
    }

    public (Error<string> err, bool changed) AcceptChanges(ISystemClock systemClock,
                                                           IAccessContext accessContext,
                                                           IUUIdProvider idProvider)
    {
        this.Validate();

        this._commitState = CommitState.Accepted;
        var (now, accessUserId) = (systemClock.GetNow(), accessContext.GetUserId());

        if (this.EntityState == EntityState.Unchanged)
        {
            return (null, false);
        }

        if (this.EntityState == EntityState.Added)
        {
            this._id = idProvider.GenerateId();
            this._createdAt = now;
            this._createdBy = accessUserId;
            this._version = 1;
        }
        else
        {
            this._version = this._version++;
        }

        this._modifiedAt = now;
        this._modifiedBy = accessUserId;



        return (null, true);
    }

    public abstract void RejectChanges();

    public Dictionary<string, object> GetChangedProperties()
    {
        return this._changedProperties;
    }

    public EntityState EntityState
    {
        get => this._entityState;
        init => this._entityState = value;
    }

    public CommitState CommitState
    {
        get => this._commitState;
        init => this._commitState = value;
    }

    public int Version
    {
        get => this._version;
        init => this._version = value;
    }

    public Dictionary<string, object> GetOriginalValues()
    {
        return this._originalValues;
    }

    public DateTimeOffset CreatedAt
    {
        get => this._createdAt;
        init => this._createdAt = value;
    }

    public string? CreatedBy
    {
        get => this._createdBy;
        init => this._createdBy = value;
    }

    public DateTimeOffset? ModifiedAt
    {
        get => this._modifiedAt;
        init => this._modifiedAt = value;
    }

    public string? ModifiedBy
    {
        get => this._modifiedBy;
        init => this._modifiedBy = value;
    }

    public void ChangeTrack(string propertyName, object value)
    {
        this.Validate();

        var changes = this._changedProperties;
        var originals = this._originalValues;
        if (originals.Count <= 0)
        {
            throw new Exception("尚未啟用追蹤");
        }

        if (changes.ContainsKey(propertyName) == false)
        {
            if (originals[propertyName] != value)
            {
                changes.Add(propertyName, value);
                this._entityState = EntityState.Modified;
            }
        }
        else
        {
            if (originals[propertyName].ToString() == value.ToString())
            {
                changes.Remove(propertyName);
            }
        }

        if (changes.Count <= 0)
        {
            this._entityState = EntityState.Unchanged;
        }
    }

    private void Validate()
    {
        if (this.CommitState == CommitState.Accepted)
        {
            throw new Exception("已經同意,無法再進行修改");
        }
    }
}

 

最後,EmployeeEntity實作EntityBase,對外,狀態都是唯讀,只能由內部改變狀態,每次異動都必須調用ChangeTrack讓狀態進入異動集合

public record EmployeeEntity : EntityBase
{
    public string Name
    {
        get => this._name;
        init => this._name = value;
    }

    public int? Age
    {
        get => this._age;
        init => this._age = value;
    }

    public string Remark
    {
        get => this._remark;
        init => this._remark = value;
    }

    public List<AddressEntity> Addresses { get; init; }

    public IdentityEntity Identity { get; init; }

    private int? _age;
    private string _name;
    private string _remark;

    /// <summary>
    ///     從資料庫查到之後放進去
    /// </summary>
    /// <param name="employee"></param>
    /// <returns></returns>
    public EmployeeEntity AsTrackable(Employee employee)
    {
        this._changedProperties.Clear();
        this._originalValues.Clear();
        this._entityState = EntityState.Unchanged;
        this._commitState = CommitState.Unchanged;
        this._id = employee.Id;
        this._version = employee.Version;
        this._createdAt = employee.CreatedAt;
        this._createdBy = employee.CreatedBy;
        this._modifiedAt = employee.ModifiedAt;
        this._modifiedBy = employee.ModifiedBy;
        this._version = employee.Version;
        this._name = employee.Name;
        this._age = employee.Age;
        this._remark = employee.Remark;

        // Addresses = null,
        // Identity = null,

        this.AsTrackable();
        return this;
    }

    public EmployeeEntity SetDelete()
    {
        this._entityState = EntityState.Deleted;
        return this;
    }

    public EmployeeEntity New(string name, int age, string remark = null)
    {
        this._entityState = EntityState.Added;
        this._commitState = CommitState.Unchanged;
        this._version = 1;
        this._name = name;
        this._age = age;
        this._remark = remark;
        return this;
    }

    public override void RejectChanges()
    {
        throw new NotImplementedException();
    }

    public EmployeeEntity SetProfile(string name, int age, string remark = null)
    {
        this._name = name;
        this._age = age;
        this._remark = remark;
        this.ChangeTrack(nameof(this.Name), name);
        this.ChangeTrack(nameof(this.Age), age);
        this.ChangeTrack(nameof(this.Remark), remark);
        return this;
    }
}

 

最後,來看看EmployeeRepository怎麼消化這些異動

  • Entity 的EntityState,用來讓EmployeeRepository決定現在是要新增、修改還是刪除。
  • Entity 的GetChangedProperties,記錄了狀態異動,這裡一樣採用 EF / EFCore 的追蹤DbContext.Entry,當然你也可以用你習慣的方式來操作,並不限於 EF/ EF Core
public class EmployeeRepository : IEmployeeRepository
{
    private readonly IDbContextFactory<EmployeeDbContext> _employeeDbContextFactory;

    public EmployeeRepository(IDbContextFactory<EmployeeDbContext> memberContextFactory)
    {
        this._employeeDbContextFactory = memberContextFactory;
    }

    public async Task<int> SaveChangeAsync(EmployeeEntity srcEmployee,
                                           IEnumerable<string> excludeProperties = null,
                                           CancellationToken cancel = default)
    {
        if (srcEmployee.CommitState != CommitState.Accepted)
        {
            throw new Exception($"{nameof(srcEmployee)} 尚未核准,不得儲存");
        }

        await using var dbContext = await this._employeeDbContextFactory.CreateDbContextAsync(cancel);
        switch (srcEmployee.EntityState)
        {
            case EntityState.Added:
                ApplyAdd(dbContext, srcEmployee);
                break;
            case EntityState.Modified:
                ApplyModify(dbContext, srcEmployee, excludeProperties);

                break;
            case EntityState.Deleted:
                ApplyDelete(srcEmployee, dbContext);

                break;

            case EntityState.Unchanged:
                return 0;
            default:
                throw new ArgumentOutOfRangeException();
        }

        return await dbContext.SaveChangesAsync(cancel);
    }

    private static void ApplyDelete(EmployeeEntity srcEmployee, EmployeeDbContext dbContext)
    {
        dbContext.Set<Employee>().Remove(new Employee() { Id = srcEmployee.Id });
    }

    private static void ApplyAdd(EmployeeDbContext dbContext, EmployeeEntity srcEmployee)
    {
        dbContext.Set<Employee>().Add(srcEmployee.To());
    }

    private static void ApplyModify(EmployeeDbContext dbContext,
                                    EmployeeEntity srcEmployee,
                                    IEnumerable<string> excludeProperties = null)
    {
        var destEmployee = new Employee()
        {
            Id = srcEmployee.Id
        };

        dbContext.Set<Employee>().Attach(destEmployee);
        var employeeEntry = dbContext.Entry(destEmployee);

        foreach (var property in srcEmployee.GetChangedProperties())
        {
            var propertyName = property.Key;
            var value = property.Value;
            if (excludeProperties != null
                && excludeProperties.Any(p => p == propertyName))
            {
                continue;
            }

            dbContext.Entry(destEmployee).Property(propertyName).CurrentValue = value;
            employeeEntry.Property(propertyName).IsModified = true;
        }
    }
}

 

執行結果如下:

結論

ChangeTracking 套件可以很輕鬆的自動追蹤狀態(Property)的變化,但也有可能會追蹤到不想要追蹤的變化;而本篇的做法卻相當精實的一個一個控制,說好聽的,控制自如,不須太過花俏的技巧,也不受限套件;說難聽的,漏掉一個就不妙了,不管用哪一種方式,我都會用測試案例來確保我寫的我想的是一樣的。

這篇還缺少了複雜型別/複雜集合的追蹤,Repository 消化完異動後的狀態復原,今天先這樣了。

範例位置

sample.dotblog/Property Change Tracking/ChangeTrackProperty 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