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,重写 SaveChangesSaveChangesAsync 方法,增加

BeforeSaveChangesAfterSaveChanges 方法,用于处理我们要自定义的保存之前和保存之后的逻辑。

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 配置

默认提供了一个 AuditDbContextBaseAuditDbContext,他们的区别在于 AuditDbContext 会创建一张 AuditRecords 表,记录审计信息,AuditDbContextBase 则不会,只会写配置的存储。

如果希望提供自动审计的功能,新建 DbContext 的时候需要继承 AuditDbContextAuditDbContextBase

审计配置

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

EF Core 数据变更自动审计设计的更多相关文章

  1. [EF Core]数据迁移(二)

    摘要 在实际项目中,大多都需要对业务逻辑以及操作数据库的逻辑进行分成操作,这个时候该如何进行数据的迁移呢? 步骤 上篇文章:EF Core数据迁移操作 比如,我们将数据上下文放在了Data层. 看一下 ...

  2. ef core数据迁移的一点小感悟

    ef core在针对mysql数据迁移的时候,有些时候没法迁移...有两种情况没法迁移,一种是因为efcore的bug问题导致没法迁移,这个在github上有个问题集,另外一种是对数据表进行较大幅度的 ...

  3. EF Core CodeFirst数据库自动迁移

    开发过程中都会遇到数据库数据结构更新的问题,怎么对数据库更新进行版本控制呢? 不同的项目对数据库版本更新控制的方式不同,常用的有第三方Evolve,开发人员将数据库更新脚本按照版本号的放在一起,然后执 ...

  4. EF Core 数据过滤

    1 前言 本文致力于将一种动态数据过滤的方案描述出来(基于 EF Core 官方的数据筛选器),实现自动注册,多个条件过滤,单条件禁用(实际上是参考ABP的源码),并尽量让代码保持 EF Core 的 ...

  5. EF Core数据访问入门

    重要概念 Entity Framework (EF) Core 是轻量化.可扩展.开源和跨平台的数据访问技术,它还是一 种对象关系映射器 (ORM),它使 .NET 开发人员能够使用面向对象的思想处理 ...

  6. EF Core数据迁移操作

    摘要 在开发中,使用EF code first方式开发,那么如果涉及到数据表的变更,该如何做呢?当然如果是新项目,删除数据库,然后重新生成就行了,那么如果是线上的项目,数据库中已经有数据了,那么删除数 ...

  7. Asp.net core下利用EF core实现从数据实现多租户(3): 按Schema分离 附加:EF Migration 操作

    前言 前段时间写了EF core实现多租户的文章,实现了根据数据库,数据表进行多租户数据隔离. 今天开始写按照Schema分离的文章. 其实还有一种,是通过在数据表内添加一个字段做多租户的,但是这种模 ...

  8. .NET 云原生架构师训练营(模块二 基础巩固 EF Core 更新和迁移)--学习笔记

    2.4.6 EF Core -- 更新 状态 自动变更检测 不查询删除和更新 并发 状态 Entity State Property State Entity State Added 添加 Uncha ...

  9. WithOne 实体关系引起 EF Core 自动删除数据

    最近遇到了一个 EF Core 的恐怖问题,在添加数据时竟然会自动删除数据库中已存在的数据,经过追查发现是一个多余的实体关系配置引起的. modelBuilder.Entity<Question ...

随机推荐

  1. 随手撸一个简单的带检查的printf

    #include <stdio.h> #include <iostream> #include <vector> #include <string> # ...

  2. JZOJ 1349. 最大公约数 (Standard IO)

    1349. 最大公约数 (Standard IO) Time Limits: 1000 ms Memory Limits: 65536 KB Description 小菜的妹妹小诗就要读小学了!正所谓 ...

  3. 使用VS2017进行Python代码的编写并打印出九九乘法表

    我们来盘一盘怎么使用VS2017进行python代码的编写并打印出九九乘法表. 使用Visual Studio 2017进行Python编程不需要太复杂的工作,只需要vs2017安装好对Python的 ...

  4. Vue2.0 【第二季】第2节 Vue.extend构造器的延伸

    目录 Vue2.0 [第二季]第2节 Vue.extend构造器的延伸 一.什么是Vue.extend 二.自定义无参数标签 三.挂载到普通标签上 Vue2.0 [第二季]第2节 Vue.extend ...

  5. 问题描述:判断一个整数 n 是否为 2 的幂次方

    一.2的幂次方的基本定义 什么样的数为2的幂次方?例如2^0=1,2^1=2,2^2=4……,符合公式2^n(n>=0)的数称为2的幂次方. 如何判断一个数是否为2的幂次方呢?基本思路:把一个数 ...

  6. Echart饼形图和折线图的循环展示及选择展示

    需求:根据不同的入参调同一接口,循环展示一组饼形图或折线图: 主要问题:在于给定的数据格式不符合图表的配置项格式,需要拆分组装数据:首先默认展示几个图表,当选中一个类别,需要展示其中一个的时候,页面中 ...

  7. 对象数组化 Object.values(this.totalValueObj).forEach(value => {

    对象数组化 Object.values(this.totalValueObj).forEach(value => {

  8. layui的弹出层的title的自定义html

       layui的弹出层的title的自定义html //在这里面输入任何合法的js语句 layer.open({ type: 1 //Page层类型 ,area: ['500px', '300px' ...

  9. PDF顯示插件

    1. ie瀏覽器適用<object id="pdf_panel" class="pdf-panel" classid="clsid:CA8A97 ...

  10. Natas12 Writeup(文件上传漏洞)

    Natas12: 文件上传页面,源码如下: function genRandomString() { $length = 10; $characters = "0123456789abcde ...