如何实现数据变更日志记录
采用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的原有方式上做了精简,虽然需求可以实现,但之后还会进行优化。