EF Core 資料變更自動審計設計
阿新 • • 發佈:2020-04-05
# EF Core 資料變更自動審計設計
## Intro
有的時候我們需要知道每個資料表的變更記錄以便做一些資料審計,資料恢復以及資料同步等之類的事情,
EF 自帶了物件追蹤,使得我們可以很方便的做一些審計工作,每次變更發生了什麼變化都變得很清晰,於是就基於 EF 封裝了一層資料變更自動審計
## 使用效果
測試程式碼:
``` csharp
private static void AutoAuditTest()
{
// 審計配置
AuditConfig.Configure(builder =>
{
builder
// 配置操作使用者獲取方式
.WithUserIdProvider(EnvironmentAuditUserIdProvider.Instance.Value)
//.WithUnModifiedProperty() // 儲存未修改的屬性,預設只儲存發生修改的屬性
// 儲存更多屬性
.EnrichWithProperty("MachineName", Environment.MachineName)
.EnrichWithProperty(nameof(ApplicationHelper.ApplicationName), ApplicationHelper.ApplicationName)
// 儲存到自定義的儲存
.WithStore()
.WithStore("logs.log")
// 忽略指定實體
.IgnoreEntity()
// 忽略指定實體的某個屬性
.IgnoreProperty(t => t.CreatedAt)
// 忽略所有屬性名稱為 CreatedAt 的屬性
.IgnoreProperty("CreatedAt")
;
});
DependencyResolver.TryInvokeService(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(dbContext =>
{
dbContext.Remove(new TestEntity()
{
Id = 2
});
dbContext.SaveChanges();
});
// disable audit
AuditConfig.DisableAudit();
}
```
檢視審計記錄資訊:
![audit records](https://img2020.cnblogs.com/blog/489462/202004/489462-20200405150154870-3367218.png)
可以看到,每次資料變更都會被記錄下來,`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` 方法,用於處理我們要自定義的儲存之前和儲存之後的邏輯。
``` csharp
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 SaveChangesAsync(CancellationToken cancellationToken = default)
{
await BeforeSaveChanges();
var result = await base.SaveChangesAsync(cancellationToken);
await AfterSaveChanges();
return result;
}
```
接著來實現一個用來自動審計的 `AuditDbContextBase`,核心程式碼如下:
``` csharp
public abstract class AuditDbContextBase : DbContextBase
{
protected AuditDbContextBase()
{
}
protected AuditDbContextBase(DbContextOptions dbContextOptions) : base(dbContextOptions)
{
}
protected List AuditEntries { get; set; }
protected override Task BeforeSaveChanges()
{
AuditEntries = new List();
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` 來實現操作使用者資訊的獲取,預設提供兩個實現,定義如下:
``` csharp
public interface IAuditUserIdProvider
{
string GetUserId();
}
```
預設實現:
``` csharp
// 獲取 Environment.UserName
public class EnvironmentAuditUserIdProvider : IAuditUserIdProvider
{
private EnvironmentAuditUserIdProvider()
{
}
public static Lazy Instance = new Lazy(() => new EnvironmentAuditUserIdProvider(), true);
public string GetUserId() => Environment.UserName;
}
// 獲取 Thread.CurrentPrincipal.Identity.Name
public class ThreadPrincipalUserIdProvider : IAuditUserIdProvider
{
public static Lazy Instance = new Lazy(() => new ThreadPrincipalUserIdProvider(), true);
private ThreadPrincipalUserIdProvider()
{
}
public string GetUserId() => Thread.CurrentPrincipal?.Identity?.Name;
}
```
當然如果是 asp.net core 你也可以實現相應的基於 `HttpContext` 實現的 `UserIdProvider`
### Filters
基於我們可能希望忽略一些實體或屬性記錄,所以有必要增加 Filter 的記錄
基於實體的 Filter: `Func`
基於屬性的 Filter: `Func`
為了使用方便定義了一些擴充套件方法:
``` csharp
public static IAuditConfigBuilder IgnoreEntity(this IAuditConfigBuilder configBuilder, Type entityType)
{
configBuilder.WithEntityFilter(entityEntry => entityEntry.Entity.GetType() != entityType);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreEntity(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 filterFunc)
{
configBuilder.WithEntityFilter(filterFunc);
return configBuilder;
}
public static IAuditConfigBuilder IgnoreProperty(this IAuditConfigBuilder configBuilder, Expression> 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 filterFunc)
{
configBuilder.WithPropertyFilter((entity, prop) => filterFunc.Invoke(prop));
return configBuilder;
}
```
### IAuditPropertyEnricher
上面由提到有時候我們希望審計記錄能夠記錄更多的資訊,需要提供給使用者一些自定義的擴充套件點,這裡的 `Enricher` 的實現參考了 Serilog 裡的做法,我們可以自定義一個 `IAuditPropertyEnricher` ,來豐富審計的資訊,預設提供了 `AuditPropertyEnricher`,可以支援 key-value 形式的補充資訊,實現如下:
``` csharp
public class AuditPropertyEnricher : IAuditPropertyEnricher
{
private readonly string _propertyName;
private readonly Func _propertyValueFactory;
private readonly bool _overwrite;
private readonly Func _auditPropertyPredict = null;
public AuditPropertyEnricher(string propertyName, object propertyValue, bool overwrite = false)
: this(propertyName, (auditEntry) => propertyValue, overwrite)
{
}
public AuditPropertyEnricher(string propertyName, Func propertyValueFactory, bool overwrite = false)
: this(propertyName, propertyValueFactory, null, overwrite)
{
}
public AuditPropertyEnricher(
string propertyName,
Func propertyValueFactory,
Func 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);
}
}
}
```
為了方便使用,提供了一些方便的擴充套件方法:
``` csharp
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 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 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 valueFactory, Func predict, bool overwrite = false)
{
configBuilder.WithEnricher(new AuditPropertyEnricher(propertyName, valueFactory, predict, overwrite));
return configBuilder;
}
```
### IAuditStore
之前的測試都是基於資料庫來的,審計記錄也是放在資料庫裡的,有時候可能不希望和原始資料存在一個數據庫裡,有時候甚至希望不放在資料庫裡,為了實現可以自定義的儲存,提供了一個 `IAuditStore` 的介面,提供給使用者可以自定義審計資訊儲存的可能。
``` csharp
public interface IAuditStore
{
Task Save(ICollection auditEntries);
}
```
## 使用
### `DbContext` 配置
預設提供了一個 `AuditDbContextBase` 和 `AuditDbContext`,他們的區別在於 `AuditDbContext` 會建立一張 `AuditRecords` 表,記錄審計資訊,`AuditDbContextBase` 則不會,只會寫配置的儲存。
如果希望提供自動審計的功能,新建 `DbContext` 的時候需要繼承 `AuditDbContext` 或 `AuditDbContextBase`
### 審計配置
``` csharp
AuditConfig.Configure(builder =>
{
builder
// 配置操作使用者獲取方式
.WithUserIdProvider(EnvironmentAuditUserIdProvider.Instance.Value)
//.WithUnModifiedProperty() // 儲存未修改的屬性,預設只儲存發生修改的屬性
// 儲存更多屬性
.EnrichWithProperty("MachineName", Environment.MachineName)
.EnrichWithProperty(nameof(ApplicationHelper.ApplicationName), ApplicationHelper.ApplicationName)
// 儲存到自定義的儲存
.WithStore()
.WithStore("logs0.txt")
// 忽略指定實體
.IgnoreEntity()
// 忽略指定實體的某個屬性
.IgnoreProperty(t => t.CreatedAt)
// 忽略所有屬性名稱為 CreatedAt 的屬性
.IgnoreProperty("CreatedAt")
;
});
```
如果希望暫時禁用審計可以使用 `AuditConfig.DisableAudit()` 來禁用,之後恢復可以使用 `AuditConfig.EnableAudit()`
``` csharp
// disable audit
AuditConfig.DisableAudit();
// enable audit
// AuditConfig.EnableAudit();
```
## More
暫時想到的特性只有這些了,想要更多新特性?歡迎 Issue & PR
專案地址:
## Reference
-
-