Entitiy Framework Core中使用ChangeTracker持久化實體修改歷史
背景介紹
在我們的日常開發中,有時候需要記錄資料庫表中值的變化, 這時候我們通常會使用觸發器或者使用關係型資料庫中臨時表(Temporal Table)或資料變更捕獲(Change Data Capture)特性來記錄資料庫表中欄位的值變化。原文的作者Gérald Barré講解了如何使用Entity Freamwork Core上下文中的ChangeTracker來獲取並儲存實體的變化記錄。
原文連結 ofollow,noindex" target="_blank">Entity Framework Core: History / Audit table
ChangeTracker
ChangeTracker是Entity Framework Core記錄實體變更的核心物件(這一點和以前版本的Entity Framework一致)。當你使用Entity Framework Core進行獲取實體物件、新增實體物件、刪除實體物件、更新實體物件、附加實體物件等操作時,ChangeTracker都會記錄下來對應的實體引用和對應的實體狀態。
我們可以通過 ChangeTracker.Entries()
方法, 獲取到當前上下文中使用的所有實體物件, 以及每個實體物件的狀態屬性State。
Entity Framework Core中可用的實體狀態屬性有以下幾種
- Detached
- Unchanged
- Deleted
- Modified
- Added
所以如果我們要記錄實體的變更,只需要從ChangeTracker中取出所有Added, Deleted, Modified狀態的實體, 並將其記錄到一個日誌表中即可。
我們的目標
我們以下面這個例子為例。
當前我們有一個顧客表Customer和一個日誌表Audit, 其對應的實體物件及Entity Framework上下文如下:
Audit.cs
[Table("Audit")] public class Audit { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } public string TableName { get; set; } public DateTime DateTime { get; set; } public string KeyValues { get; set; } public string OldValues { get; set; } public string NewValues { get; set; } }
Customer.cs
[Table("Customer")] public class Customer { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } }
SampleContext.cs
public class SampleContext : DbContext { public SampleContext() { } public DbSet<Customer> Customers { get; set; } public DbSet<Audit> Audits { get; set; } }
我們希望當執行以下程式碼之後, 在Audit表中產生如下資料
class Program { static void Main(string[] args) { using (var context = new SampleContext()) { // Insert a row var customer = new Customer(); customer.FirstName = "John"; customer.LastName = "doe"; context.Customers.Add(customer); context.SaveChangesAsync().Wait(); // Update the first customer customer.LastName = "Doe"; context.SaveChangesAsync().Wait(); // Delete the customer context.Customers.Remove(customer); context.SaveChangesAsync().Wait(); } } }
實現步驟
複寫上下文SaveChangeAsync方法
首先我們新增一個AuditEntry類, 來生成變更記錄。
public class AuditEntry { public AuditEntry(EntityEntry entry) { Entry = entry; } public EntityEntry Entry { get; } public string TableName { get; set; } public Dictionary<string, object> KeyValues { get; } = new Dictionary<string, object>(); public Dictionary<string, object> OldValues { get; } = new Dictionary<string, object>(); public Dictionary<string, object> NewValues { get; } = new Dictionary<string, object>(); public List<PropertyEntry> TemporaryProperties { get; } = new List<PropertyEntry>(); public bool HasTemporaryProperties => TemporaryProperties.Any(); public Audit ToAudit() { var audit = new Audit(); audit.TableName = TableName; audit.DateTime = DateTime.UtcNow; audit.KeyValues = JsonConvert.SerializeObject(KeyValues); audit.OldValues = OldValues.Count == 0 ? null : JsonConvert.SerializeObject(OldValues); audit.NewValues = NewValues.Count == 0 ? null : JsonConvert.SerializeObject(NewValues); return audit; } }
程式碼解釋
- Entry屬性表示變更的實體
- TableName屬性表示實體對應的資料庫表名
- KeyValues屬性表示所有的主鍵值
- OldValues屬性表示當前實體所有變更屬性的原始值
- NewValues屬性表示當前實體所有變更屬性的新值
- TemporaryProperties屬性表示當前實體所有由資料庫生成的屬性集合
然後我們開啟SampleContext.cs, 複寫方法SaveChangeAsync程式碼如下。
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken)) { var auditEntries = OnBeforeSaveChanges(); var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); await OnAfterSaveChanges(auditEntries); return result; } private List<AuditEntry> OnBeforeSaveChanges() { throw new NotImplementedException(); } private Task OnAfterSaveChanges(List<AuditEntry> auditEntries) { throw new NotImplementedException(); }
程式碼解釋
- 這裡我們添加了2個方法
OnBeforeSaveChange()
和OnAfterSaveChanges
。 -
OnBeforeSaveChanges
是用來獲取所有需要記錄的實體 -
OnAfterSaveChanges
是為了獲得實體中資料庫生成列的新值(例如自增列, 計算列)並持久化變更記錄, 這一步必須放置在呼叫父類SaveChangesAsync
之後,因為只有持久化之後,才能獲取自增列和計算列的新值。 - 在
OnBeforeSaveChange
方法之後,OnAfterSaveChanges
方法之前, 我們呼叫父類的SaveChangesAsync
來儲存實體變更。
然後我們來修改 OnBeforeSaveChanges
方法, 程式碼如下
private List<AuditEntry> OnBeforeSaveChanges() { ChangeTracker.DetectChanges(); var auditEntries = new List<AuditEntry>(); foreach (var entry in ChangeTracker.Entries()) { if (entry.Entity is Audit || entry.State == EntityState.Detached || entry.State == EntityState.Unchanged) continue; var auditEntry = new AuditEntry(entry); auditEntry.TableName = entry.Metadata.Relational().TableName; auditEntries.Add(auditEntry); foreach (var property in entry.Properties) { if (property.IsTemporary) { // value will be generated by the database, get the value after saving auditEntry.TemporaryProperties.Add(property); continue; } string propertyName = property.Metadata.Name; if (property.Metadata.IsPrimaryKey()) { auditEntry.KeyValues[propertyName] = property.CurrentValue; continue; } switch (entry.State) { case EntityState.Added: auditEntry.NewValues[propertyName] = property.CurrentValue; break; case EntityState.Deleted: auditEntry.OldValues[propertyName] = property.OriginalValue; break; case EntityState.Modified: if (property.IsModified) { auditEntry.OldValues[propertyName] = property.OriginalValue; auditEntry.NewValues[propertyName] = property.CurrentValue; } break; } } } }
程式碼解釋
-
ChangeTracker.DetectChanges()
是強制上下文再做一次變更檢查 - 由於Audit表也在ChangeTracker的管理中, 所以在
OnBeforeSaveChanges
方法中,我們需要將Audit表的實體排除掉,否則會出現死迴圈 - 這裡我們只需要操作所有Added, Modified, Deleted狀態的實體,所以Detached和Unchanged狀態的實體需要排除掉
- ChangeTracker中記錄的每個實體都有一個
Properties
集合,裡面記錄的每個實體所有屬性的狀態, 如果某個屬性被修改了,則該屬性的IsModified
是true. - 實體屬性Property物件中的
IsTemporary
屬性表明了該欄位是不是資料庫生成的。 我們將所有資料庫生成的屬性放到了TemplateProperties
集合中,供OnAfterSaveChanges
方法遍歷 - 我們可以通過Property物件的
Metadata.IsPrimaryKey()
方法來獲得當前欄位是不是主鍵欄位 - Property物件的CurrentValue屬性表示當前欄位的新值,OriginalValue屬性表示當前欄位的原始值
最後我們修改一下 OnAfterSaveChanges
, 程式碼如下
private Task OnAfterSaveChanges(List<AuditEntry> auditEntries) { if (auditEntries == null || auditEntries.Count == 0) return Task.CompletedTask; foreach (var auditEntry in auditEntries) { // Get the final value of the temporary properties foreach (var prop in auditEntry.TemporaryProperties) { if (prop.Metadata.IsPrimaryKey()) { auditEntry.KeyValues[prop.Metadata.Name] = prop.CurrentValue; } else { auditEntry.NewValues[prop.Metadata.Name] = prop.CurrentValue; } } // Save the Audit entry Audits.Add(auditEntry.ToAudit()); } return SaveChangesAsync(); }
程式碼解釋
- 在
OnBeforeSaveChanges
中,我們記錄下了當前實體所有需要資料庫生成的屬性。 在呼叫父類的SaveChangesAsync
方法, 我們可以獲取通過property的CurrentValue
屬性獲得到這些資料庫生成屬性的新值 - 記錄下新值,之後我們生成變更實體記錄Audit,並新增到上下文中,再次呼叫SaveChangesAsync方法,將其持久化
當前方案的問題和適合的場景
OnBeforeSaveChanges