241  
查询码:00000121
如何实现数据变更日志记录(EFCore)
作者: 朱鹏程 于 2021年06月21日 发布在分类 / 人防组 / 人防后端 下,并于 2021年07月03日 编辑
EFCore ElasticSearch Kibana

如何实现数据变更日志记录

采用ORM框架使用EFCore(3.x)版本

 


前言

因项目需要,在之前已实现将系统的运行日志采用Serilog ElasticSearch Kibana的方式进行日志采集-存储-展示。目前需要将数据操作的日志记录进行采集,本文将重点讲解如何采集数据变更记录。


 

一、核心思想

主要核心思想参考Weihan Li的博客EF Core 数据变更自动审计设计,实现的原理是基于 EF 的内置的 Change Tracking 来实现的,EF 每次 SaveChanges 之前都会检测变更,每条变更的记录都会记录变更前的属性值以及变更之后的属性值,因此我们可以在 SaveChanges 之前记录变更前后的属性,对于数据库生成的值,如 SQL Server 里的自增主键,在保存之前,属性的会被标记为 IsTemporary ,保存成功之后会自动更新,在保存之后可以获取到数据库生成的值。

二、实现步骤

1.在DbContext.cs文件中增加以下代码

重写SaveChanges、SaveChangesAsync方法,并增加BeforeSaveChanges、AfterSaveChanges方法。

        protected List<AuditEntry>? AuditEntries { get; set; } = new List<AuditEntry>();
 
        protected Task BeforeSaveChanges()
        {
            if (AuditConfig.AuditConfigOptions.AuditEnabled)
            {
 
                foreach (var entityEntry in ChangeTracker.Entries())
                {
                    if (entityEntry.State == EntityState.Detached || entityEntry.State == EntityState.Unchanged)
                    {
                        continue;
                    }
                    //
                    if (AuditConfig.AuditConfigOptions.EntityFilters.Any(entityFilter =>
                        entityFilter.Invoke(entityEntry) == false))
                    {
                        continue;
                    }
                    AuditEntries.Add(new InternalAuditEntry(entityEntry));
                }
            }
 
            return Task.CompletedTask;
        }
 
        protected Task AfterSaveChanges()
        {
            if (null != AuditEntries && AuditEntries.Count > 0)
            {
                foreach (var entry in AuditEntries)
                {
                    if (entry is InternalAuditEntry auditEntry)
                    {
                        // update TemporaryProperties
                        if (auditEntry.TemporaryProperties != null && auditEntry.TemporaryProperties.Count > 0)
                        {
                            foreach (var temporaryProperty in auditEntry.TemporaryProperties)
                            {
                                var colName = temporaryProperty.GetColumnName();
                                if (temporaryProperty.Metadata.IsPrimaryKey())
                                {
                                    auditEntry.KeyValues[colName] = temporaryProperty.CurrentValue;
                                }
 
                                switch (auditEntry.OperationType)
                                {
                                    case DataOperationType.Add:
                                        auditEntry.NewValues![colName] = temporaryProperty.CurrentValue;
                                        break;
 
                                    case DataOperationType.Delete:
                                        auditEntry.OriginalValues![colName] = temporaryProperty.OriginalValue;
                                        break;
 
                                    case DataOperationType.Update:
                                        auditEntry.OriginalValues![colName] = temporaryProperty.OriginalValue;
                                        auditEntry.NewValues![colName] = temporaryProperty.CurrentValue;
                                        break;
                                }
                            }
                            // set to null
                            auditEntry.TemporaryProperties = null;
                        }
                    }
 
 
 
                }
            }
 
            AuditEntries.ForEach((e) => _logger.Info(JsonConvert.SerializeObject(e)));
            return Task.CompletedTask;
        }
        public override int SaveChanges()
        {
            BeforeSaveChanges().ConfigureAwait(false).GetAwaiter().GetResult();
            var result = base.SaveChanges();
            AfterSaveChanges().ConfigureAwait(false).GetAwaiter().GetResult();
            return result;
        }
 
        public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        {
            await BeforeSaveChanges();
            var result = await base.SaveChangesAsync(cancellationToken);
            await AfterSaveChanges();
            return result;
        }

2.增加AuditEntry.cs、DataOperationType.cs、IAuditConfig.cs、EFInternalExtensions.cs类

AuditEntry.cs代码如下:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
 
namespace RunGo.Admin.EntityFrameworkCore
{
    public class AuditEntry
    {
        public string TableName { get; set; } = null!;
 
        public Dictionary<string, object?>? OriginalValues { get; set; }
 
        public Dictionary<string, object?>? NewValues { get; set; }
 
        public Dictionary<string, object?> KeyValues { get; } = new Dictionary<string, object?>();
 
        public DataOperationType OperationType { get; set; }
 
        public Dictionary<string, object?> Properties { get; } = new Dictionary<string, object?>();
 
        public DateTimeOffset UpdatedAt { get; set; }
 
        public string? UpdatedBy { get; set; }
    }
    internal sealed class InternalAuditEntry : AuditEntry
    {
        public List<PropertyEntry>? TemporaryProperties { get; set; }
 
