EF Core 数据变更自动审计设计
EF Core 数据变更自动审计设计
Intro
有的时候我们需要知道每个数据表的变更记录以便做一些数据审计,数据恢复以及数据同步等之类的事情,
EF 自带了对象追踪,使得我们可以很方便的做一些审计工作,每次变更发生了什么变化都变得很清晰,于是就基于 EF 封装了一层数据变更自动审计
使用效果
测试代码:
private static void AutoAuditTest()
{
// 审计配置
AuditConfig.Configure(builder =>
{
builder
// 配置操作用户获取方式
.WithUserIdProvider(EnvironmentAuditUserIdProvider.Instance.Value)
//.WithUnModifiedProperty() // 保存未修改的属性,默认只保存发生修改的属性
// 保存更多属性
.EnrichWithProperty("MachineName", Environment.MachineName)
.EnrichWithProperty(nameof(ApplicationHelper.ApplicationName), ApplicationHelper.ApplicationName)
// 保存到自定义的存储
.WithStore<AuditFileStore>()
.WithStore<AuditFileStore>("logs.log")
// 忽略指定实体
.IgnoreEntity<AuditRecord>()
// 忽略指定实体的某个属性
.IgnoreProperty<TestEntity>(t => t.CreatedAt)
// 忽略所有属性名称为 CreatedAt 的属性
.IgnoreProperty("CreatedAt")
;
});
DependencyResolver.TryInvokeService<TestDbContext>(dbContext =>
{
dbContext.Database.EnsureDeleted();
dbContext.Database.EnsureCreated();
var testEntity = new TestEntity()
{
Extra = new { Name = "Tom" }.ToJson(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.TestEntities.Add(testEntity);
dbContext.SaveChanges();
testEntity.CreatedAt = DateTimeOffset.Now;
testEntity.Extra = new { Name = "Jerry" }.ToJson();
dbContext.SaveChanges();
dbContext.Remove(testEntity);
dbContext.SaveChanges();
var testEntity1 = new TestEntity()
{
Extra = new { Name = "Tom1" }.ToJson(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.TestEntities.Add(testEntity1);
var testEntity2 = new TestEntity()
{
Extra = new { Name = "Tom2" }.ToJson(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.TestEntities.Add(testEntity2);
dbContext.SaveChanges();
});
DependencyResolver.TryInvokeService<TestDbContext>(dbContext =>
{
dbContext.Remove(new TestEntity()
{
Id = 2
});
dbContext.SaveChanges();
});
// disable audit
AuditConfig.DisableAudit();
}
查看审计记录信息:

可以看到,每次数据变更都会被记录下来,CreatedAt 没有记录是因为上面配置的忽略 CreatedAt 属性信息的记录。
这里的 TableName ,属性名称和 Entity 定义的不同是为了测试列名和属性名称不一致的情况,实际记录的是数据库里的表名称和列名称,之所以这样设计考虑的是可能多个应用使用同一张表,但是不同的应用里可能使用的 Entity 和 Property 都不同,所以统一使用了数据库的表名称和字段名称。
OperationType是一个枚举,1是新增,2是删除,3是修改。
Extra 列对应的就是我们自定义的增加的审计属性
UpdatedBy 是我们配置的 UserIdProvider 所提供的操作用户的信息
值得注意的是最后一条变更记录,这条数据的删除没有经过数据库查询,直接删除的,EF 不知道原本的除了主键之外的信息,所以记录的原始信息可能不准确,不过还是知道谁删除的这一条数据,对比之前的变更还是可以满足需求的。
实现原理
实现的原理是基于 EF 的内置的 Change Tracking 来实现的,EF 每次 SaveChanges 之前都会检测变更,每条变更的记录都会记录变更前的属性值以及变更之后的属性值,因此我们可以在 SaveChanges 之前记录变更前后的属性,对于数据库生成的值,如 SQL Server 里的自增主键,在保存之前,属性的会被标记为 IsTemporary ,保存成功之后会自动更新,在保存之后可以获取到数据库生成的值。
实现代码
首先实现一个 DbContextBase,重写 SaveChanges 和 SaveChangesAsync 方法,增加
BeforeSaveChanges 和 AfterSaveChanges 方法,用于处理我们要自定义的保存之前和保存之后的逻辑。
public abstract class DbContextBase : DbContext
{
protected DbContextBase()
{
}
protected DbContextBase(DbContextOptions dbContextOptions) : base(dbContextOptions)
{
}
protected virtual Task BeforeSaveChanges() => Task.CompletedTask;
protected virtual Task AfterSaveChanges() => Task.CompletedTask;
public override int SaveChanges()
{
BeforeSaveChanges().Wait();
var result = base.SaveChanges();
AfterSaveChanges().Wait();
return result;
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
await BeforeSaveChanges();
var result = await base.SaveChangesAsync(cancellationToken);
await AfterSaveChanges();
return result;
}
接着来实现一个用来自动审计的 AuditDbContextBase,核心代码如下:
public abstract class AuditDbContextBase : DbContextBase
{
protected AuditDbContextBase()
{
}
protected AuditDbContextBase(DbContextOptions dbContextOptions) : base(dbContextOptions)
{
}
protected List<AuditEntry> AuditEntries { get; set; }
protected override Task BeforeSaveChanges()
{
AuditEntries = new List<AuditEntry>();
foreach (var entityEntry in ChangeTracker.Entries())
{
if (entityEntry.State == EntityState.Detached || entityEntry.State == EntityState.Unchanged)
{
continue;
}
AuditEntries.Add(new AuditEntry(entityEntry));
}
return Task.CompletedTask;
}
protected override async Task AfterSaveChanges()
{
if (null != AuditEntries && AuditEntries.Count > 0)
{
foreach (var auditEntry in AuditEntries)
{
// update TemporaryProperties
if (auditEntry.TemporaryProperties != null && auditEntry.TemporaryProperties.Count > 0)
{
foreach (var temporaryProperty in auditEntry.TemporaryProperties)
{
var colName = temporaryProperty.Metadata.GetColumnName();
if (temporaryProperty.Metadata.IsPrimaryKey())
{
auditEntry.KeyValues[colName] = temporaryProperty.CurrentValue;
}
switch (auditEntry.OperationType)
{
case OperationType.Add:
auditEntry.NewValues[colName] = temporaryProperty.CurrentValue;
break;
case OperationType.Delete:
auditEntry.OriginalValues[colName] = temporaryProperty.OriginalValue;
break;
case OperationType.Update:
auditEntry.OriginalValues[colName] = temporaryProperty.OriginalValue;
auditEntry.NewValues[colName] = temporaryProperty.CurrentValue;
break;
}
}
// set to null
auditEntry.TemporaryProperties = null;
}
}
// ... save audit entries
}
}
此时我们已经可以实现自动的审计处理了,但是在实际业务处理的过程中,往往我们还会有更多的需求,
比如上面的实现还没有加入更新人,不知道是由谁来操作的,有些字段可能不希望被记录下来,或者有些表不要记录,还有我们向增加一些自定义的属性,比如多个应用操作同一个数据库表的时候我们可能希望记录下来是哪一个用户通过哪一个应用来更新的等等,所以之前上面的实现还是不能够实际应用的,于是我又在上面的基础上增加了一些配置以及扩展,使得自动审计扩展性更好,可定制性更强。
扩展设计
UserIdProvider
我们可以通过 UserIdProvider 来实现操作用户信息的获取,默认提供两个实现,定义如下:
public interface IAuditUserIdProvider
{
string GetUserId();
}
默认实现:
// 获取 Environment.UserName
public class EnvironmentAuditUserIdProvider : IAuditUserIdProvider
{
private EnvironmentAuditUserIdProvider()
{
}
public static Lazy<EnvironmentAuditUserIdProvider> Instance = new Lazy<EnvironmentAuditUserIdProvider>(() => new EnvironmentAuditUserIdProvider(), true);
public string GetUserId() => Environment.UserName;
}
// 获取 Thread.CurrentPrincipal.Identity.Name
public class ThreadPrincipalUserIdProvider : IAuditUserIdProvider
{
public static Lazy<ThreadPrincipalUserIdProvider> Instance = new Lazy<ThreadPrincipalUserIdProvider>(() => new ThreadPrincipalUserIdProvider(), true);
private ThreadPrincipalUserIdProvider()
{
}
public string GetUserId() => Thread.CurrentPrincipal?.Identity?.Name;
}
当然如果是 asp.net core 你也可以实现相应的基于 HttpContext 实现的 UserIdProvider
Filters
基于我们可能希望忽略一些实体或属性记录,所以有必要增加 Filter 的记录
基于实体的 Filter: Func<EntityEntry, bool>
基于属性的 Filter: Func<EntityEntry, PropertyEntry, bool>
为了使用方便定义了一些扩展方法:
public static IAuditConfigBuilder IgnoreEntity(this IAuditConfigBuilder configBuilder, Type entityType)
{
configBuilder.WithEntityFilter(entityEntry => entityEntry.Entity.GetType() != entityType);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreEntity<TEntity>(this IAuditConfigBuilder configBuilder) where TEntity : class
{
configBuilder.WithEntityFilter(entityEntry => entityEntry.Entity.GetType() != typeof(TEntity));
return configBuilder;
}
public static IAuditConfigBuilder IgnoreTable(this IAuditConfigBuilder configBuilder, string tableName)
{
configBuilder.WithEntityFilter(entityEntry => entityEntry.Metadata.GetTableName() != tableName);
return configBuilder;
}
public static IAuditConfigBuilder WithEntityFilter(this IAuditConfigBuilder configBuilder, Func<EntityEntry, bool> filterFunc)
{
configBuilder.WithEntityFilter(filterFunc);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreProperty<TEntity>(this IAuditConfigBuilder configBuilder, Expression<Func<TEntity, object>> propertyExpression) where TEntity : class
{
var propertyName = propertyExpression.GetMemberName();
configBuilder.WithPropertyFilter(propertyEntry => propertyEntry.Metadata.Name != propertyName);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreProperty(this IAuditConfigBuilder configBuilder, string propertyName)
{
configBuilder.WithPropertyFilter(propertyEntry => propertyEntry.Metadata.Name != propertyName);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreColumn(this IAuditConfigBuilder configBuilder, string columnName)
{
configBuilder.WithPropertyFilter(propertyEntry => propertyEntry.Metadata.GetColumnName() != columnName);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreColumn(this IAuditConfigBuilder configBuilder, string tableName, string columnName)
{
configBuilder.WithPropertyFilter((entityEntry, propertyEntry) => entityEntry.Metadata.GetTableName() != tableName
&& propertyEntry.Metadata.GetColumnName() != columnName);
return configBuilder;
}
public static IAuditConfigBuilder WithPropertyFilter(this IAuditConfigBuilder configBuilder, Func<PropertyEntry, bool> filterFunc)
{
configBuilder.WithPropertyFilter((entity, prop) => filterFunc.Invoke(prop));
return configBuilder;
}
IAuditPropertyEnricher
上面由提到有时候我们希望审计记录能够记录更多的信息,需要提供给用户一些自定义的扩展点,这里的 Enricher 的实现参考了 Serilog 里的做法,我们可以自定义一个 IAuditPropertyEnricher ,来丰富审计的信息,默认提供了 AuditPropertyEnricher,可以支持 key-value 形式的补充信息,实现如下:
public class AuditPropertyEnricher : IAuditPropertyEnricher
{
private readonly string _propertyName;
private readonly Func<AuditEntry, object> _propertyValueFactory;
private readonly bool _overwrite;
private readonly Func<AuditEntry, bool> _auditPropertyPredict = null;
public AuditPropertyEnricher(string propertyName, object propertyValue, bool overwrite = false)
: this(propertyName, (auditEntry) => propertyValue, overwrite)
{
}
public AuditPropertyEnricher(string propertyName, Func<AuditEntry, object> propertyValueFactory, bool overwrite = false)
: this(propertyName, propertyValueFactory, null, overwrite)
{
}
public AuditPropertyEnricher(
string propertyName,
Func<AuditEntry, object> propertyValueFactory,
Func<AuditEntry, bool> auditPropertyPredict,
bool overwrite = false)
{
_propertyName = propertyName;
_propertyValueFactory = propertyValueFactory;
_auditPropertyPredict = auditPropertyPredict;
_overwrite = overwrite;
}
public void Enrich(AuditEntry auditEntry)
{
if (_auditPropertyPredict?.Invoke(auditEntry) != false)
{
auditEntry.WithProperty(_propertyName, _propertyValueFactory, _overwrite);
}
}
}
为了方便使用,提供了一些方便的扩展方法:
public static IAuditConfigBuilder EnrichWithProperty(this IAuditConfigBuilder configBuilder, string propertyName, object value, bool overwrite = false)
{
configBuilder.WithEnricher(new AuditPropertyEnricher(propertyName, value, overwrite));
return configBuilder;
}
public static IAuditConfigBuilder EnrichWithProperty(this IAuditConfigBuilder configBuilder, string propertyName, Func<AuditEntry> valueFactory, bool overwrite = false)
{
configBuilder.WithEnricher(new AuditPropertyEnricher(propertyName, valueFactory, overwrite));
return configBuilder;
}
public static IAuditConfigBuilder EnrichWithProperty(this IAuditConfigBuilder configBuilder, string propertyName, object value, Func<AuditEntry, bool> predict, bool overwrite = false)
{
configBuilder.WithEnricher(new AuditPropertyEnricher(propertyName, e => value, predict, overwrite));
return configBuilder;
}
public static IAuditConfigBuilder EnrichWithProperty(this IAuditConfigBuilder configBuilder, string propertyName, Func<AuditEntry, object> valueFactory, Func<AuditEntry, bool> predict, bool overwrite = false)
{
configBuilder.WithEnricher(new AuditPropertyEnricher(propertyName, valueFactory, predict, overwrite));
return configBuilder;
}
IAuditStore
之前的测试都是基于数据库来的,审计记录也是放在数据库里的,有时候可能不希望和原始数据存在一个数据库里,有时候甚至希望不放在数据库里,为了实现可以自定义的存储,提供了一个 IAuditStore 的接口,提供给用户可以自定义审计信息存储的可能。
public interface IAuditStore
{
Task Save(ICollection<AuditEntry> auditEntries);
}
使用
DbContext 配置
默认提供了一个 AuditDbContextBase 和 AuditDbContext,他们的区别在于 AuditDbContext 会创建一张 AuditRecords 表,记录审计信息,AuditDbContextBase 则不会,只会写配置的存储。
如果希望提供自动审计的功能,新建 DbContext 的时候需要继承 AuditDbContext 或 AuditDbContextBase
审计配置
AuditConfig.Configure(builder =>
{
builder
// 配置操作用户获取方式
.WithUserIdProvider(EnvironmentAuditUserIdProvider.Instance.Value)
//.WithUnModifiedProperty() // 保存未修改的属性,默认只保存发生修改的属性
// 保存更多属性
.EnrichWithProperty("MachineName", Environment.MachineName)
.EnrichWithProperty(nameof(ApplicationHelper.ApplicationName), ApplicationHelper.ApplicationName)
// 保存到自定义的存储
.WithStore<AuditFileStore>()
.WithStore<AuditFileStore>("logs0.txt")
// 忽略指定实体
.IgnoreEntity<AuditRecord>()
// 忽略指定实体的某个属性
.IgnoreProperty<TestEntity>(t => t.CreatedAt)
// 忽略所有属性名称为 CreatedAt 的属性
.IgnoreProperty("CreatedAt")
;
});
如果希望暂时禁用审计可以使用 AuditConfig.DisableAudit() 来禁用,之后恢复可以使用 AuditConfig.EnableAudit()
// disable audit
AuditConfig.DisableAudit();
// enable audit
// AuditConfig.EnableAudit();
More
暂时想到的特性只有这些了,想要更多新特性?欢迎 Issue & PR
项目地址:https://github.com/WeihanLi/WeihanLi.EntityFramework
Reference
- https://www.meziantou.net/entity-framework-core-history-audit-table.htm
- https://github.com/WeihanLi/WeihanLi.EntityFramework
EF Core 数据变更自动审计设计的更多相关文章
- [EF Core]数据迁移(二)
摘要 在实际项目中,大多都需要对业务逻辑以及操作数据库的逻辑进行分成操作,这个时候该如何进行数据的迁移呢? 步骤 上篇文章:EF Core数据迁移操作 比如,我们将数据上下文放在了Data层. 看一下 ...
- ef core数据迁移的一点小感悟
ef core在针对mysql数据迁移的时候,有些时候没法迁移...有两种情况没法迁移,一种是因为efcore的bug问题导致没法迁移,这个在github上有个问题集,另外一种是对数据表进行较大幅度的 ...
- EF Core CodeFirst数据库自动迁移
开发过程中都会遇到数据库数据结构更新的问题,怎么对数据库更新进行版本控制呢? 不同的项目对数据库版本更新控制的方式不同,常用的有第三方Evolve,开发人员将数据库更新脚本按照版本号的放在一起,然后执 ...
- EF Core 数据过滤
1 前言 本文致力于将一种动态数据过滤的方案描述出来(基于 EF Core 官方的数据筛选器),实现自动注册,多个条件过滤,单条件禁用(实际上是参考ABP的源码),并尽量让代码保持 EF Core 的 ...
- EF Core数据访问入门
重要概念 Entity Framework (EF) Core 是轻量化.可扩展.开源和跨平台的数据访问技术,它还是一 种对象关系映射器 (ORM),它使 .NET 开发人员能够使用面向对象的思想处理 ...
- EF Core数据迁移操作
摘要 在开发中,使用EF code first方式开发,那么如果涉及到数据表的变更,该如何做呢?当然如果是新项目,删除数据库,然后重新生成就行了,那么如果是线上的项目,数据库中已经有数据了,那么删除数 ...
- Asp.net core下利用EF core实现从数据实现多租户(3): 按Schema分离 附加:EF Migration 操作
前言 前段时间写了EF core实现多租户的文章,实现了根据数据库,数据表进行多租户数据隔离. 今天开始写按照Schema分离的文章. 其实还有一种,是通过在数据表内添加一个字段做多租户的,但是这种模 ...
- .NET 云原生架构师训练营(模块二 基础巩固 EF Core 更新和迁移)--学习笔记
2.4.6 EF Core -- 更新 状态 自动变更检测 不查询删除和更新 并发 状态 Entity State Property State Entity State Added 添加 Uncha ...
- WithOne 实体关系引起 EF Core 自动删除数据
最近遇到了一个 EF Core 的恐怖问题,在添加数据时竟然会自动删除数据库中已存在的数据,经过追查发现是一个多余的实体关系配置引起的. modelBuilder.Entity<Question ...
随机推荐
- 一篇带你看懂Flutter叠加组件Stack
注意:无特殊说明,Flutter版本及Dart版本如下: Flutter版本: 1.12.13+hotfix.5 Dart版本: 2.7.0 Stack Stack组件可以将子组件叠加显示,根据子组件 ...
- 差分放大电路的CMRR与输入电阻分析
分析了经典差分放大电路的共模抑制比CMRR与输入电阻RIN 1.经典差分放大电路 基于运放的经典差分放大电路在各模电教材中均能找到,利用分离电阻和运算放大器实现,如图1所示为一种差分放大电路: 图1 ...
- 网络|Trojan 网络代理服务搭建
Trojan 网络代理服务搭建 前言 本文目的在于帮助相同困惑的网友,让使用更加简单. Trojan为Trojan-GFW开源的一款新思路网络代理软件, 前期准备 [x] 服务器:系统CentOS 7 ...
- js的变量——基本类型保存在栈中,引用类型保存在堆中
javascript的基本类型:Undefined,Null,Boolean,Number,String 引用类型:Object,Array,Function 基本类型值在内存中占据固定大小,被保存在 ...
- 用vue-cli进行npm run dev时候Cannot GET/
在用vue cli进行项目npm run dev 时候,页面Cannot GET/ 主要是把config/index.js里面的dev:{assetsPublicPath:'/'}改成了跟build里 ...
- Redis系列三 - 缓存雪崩、击穿、穿透
前言 从学校出来,做开发工作也有一定时间了,最近有想系统地进一步深入学习,但发现基础知识不够扎实,故此来回顾基础知识,进一步巩固.加深印象. 最初开始接触编程时,总是自己跌跌撞撞.不断摸索地去学习,再 ...
- TEA5676 + AT24C08 FM收音机 搜台 存台 mmap 实现读写
硬件说明TEA5767 + AT24c08 要使用耳机收听,不加功放芯片,声音非常小. 这2个芯片都支持 3.3 或 5.0 电源支持连线比较简单,sda scl 接到 2440 对应的 排针上,找出 ...
- golang 交叉编译 win开发 linux生产
windows平台之下使用 go env 能看到go本身的配置的环境变量,其中红框框起来的变量是交叉编译需要改动的选项, 由于是win平台开发,但是跑起来的程序都是在linux,所以linux转win ...
- CSS 权重图
关系图 图片出处我找不到了. 结论 权重从高到低排序 1. !important 2. style 3. #id 4. .class .child-class 5. .class1.class2 6. ...
- django中ORM中锁和事务
一 锁 行级锁 select_for_update(nowait=False, skip_locked=False) #注意必须用在事务里面,至于如何开启事务,我们看下面的事务一节. 返回一个锁住行直 ...