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 ...
随机推荐
- 解决WebMvcConfigurer下的addViewControllers无法找到制定页面
解决WebMvcConfigurer下的addViewControllers无法找到制定页面 这种都已经配置了拦截跳转,但无效的原因是,没有加载thymeleaf依赖 <dependency&g ...
- handlebar.js模板引擎(轻页面小工程可用)
介绍 Handlebars 让你能够有能力高效地容易地创立语义化的模版.Handlebars兼容Mustache语法,在大多数情况下它可以读取Mustache的语法并在你当前模板中使用.具体点击这里 ...
- Error response:/usr/bin/tf_serving_entrypoint.sh: line 3: 6 Illegal instruction (core dumped) ...
用docker部署tensorflow-serving:gpu时,参照官方文档:https://tensorflow.google.cn/tfx/serving/docker 本应该是很简单的部署,没 ...
- idea 2018.1激活方法
之前用的idea都是2017版本的,现在已经四月份了,对于2018年1月份的版本应该可以放心的用了. 在这里,仅提供2018版本的激活码. 至于安装步骤,这里省略一千个字...... 下面是具体的激活 ...
- Flutter 拖拽控件Draggable看这一篇就够了
注意:无特殊说明,Flutter版本及Dart版本如下: Flutter版本: 1.12.13+hotfix.5 Dart版本: 2.7.0 Draggable系列组件可以让我们拖动组件. Dragg ...
- java算法--循环队列
循环队列 我们再用队列得时候不知道发没发现这样一个问题. 这是一个只有三个位置得队列,在进行三次加入(addqueue)操作和三次取出(get)操作之后再进行加入操作时候的样子.明显可以看到,队列已经 ...
- 利用border-radius画椭圆
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title> ...
- java输入输出流操作同一资源实现覆盖引发冲突的解析
一.问题发生的场景 题目:把s.txt中大写转成小写,小写转成大写,空格转成下划线,在输入到文件中覆盖之前的 前面的没有问题,问题出现在后面的覆盖文件上,输入流.输出流要操作同一个文件的问题 二.小白 ...
- Css盒模型属性详解(margin和padding)
Css盒模型属性详解(margin和padding) 大家好,我是逆战班的一名学员,今天我来给大家分享一下关于盒模型的知识! 关于盒模型的属性详解及用法 盒模型基本属性有两个:padding和marg ...
- JavaScript隐式类型转换(详解 +,-,*,/,==)
JavaScript 在 运算 或 比较 之前, 会自动进行隐式类型转换. 下面我们来仔细讲一讲 + - * / == 运算符经历了哪些过程. 类型转换 ECMAScript 运行时系统会在需要时从事 ...