Entitiy Framework Core中使用ChangeTracker持久化實(shí)體修改歷史

背景介紹

在我們的日常開(kāi)發(fā)中,有時(shí)候需要記錄數(shù)據(jù)庫(kù)表中值的變化, 這時(shí)候我們通常會(huì)使用觸發(fā)器或者使用關(guān)系型數(shù)據(jù)庫(kù)中臨時(shí)表(Temporal Table)或數(shù)據(jù)變更捕獲(Change Data Capture)特性來(lái)記錄數(shù)據(jù)庫(kù)表中字段的值變化。原文的作者Gérald Barré講解了如何使用Entity Freamwork Core上下文中的ChangeTracker來(lái)獲取并保存實(shí)體的變化記錄。

原文鏈接 Entity Framework Core: History / Audit table

ChangeTracker

ChangeTracker是Entity Framework Core記錄實(shí)體變更的核心對(duì)象(這一點(diǎn)和以前版本的Entity Framework一致)。當(dāng)你使用Entity Framework Core進(jìn)行獲取實(shí)體對(duì)象、添加實(shí)體對(duì)象、刪除實(shí)體對(duì)象、更新實(shí)體對(duì)象、附加實(shí)體對(duì)象等操作時(shí),ChangeTracker都會(huì)記錄下來(lái)對(duì)應(yīng)的實(shí)體引用和對(duì)應(yīng)的實(shí)體狀態(tài)。
我們可以通過(guò)<code>ChangeTracker.Entries()</code>方法, 獲取到當(dāng)前上下文中使用的所有實(shí)體對(duì)象, 以及每個(gè)實(shí)體對(duì)象的狀態(tài)屬性State。

Entity Framework Core中可用的實(shí)體狀態(tài)屬性有以下幾種

  • Detached
  • Unchanged
  • Deleted
  • Modified
  • Added

所以如果我們要記錄實(shí)體的變更,只需要從ChangeTracker中取出所有Added, Deleted, Modified狀態(tài)的實(shí)體, 并將其記錄到一個(gè)日志表中即可。

我們的目標(biāo)

我們以下面這個(gè)例子為例。
當(dāng)前我們有一個(gè)顧客表Customer和一個(gè)日志表Audit, 其對(duì)應(yīng)的實(shí)體對(duì)象及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; }
    }

我們希望當(dāng)執(zhí)行以下代碼之后, 在Audit表中產(chǎn)生如下數(shù)據(jù)

    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();
            }
        }
    }
image

實(shí)現(xiàn)步驟

復(fù)寫(xiě)上下文SaveChangeAsync方法

首先我們添加一個(gè)AuditEntry類, 來(lái)生成變更記錄。

    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屬性表示變更的實(shí)體
  • TableName屬性表示實(shí)體對(duì)應(yīng)的數(shù)據(jù)庫(kù)表名
  • KeyValues屬性表示所有的主鍵值
  • OldValues屬性表示當(dāng)前實(shí)體所有變更屬性的原始值
  • NewValues屬性表示當(dāng)前實(shí)體所有變更屬性的新值
  • TemporaryProperties屬性表示當(dāng)前實(shí)體所有由數(shù)據(jù)庫(kù)生成的屬性集合

然后我們打開(kāi)SampleContext.cs, 復(fù)寫(xiě)方法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個(gè)方法<code>OnBeforeSaveChange()</code>和<code>OnAfterSaveChanges</code>。
  • <code>OnBeforeSaveChanges</code>是用來(lái)獲取所有需要記錄的實(shí)體
  • <code>OnAfterSaveChanges</code>是為了獲得實(shí)體中數(shù)據(jù)庫(kù)生成列的新值(例如自增列, 計(jì)算列)并持久化變更記錄, 這一步必須放置在調(diào)用父類<code>SaveChangesAsync</code>之后,因?yàn)橹挥谐志没螅拍塬@取自增列和計(jì)算列的新值。
  • 在<code>OnBeforeSaveChange</code>方法之后,<code>OnAfterSaveChanges</code>方法之前, 我們調(diào)用父類的<code>SaveChangesAsync</code>來(lái)保存實(shí)體變更。

