采用ORM框架使用EFCore(3.x)版本
因项目需要,在之前已实现将系统的运行日志采用Serilog ElasticSearch Kibana的方式进行日志采集-存储-展示。目前需要将数据操作的日志记录进行采集,本文将重点讲解如何采集数据变更记录。
主要核心思想参考Weihan Li的博客EF Core 数据变更自动审计设计,实现的原理是基于 EF 的内置的 Change Tracking 来实现的,EF 每次 SaveChanges 之前都会检测变更,每条变更的记录都会记录变更前的属性值以及变更之后的属性值,因此我们可以在 SaveChanges 之前记录变更前后的属性,对于数据库生成的值,如 SQL Server 里的自增主键,在保存之前,属性的会被标记为 IsTemporary ,保存成功之后会自动更新,在保存之后可以获取到数据库生成的值。
重写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; }
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 | 朱鹏程 | 创建版本 |
附件类型 |
|
|
|