        public InternalAuditEntry(EntityEntry entityEntry)
        {
            TableName = entityEntry.Metadata.GetTableName();
 
            if (entityEntry.Properties.Any(x => x.IsTemporary))
            {
                TemporaryProperties = new List<PropertyEntry>(4);
            }
 
            if (entityEntry.State == EntityState.Added)
            {
                OperationType = DataOperationType.Add;
                NewValues = new Dictionary<string, object?>();
            }
            else if (entityEntry.State == EntityState.Deleted)
            {
                OperationType = DataOperationType.Delete;
                OriginalValues = new Dictionary<string, object?>();
            }
            else if (entityEntry.State == EntityState.Modified)
            {
                OperationType = DataOperationType.Update;
                OriginalValues = new Dictionary<string, object?>();
                NewValues = new Dictionary<string, object?>();
            }
            foreach (var propertyEntry in entityEntry.Properties)
            {
                if (AuditConfig.AuditConfigOptions.PropertyFilters.Any(f => f.Invoke(entityEntry, propertyEntry) == false))
                {
                    continue;
                }
 
                if (propertyEntry.IsTemporary)
                {
                    TemporaryProperties!.Add(propertyEntry);
                    continue;
                }
 
                var columnName = propertyEntry.GetColumnName();
                if (propertyEntry.Metadata.IsPrimaryKey())
                {
                    KeyValues[columnName] = propertyEntry.CurrentValue;
                }
                switch (entityEntry.State)
                {
                    case EntityState.Added:
                        NewValues![columnName] = propertyEntry.CurrentValue;
                        break;
 
                    case EntityState.Deleted:
                        OriginalValues![columnName] = propertyEntry.OriginalValue;
                        break;
 
                    case EntityState.Modified:
                        if (propertyEntry.IsModified || AuditConfig.AuditConfigOptions.SaveUnModifiedProperties)
                        {
                            OriginalValues![columnName] = propertyEntry.OriginalValue;
                            NewValues![columnName] = propertyEntry.CurrentValue;
                        }
                        break;
                }
            }
        }
    }
}

DataIOperationType.cs代码如下:

using System;
using System.Collections.Generic;
using System.Text;
 
namespace RunGo.Admin.EntityFrameworkCore
{
    public enum DataOperationType : sbyte
    {
        /// <summary>
        /// 查询
        /// </summary>
        Query = 0,
        /// <summary>
        /// 新增
        /// </summary>
        Add = 1,
        /// <summary>
        /// 删除
        /// </summary>
        Delete = 2,
        /// <summary>
        /// 修改
        /// </summary>
        Update = 3
    }
}

IAuditConfig.cs代码如下:

using Microsoft.EntityFrameworkCore.ChangeTracking;
using System;
using System.Collections.Generic;
using System.Text;
 
namespace RunGo.Admin.EntityFrameworkCore
{
    internal sealed class AuditConfigOptions
    {
        public bool AuditEnabled { get; set; } = true;
 
        public bool SaveUnModifiedProperties { get; set; }
 
 
        private IReadOnlyCollection<Func<EntityEntry, bool>> _entityFilters = Array.Empty<Func<EntityEntry, bool>>();
 
        public IReadOnlyCollection<Func<EntityEntry, bool>> EntityFilters
        {
            get => _entityFilters;
            set => _entityFilters = value ?? throw new ArgumentNullException(nameof(value));
        }
 
        private IReadOnlyCollection<Func<EntityEntry, PropertyEntry, bool>> _propertyFilters = Array.Empty<Func<EntityEntry, PropertyEntry, bool>>();
 
        public IReadOnlyCollection<Func<EntityEntry, PropertyEntry, bool>> PropertyFilters
        {
            get => _propertyFilters;
            set => _propertyFilters = value ?? throw new ArgumentNullException(nameof(value));
        }
       
    }
    public sealed class AuditConfig
    {
        internal static AuditConfigOptions AuditConfigOptions = new AuditConfigOptions();
 
        public static void EnableAudit()
        {
            AuditConfigOptions.AuditEnabled = true;
        }
 
        public static void DisableAudit()
        {
            AuditConfigOptions.AuditEnabled = false;
        }
 
#nullable disable
 
    
 
#nullable restore
    }
}

EFInternalExtensions.cs代码如下:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using System;
using System.Collections.Generic;
using System.Text;
 
 
namespace RunGo.Admin.EntityFrameworkCore
{
    public static class EFInternalExtensions
    {
        public static string GetColumnName(this PropertyEntry propertyEntry)
        {
           
            return propertyEntry.Metadata.Name;
        }
    }
}

 

最后在将ILogger注入到DbContext中进行日志输出即可,因之前已搭建好了ES Kibana,故可直接在Kibana上展示,如下图所示:


总结

本实现方式在Weihan Li的原有方式上做了精简,虽然需求可以实现,但之后还会进行优化。

 注意:在使用时会遇到以下问题

在add-Migration 时会报错:

解决方法是在DbContextFactory类中增加一个无参的构造函数





 推荐知识

 历史版本

修改日期 修改人 备注
2021-07-03 16:03:00[当前版本] 朱鹏程 增加内容
2021-06-21 18:19:16 朱鹏程 格式调整
2021-06-21 18:04:29 朱鹏程 格式调整
2021-06-21 18:03:13 朱鹏程 创建版本

 附件

附件类型

PNGPNG

知识分享平台 -V 4.8.7 -wcp