然后我們來(lái)修改<code>OnBeforeSaveChanges</code>方法, 代碼如下

    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;
                }
            }
        }
    }
代碼解釋
  • <code>ChangeTracker.DetectChanges()</code>是強(qiáng)制上下文再做一次變更檢查
  • 由于Audit表也在ChangeTracker的管理中, 所以在<code>OnBeforeSaveChanges</code>方法中,我們需要將Audit表的實(shí)體排除掉,否則會(huì)出現(xiàn)死循環(huán)
  • 這里我們只需要操作所有Added, Modified, Deleted狀態(tài)的實(shí)體,所以Detached和Unchanged狀態(tài)的實(shí)體需要排除掉
  • ChangeTracker中記錄的每個(gè)實(shí)體都有一個(gè)<code>Properties</code>集合,里面記錄的每個(gè)實(shí)體所有屬性的狀態(tài), 如果某個(gè)屬性被修改了,則該屬性的<code>IsModified</code>是true.
  • 實(shí)體屬性Property對(duì)象中的<code>IsTemporary</code>屬性表明了該字段是不是數(shù)據(jù)庫(kù)生成的。 我們將所有數(shù)據(jù)庫(kù)生成的屬性放到了<code>TemplateProperties</code>集合中,供<code>OnAfterSaveChanges</code>方法遍歷
  • 我們可以通過(guò)Property對(duì)象的<code>Metadata.IsPrimaryKey()</code>方法來(lái)獲得當(dāng)前字段是不是主鍵字段
  • Property對(duì)象的CurrentValue屬性表示當(dāng)前字段的新值,OriginalValue屬性表示當(dāng)前字段的原始值

最后我們修改一下<code>OnAfterSaveChanges</code>, 代碼如下

    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();
    }
代碼解釋
  • 在<code>OnBeforeSaveChanges</code>中,我們記錄下了當(dāng)前實(shí)體所有需要數(shù)據(jù)庫(kù)生成的屬性。 在調(diào)用父類的<code>SaveChangesAsync</code>方法, 我們可以獲取通過(guò)property的<code>CurrentValue</code>屬性獲得到這些數(shù)據(jù)庫(kù)生成屬性的新值
  • 記錄下新值,之后我們生成變更實(shí)體記錄Audit,并添加到上下文中,再次調(diào)用SaveChangesAsync方法,將其持久化

當(dāng)前方案的問(wèn)題和適合的場(chǎng)景

  • 這個(gè)方案中,整個(gè)數(shù)據(jù)庫(kù)持久化并不在一個(gè)原子事務(wù)中,我們都知道Entity Framework的SaveChangesAsync方法是自帶事務(wù)的,但是調(diào)用2次SaveChangeAsync就不是一個(gè)事務(wù)作用域了,可能出現(xiàn)實(shí)體保存成功,Audit實(shí)體保存失敗的情況
  • 由于調(diào)用了2次SaveChangeAsync方法,所以Audit實(shí)體中的DateTime屬性并不能確切的反映保存實(shí)體操作的真正時(shí)間, 中間間隔了第一次SaveChangeAsync花費(fèi)的時(shí)間(個(gè)人認(rèn)為在<code>OnBeforeSaveChanges</code>中就可以生成這個(gè)DateTime讓時(shí)間更精確一些)
  • 如果所有實(shí)體屬性值都是預(yù)生成的,非數(shù)據(jù)庫(kù)生成的,作者這個(gè)方案還是非常好的,但是如果有數(shù)據(jù)庫(kù)自增列或計(jì)算列, 還是使用關(guān)系型數(shù)據(jù)庫(kù)中臨時(shí)表(Temporal Table)或數(shù)據(jù)變更捕獲(Change Data Capture)特性比較合理

本篇源代碼

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容