前面幾篇使用 ChangeTracking 來幫我們追蹤物件狀態,但他必須公開狀態讓外部可以修改,為了解決不被外部隨意修改的問題,可以利用深複製回傳一份不同實例的物件,這樣就可以不被外部影響;操作資料庫仍是使用 EF / EF Core,當然這不受限,你可以挑選妳喜歡的控制方式,接著,來看看怎麼實現它吧。
當我們需要用 Model (DTO) 異動資料庫某一筆資料的某些欄位,有幾種策略:
- 整筆更新,不用追蹤欄位的變化,最簡單無腦,但存放到 DB 也最花費效能
- 使用 EF 的追蹤,更新前必須先從資料庫讀取一次,然後再把狀態蓋過去,由 EF 會幫你產生異動語法
- 根據追蹤紀錄組合 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
實作IChangeTrackable
,ChangeTrack
管理異動集合_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 消化完異動後的狀態復原,今天先這樣了。
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET