前言 資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些S ...
前言
資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些SQL的複雜度是一個很有價值的問題。而且這個問題同時涉及應用軟體和資料庫兩個相對獨立的體系,平行共管也是產生混亂的一大因素。
EF Core作為 .NET平臺的高級ORM框架,可以托管和資料庫的交互,同時提供了大量擴展點方便自定義。以此為基點把對資料庫的操作托管後便可以解決平行共管所產生的混亂,利用LINQ則可以最大程度上降低軟體代碼的維護難度。
由於項目需要,筆者先後開發併發布了通用的基於EF Core存儲的國際化服務和基於EF Core存儲的Serilog持久化服務,不過這兩個功能包並沒有深度利用EF Core,雖然主要是因為沒什麼必要。但是項目還需要提供常用的數據審計和軟刪除功能,因此對EF Core進行了一些更深入的研究。
起初有考慮過是否使用現成的ABP框架來處理這些功能,但是在其他項目的使用體驗來說並不算好,其中充斥著大量上下文依賴的功能,而且這些依賴信息能輕易藏到和最終業務代碼相距十萬八千里的地方(特別是代碼還是別人寫的時候),然後在不經意間給你一個大驚喜。對於以代碼正交性、非誤導性,純函數化為追求的一介碼農(看過我發佈的那兩個功能包的朋友應該有感覺,一個功能筆者也要根據用途劃分為不同的包,確保解決方案中的各個項目都能按需引用,不會殘留無用的代碼),實在是喜歡不起來ABP這種全家桶。
鑒於項目規模不大,筆者決定針對這些需求做一個專用功能,目標是儘可能減少依賴,方便將來複用到其他項目,降低和其他功能功能衝突的風險。現在筆者將用一系列博客做成果展示。由於這些功能沒有經過大範圍測試,不確定是否存在未知缺陷,因此暫不打包發佈。
新書宣傳
有關新書的更多介紹歡迎查看《C#與.NET6 開發從入門到實踐》上市,作者親自來打廣告了!
正文
由於這些功能設計的代碼量和知識點較多,為控制篇幅,本文介紹數據審計和樂觀併發功能。
EF Core 3.0新增了偵聽器功能,允許在實際執行操作之前或之後插入自定義操作,利用這個功能可以實現數據審計的自動化。為此需要做些前期準備。
審計實體介面
樂觀併發介面
/// <summary>
/// 樂觀併發介面
/// </summary>
public interface IOptimisticConcurrencySupported
{
/// <summary>
/// 行版本,樂觀併發鎖
/// </summary>
[ConcurrencyCheck]
string? ConcurrencyStamp { get; set; }
}
SqlServer資料庫支持自動的行版本功能,但是大多數其他資料庫並不支持,因此選用相容性更好的方案。Identity Core為了相容性也不用行版本實現樂觀併發。
時間審計介面
/// <summary>
/// 創建和最近更新時間審計的合成介面
/// </summary>
public interface IFullyTimeAuditable : ICreationTimeAuditable, ILastUpdateTimeAuditable;
/// <summary>
/// 創建時間審計介面
/// </summary>
public interface ICreationTimeAuditable
{
/// <summary>
/// 創建時間標記
/// </summary>
DateTimeOffset? CreatedAt { get; set; }
}
/// <summary>
/// 最近更新時間審計介面
/// </summary>
public interface ILastUpdateTimeAuditable
{
/// <summary>
/// 最近更新時間標記
/// </summary>
DateTimeOffset? LastUpdatedAt { get; set; }
}
操作人審計介面
/// <summary>
/// 創建和最近更新用戶審計的合成介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface IFullyOperatorAuditable<TIdentityKey>
: ICreationUserAuditable<TIdentityKey>
, ILastUpdateUserAuditable<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>;
/// <summary>
/// 包括導航的創建和最近更新用戶審計的合成介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <typeparam name="TUser">用戶類型</typeparam>
public interface IFullyOperatorAuditable<TIdentityKey, TUser>
: ICreationUserAuditable<TIdentityKey, TUser>
, ILastUpdateUserAuditable<TIdentityKey, TUser>
where TIdentityKey : struct, IEquatable<TIdentityKey>
where TUser : class;
/// <summary>
/// 創建用戶審計介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface ICreationUserAuditable<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
/// <summary>
/// 創建用戶Id
/// </summary>
TIdentityKey? CreatedById { get; set; }
}
/// <summary>
/// 包括導航的創建用戶審計介面
/// </summary>
/// <typeparam name="TUser">用戶類型</typeparam>
/// <inheritdoc />
public interface ICreationUserAuditable<TIdentityKey, TUser> : ICreationUserAuditable<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>
where TUser : class
{
/// <summary>
/// 創建用戶
/// </summary>
TUser? CreatedBy { get; set; }
}
/// <summary>
/// 最近更新用戶審計介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface ILastUpdateUserAuditable<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
/// <summary>
/// 最近更新用戶Id
/// </summary>
TIdentityKey? LastUpdatedById { get; set; }
}
/// <summary>
/// 包括導航的最近更新用戶審計介面
/// </summary>
/// <typeparam name="TUser">用戶類型</typeparam>
/// <inheritdoc />
public interface ILastUpdateUserAuditable<TIdentityKey, TUser> : ILastUpdateUserAuditable<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>
where TUser : class
{
/// <summary>
/// 最近更新用戶
/// </summary>
TUser? LastUpdatedBy { get; set; }
}
使用介面方便和已有代碼集成。帶導航的操作人介面使用結構體Id方便準確控制外鍵可空性。
需要的輔助方法
public static class RuntimeTypeExtensions
{
/// <summary>
/// 判斷 <paramref name="type"/> 指定的類型是否派生自 <typeparamref name="T"/> 類型,或實現了 <typeparamref name="T"/> 介面
/// </summary>
/// <typeparam name="T">要匹配的類型</typeparam>
/// <param name="type">需要測試的類型</param>
/// <returns>如果 <paramref name="type"/> 指定的類型派生自 <typeparamref name="T"/> 類型,或實現了 <typeparamref name="T"/> 介面,則返回 <see langword="true"/>,否則返回 <see langword="false"/>。</returns>
public static bool IsDerivedFrom<T>(this Type type)
{
return IsDerivedFrom(type, typeof(T));
}
/// <summary>
/// 判斷 <paramref name="type"/> 指定的類型是否繼承自 <paramref name="pattern"/> 指定的類型,或實現了 <paramref name="pattern"/> 指定的介面
/// <para>支持開放式泛型,如<see cref="List{T}" /></para>
/// </summary>
/// <param name="type">需要測試的類型</param>
/// <param name="pattern">要匹配的類型,如 <c>typeof(int)</c>,<c>typeof(IEnumerable)</c>,<c>typeof(List<>)</c>,<c>typeof(List<int>)</c>,<c>typeof(IDictionary<,>)</c></param>
/// <returns>如果 <paramref name="type"/> 指定的類型繼承自 <paramref name="pattern"/> 指定的類型,或實現了 <paramref name="pattern"/> 指定的介面,則返回 <see langword="true"/>,否則返回 <see langword="false"/>。</returns>
public static bool IsDerivedFrom(this Type type, Type pattern)
{
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(pattern);
// 測試非泛型類型(如ArrayList)或確定類型參數的泛型類型(如List<int>,類型參數T已經確定為 int)
if (type.IsSubclassOf(pattern)) return true;
// 測試非泛型介面(如IEnumerable)或確定類型參數的泛型介面(如IEnumerable<int>,類型參數T已經確定為 int)
if (pattern.IsAssignableFrom(type)) return true;
// 測試泛型介面(如IEnumerable<>,IDictionary<,>,未知類型參數,留空)
var isTheRawGenericType = type.GetInterfaces().Any(IsTheRawGenericType);
if (isTheRawGenericType) return true;
// 測試泛型類型(如List<>,Dictionary<,>,未知類型參數,留空)
while (type != null && type != typeof(object))
{
isTheRawGenericType = IsTheRawGenericType(type);
if (isTheRawGenericType) return true;
type = type.BaseType!;
}
// 沒有找到任何匹配的介面或類型。
return false;
// 測試某個類型是否是指定的原始介面。
bool IsTheRawGenericType(Type test)
=> pattern == (test.IsGenericType ? test.GetGenericTypeDefinition() : test);
}
}
/// <summary>
/// 實體配置相關泛型方法生成擴展
/// </summary>
internal static class EntityConfigurationMethodsHelper
{
private const BindingFlags _bindingFlags = BindingFlags.Public | BindingFlags.Static;
private static readonly ImmutableArray<MethodInfo> _configurationMethods;
private static readonly MethodInfo _genericEntityTypeBuilderGetterMethod;
static EntityConfigurationMethodsHelper()
{
_configurationMethods =
[
.. typeof(EntityModelBuilderExtensions).GetMethods(_bindingFlags),
.. typeof(OperationUserAuditableEntityModelBuilderExtensions).GetMethods(_bindingFlags),
.. typeof(TimeAuditableEntityModelBuilderExtensions).GetMethods(_bindingFlags),
.. typeof(TreeEntityModelBuilderExtensions).GetMethods(_bindingFlags),
];
_genericEntityTypeBuilderGetterMethod = typeof(ModelBuilder)
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(static m => m.Name is nameof(ModelBuilder.Entity))
.Where(static m => m.IsGenericMethod)
.Where(static m => m.GetParameters().Length is 0)
.Single();
}
/// <summary>
/// 獲取泛型實體類型配置擴展方法
/// </summary>
/// <param name="name">方法名</param>
/// <param name="ParametersCount">參數數量</param>
/// <returns>已生成的封閉式泛型配置擴展方法</returns>
internal static MethodInfo GetEntityTypeConfigurationMethod(string name, int ParametersCount, params Type[] typeParameterTypes)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(typeParameterTypes);
return _configurationMethods
.Where(m => m.Name == name)
.Where(m => m.GetParameters().Length == ParametersCount)
.Where(static m => m.IsGenericMethod)
.Where(m => m.GetGenericArguments().Length == typeParameterTypes.Length)
.Single()
.MakeGenericMethod(typeParameterTypes);
}
/// <summary>
/// 獲取泛型實體類型構造器
/// </summary>
/// <param name="entity">實體類型</param>
/// <returns></returns>
internal static MethodInfo GetEntityTypeBuilderMethod(IMutableEntityType entity)
{
ArgumentNullException.ThrowIfNull(entity);
// 動態生成泛型方法使配置邏輯擁有唯一的定義位置,避免發生不必要的問題
return _genericEntityTypeBuilderGetterMethod.MakeGenericMethod(entity.ClrType);
}
}
/// <summary>
/// 指示實體配置適用於何種資料庫提供程式
/// </summary>
/// <param name="ProviderName"></param>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class DatabaseProviderAttribute(string ProviderName) : Attribute
{
/// <summary>
/// 提供程式名稱
/// </summary>
public string ProviderName { get; } = ProviderName;
}
把實體配置擴展方法緩存起來方便之後批量調用,因為EF Core的泛型和非泛型實體構造器無法直接轉換,只能通過反射動態生成泛型方法復用單體配置擴展。這樣能保證配置代碼只有唯一一份,避免重覆代碼導致維護時出現疏漏。
實體模型配置擴展
樂觀併發擴展
/// <summary>
/// 配置樂觀併發實體的併發檢查欄位
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <returns>實體屬性構造器</returns>
public static PropertyBuilder<string> ConfigureForIOptimisticConcurrencySupported<TEntity>(
this EntityTypeBuilder<TEntity> builder)
where TEntity : class, IOptimisticConcurrencySupported
{
ArgumentNullException.ThrowIfNull(builder);
return builder.Property(e => e.ConcurrencyStamp!).IsConcurrencyToken();
}
/// <summary>
/// 批量配置樂觀併發實體的併發檢查欄位
/// </summary>
/// <param name="modelBuilder">模型構造器</param>
/// <returns>模型構造器</returns>
public static ModelBuilder ConfigureForIOptimisticConcurrencySupported(this ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<IOptimisticConcurrencySupported>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
var optimisticConcurrencySupportedMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForIOptimisticConcurrencySupported),
1,
entity.ClrType);
optimisticConcurrencySupportedMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
}
return modelBuilder;
}
時間審計擴展
/// <summary>
/// 實體時間審計配置擴展
/// </summary>
public static class TimeAuditableEntityModelBuilderExtensions
{
/// <summary>
/// 配置創建時間審計
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <param name="defaultValueSql">預設值Sql</param>
/// <returns>實體類型構造器</returns>
public static EntityTypeBuilder<TEntity> ConfigureForCreationTimeAuditable<TEntity>(
this EntityTypeBuilder<TEntity> builder,
ITimeAuditableDefaultValueSql defaultValueSql)
where TEntity : class, ICreationTimeAuditable
{
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql(defaultValueSql.Sql);
return builder;
}
/// <summary>
/// 批量配置創建時間審計
/// </summary>
/// <param name="modelBuilder">模型構造器</param>
/// <returns>模型構造器</returns>
public static ModelBuilder ConfigureForCreationTimeAuditable(
this ModelBuilder modelBuilder,
ITimeAuditableDefaultValueSql defaultValueSql)
{
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<ICreationTimeAuditable>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
var creationTimeAuditableMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForCreationTimeAuditable),
2,
entity.ClrType);
creationTimeAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), defaultValueSql]);
}
return modelBuilder;
}
/// <summary>
/// 配置最近更新時間審計
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <param name="defaultValueSql">預設值Sql</param>
/// <returns>實體類型構造器</returns>
public static EntityTypeBuilder<TEntity> ConfigureForLastUpdateTimeAuditable<TEntity>(
this EntityTypeBuilder<TEntity> builder,
ITimeAuditableDefaultValueSql defaultValueSql)
where TEntity : class, ILastUpdateTimeAuditable
{
builder.Property(e => e.LastUpdatedAt)
.IsRequired()
.HasDefaultValueSql(defaultValueSql.Sql);
return builder;
}
/// <summary>
/// 批量配置最近更新時間審計
/// </summary>
/// <param name="modelBuilder">模型構造器</param>
/// <returns>模型構造器</returns>
public static ModelBuilder ConfigureForLastUpdateTimeAuditable(
this ModelBuilder modelBuilder,
ITimeAuditableDefaultValueSql defaultValueSql)
{
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateTimeAuditable>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
var lastUpdateTimeAuditableMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForLastUpdateTimeAuditable),
2,
entity.ClrType);
lastUpdateTimeAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), defaultValueSql]);
}
return modelBuilder;
}
/// <summary>
/// 配置完整時間審計
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <param name="defaultValueSql">預設值Sql</param>
/// <returns>實體類型構造器</returns>
public static EntityTypeBuilder<TEntity> ConfigureForFullyTimeAuditable<TEntity>(
this EntityTypeBuilder<TEntity> builder,
ITimeAuditableDefaultValueSql defaultValueSql)
where TEntity : class, IFullyTimeAuditable
{
builder
.ConfigureForCreationTimeAuditable(defaultValueSql)
.ConfigureForLastUpdateTimeAuditable(defaultValueSql);
return builder;
}
/// <summary>
/// 批量配置時間審計
/// </summary>
/// <param name="modelBuilder">模型構造器</param>
/// <returns>模型構造器</returns>
public static ModelBuilder ConfigureForTimeAuditable(
this ModelBuilder modelBuilder,
ITimeAuditableDefaultValueSql defaultValueSql)
{
modelBuilder
.ConfigureForCreationTimeAuditable(defaultValueSql)
.ConfigureForLastUpdateTimeAuditable(defaultValueSql);
return modelBuilder;
}
}
時間審計使用預設值SQL儘可能使資料庫和代碼統一邏輯,即使直接向資料庫插入記錄也能儘量保證有相關審計數據。只是最近更新時間在更新時實在是做不到資料庫級別的自動,用觸發器會阻止手動操作數據,所以不用。
時間列的預設值SQL在不同資料庫下有差異,因此需要從外部傳入,方便根據資料庫類型切換。
/// <summary>
/// 實體時間審計預設值Sql
/// </summary>
public interface ITimeAuditableDefaultValueSql
{
string Sql { get; }
}
public class DefaultSqlServerTimeAuditableDefaultValueSql : ITimeAuditableDefaultValueSql
{
public static DefaultSqlServerTimeAuditableDefaultValueSql Instance => new();
public string Sql => "GETDATE()";
private DefaultSqlServerTimeAuditableDefaultValueSql() { }
}
public class DefaultMySqlTimeAuditableDefaultValueSql : ITimeAuditableDefaultValueSql
{
public static DefaultMySqlTimeAuditableDefaultValueSql Instance => new();
public string Sql => "CURRENT_TIMESTAMP(6)";
private DefaultMySqlTimeAuditableDefaultValueSql() { }
}
操作人審計擴展
/// <summary>
/// 實體操作人審計配置擴展
/// </summary>
public static class OperationUserAuditableEntityModelBuilderExtensions
{
/// <summary>
/// 配置實體創建人外鍵和導航屬性
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <typeparam name="TUser">用戶實體類型</typeparam>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <returns>實體類型構造器</returns>
public static EntityTypeBuilder<TEntity> ConfigureForNavigationIncludedCreationUserAuditable<TEntity, TUser, TIdentityKey>(
this EntityTypeBuilder<TEntity> builder)
where TEntity : class, ICreationUserAuditable<TIdentityKey, TUser>
where TUser : class
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
builder
.HasOne(b => b.CreatedBy)
.WithMany()
.HasForeignKey(b => b.CreatedById);
return builder;
}
/// <summary>
/// 批量配置實體創建人外鍵和導航屬性
/// </summary>
/// <typeparam name="TUser">用戶實體類型</typeparam>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <param name="modelBuilder">實體構造器</param>
/// <returns>當前實體構造器</returns>
public static ModelBuilder ConfigureForNavigationIncludedCreationUserAuditable<TUser, TIdentityKey>(
this ModelBuilder modelBuilder)
where TUser : class
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey, TUser>>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
var navigationIncludedCreationUserAuditableMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForNavigationIncludedCreationUserAuditable),
1,
[entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);
navigationIncludedCreationUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
}
return modelBuilder;
}
/// <summary>
/// 批量配置實體創建人外鍵,如果有導航屬性就同時配置導航屬性
/// </summary>
/// <typeparam name="TUser">用戶實體類型</typeparam>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <param name="modelBuilder">實體構造器</param>
/// <returns>當前實體構造器</returns>
public static ModelBuilder ConfigureForCreationUserOrNavigationIncludedAuditable<TUser, TIdentityKey>(
this ModelBuilder modelBuilder)
where TUser : class
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey>>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
MethodInfo creationUserAuditableMethod;
if (entity.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey, TUser>>())
{
creationUserAuditableMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForNavigationIncludedCreationUserAuditable),
1,
[entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);
creationUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
}
}
return modelBuilder;
}
/// <summary>
/// 配置實體最近修改人外鍵和導航屬性
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <typeparam name="TUser">用戶實體類型</typeparam>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <returns>實體類型構造器</returns>
public static EntityTypeBuilder<TEntity> ConfigureForNavigationIncludedLastUpdateUserAuditable<TEntity, TUser, TIdentityKey>(
this EntityTypeBuilder<TEntity> builder)
where TEntity : class, ILastUpdateUserAuditable<TIdentityKey, TUser>
where TUser : class
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
builder
.HasOne(b => b.LastUpdatedBy)
.WithMany()
.HasForeignKey(b => b.LastUpdatedById);
return builder;
}
/// <summary>
/// 批量配置實體最近修改人外鍵和導航屬性
/// </summary>
/// <typeparam name="TUser">用戶實體類型</typeparam>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <param name="modelBuilder">實體構造器</param>
/// <returns>當前實體構造器</returns>
public static ModelBuilder ConfigureForNavigationIncludedLastUpdateUserAuditable<TUser, TIdentityKey>(
this ModelBuilder modelBuilder)
where TUser : class
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey, TUser>>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
var navigationIncludedLastUpdateUserAuditableMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForNavigationIncludedLastUpdateUserAuditable),
1,
[entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);
navigationIncludedLastUpdateUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
}
return modelBuilder;
}
/// <summary>
/// 批量配置實體最近修改人外鍵,如果有導航屬性就同時配置導航屬性
/// </summary>
/// <typeparam name="TUser">用戶實體類型</typeparam>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <param name="modelBuilder">實體構造器</param>
/// <returns>當前實體構造器</returns>
public static ModelBuilder ConfigureForLastUpdateUserOrNavigationIncludedAuditable<TUser, TIdentityKey>(
this ModelBuilder modelBuilder)
where TUser : class
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
foreach (var entity
in modelBuilder.Model.GetEntityTypes()
.Where(static e => !e.HasSharedClrType)
.Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey>>()))
{
var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
MethodInfo lastUpdateUserAuditableMethod;
if (entity.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey, TUser>>())
{
lastUpdateUserAuditableMethod = GetEntityTypeConfigurationMethod(
nameof(ConfigureForNavigationIncludedLastUpdateUserAuditable),
1,
[entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);
lastUpdateUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
}
}
return modelBuilder;
}
}
沒有導航屬性的介面是為用戶表在其他資料庫的情況預留的,因此這個版本的介面不做作任何特殊配置。
資料庫上下文
// 其中IdentityKey是int的全局類型別名,上下文類型繼承自Identity Core上下文,用於演示操作用戶自動審計
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: ApplicationIdentityDbContext<
ApplicationUser,
ApplicationRole,
IdentityKey,
ApplicationUserClaim,
ApplicationUserRole,
ApplicationUserLogin,
ApplicationRoleClaim,
ApplicationUserToken>(options)
{
// 其他無關代碼
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 其他無關代碼
// 自動根據資料庫類型進行資料庫相關的模型配置
switch (Database.ProviderName)
{
case _msSqlServerProvider:
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(LogRecordEntityTypeConfiguration).Assembly,
type => type.GetCustomAttributes<DatabaseProviderAttribute>().Any(a => a.ProviderName is _msSqlServerProvider));
modelBuilder.ConfigureForTimeAuditable(DefaultSqlServerTimeAuditableDefaultValueSql.Instance);
break;
case _pomeloMySqlProvider:
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(LogRecordEntityTypeConfiguration).Assembly,
type => type.GetCustomAttributes<DatabaseProviderAttribute>().Any(a => a.ProviderName is _pomeloMySqlProvider));
modelBuilder.ConfigureForTimeAuditable(DefaultMySqlTimeAuditableDefaultValueSql.Instance);
break;
case _msSqliteProvider:
goto default;
default:
throw new NotSupportedException(Database.ProviderName);
}
// 配置其他資料庫中立的模型配置
modelBuilder.ConfigureForIOptimisticConcurrencySupported();
modelBuilder.ConfigureForCreationUserOrNavigationIncludedAuditable<ApplicationUser, IdentityKey>();
modelBuilder.ConfigureForLastUpdateUserOrNavigationIncludedAuditable<ApplicationUser, IdentityKey>();
}
}
項目使用MySQL,而VS會附帶一個SqlServer單機版,所以暫時使用這兩個資料庫進行演示,如果需要支持其他資料庫,可自行改造。
EF Core偵聽器
併發檢查偵聽器
/// <summary>
/// 為併發檢查標記設置值,如果有邏輯刪除實體,應該位於邏輯刪除攔截器之後
/// </summary>
public class OptimisticConcurrencySupportedSaveChangesInterceptor : SaveChangesInterceptor
{
protected IServiceScopeFactory ScopeFactory { get; }
public OptimisticConcurrencySupportedSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ScopeFactory = scopeFactory;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
OnSavingChanges(eventData);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
OnSavingChanges(eventData);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
/// <summary>
/// 處理實體的併發檢查令牌,並忽略由<see cref="ShouldProcessEntry"/>排除的實體
/// </summary>
/// <param name="eventData"></param>
protected virtual void OnSavingChanges(DbContextEventData eventData)
{
ArgumentNullException.ThrowIfNull(eventData.Context);
eventData.Context.ChangeTracker.DetectChanges();
var entries = eventData.Context.ChangeTracker.Entries()
.Where(static e => e.State is EntityState.Added or EntityState.Modified)
.Where(ShouldProcessEntry);
foreach (var entry in entries)
{
if (entry.Entity is IOptimisticConcurrencySupported optimistic)
{
if (entry.State is EntityState.Added)
{
optimistic.ConcurrencyStamp = Guid.NewGuid().ToString();
}
if (entry.State is EntityState.Modified)
{
// 如果是更新實體,需要分別處理原值和新值
var concurrencyStamp = entry.Property(nameof(IOptimisticConcurrencySupported.ConcurrencyStamp));
// 實體的當前值要指定為原值
concurrencyStamp!.OriginalValue = (entry.Entity as IOptimisticConcurrencySupported)!.ConcurrencyStamp;
// 然後重新生成新值
concurrencyStamp.CurrentValue = Guid.NewGuid().ToString();
}
}
}
}
/// <summary>
/// 用於排除在其他位置處理過併發檢查令牌的實體
/// </summary>
/// <param name="entry">實體</param>
/// <returns>如果應該由當前攔截器處理返回<see langword="true"/>,否則返回<see langword="false"/>。</returns>
protected virtual bool ShouldProcessEntry(EntityEntry entry) => true;
}
/// <summary><inheritdoc cref="OptimisticConcurrencySupportedSaveChangesInterceptor"/></summary>
/// <remarks>忽略用戶實體的併發檢查令牌,Identity服務已經處理過實體</remarks>
public class IdentityOptimisticConcurrencySupportedSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
: OptimisticConcurrencySupportedSaveChangesInterceptor(scopeFactory)
{
/// <summary>
/// 忽略Identity內置併發檢查的實體
/// </summary>
/// <param name="entry">待檢查的實體</param>
/// <returns>不是IdentityUser的實體</returns>
protected override bool ShouldProcessEntry(EntityEntry entry)
{
var type = entry.Entity.GetType();
var isUserOrRole = type.IsDerivedFrom(typeof(IdentityUser<>)) || type.IsDerivedFrom(typeof(IdentityRole<>));
return !isUserOrRole;
}
}
Identity Core有一套內置的併發檢查處理機制,因此需要對Identity相關實體進行排除,防止重覆處理引起異常。
時間審計偵聽器
/// <summary>
/// 為操作時間審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之前。<br/>
/// 刪除時間已經由邏輯刪除標記保留,不應該用刪除時間覆蓋更新時間,在邏輯刪除之前使用避免誤操作由邏輯刪除攔截器設置的已編輯的實體。
/// </summary>
public class OperationTimeAuditableSaveChangesInterceptor : SaveChangesInterceptor
{
protected IServiceScopeFactory ScopeFactory { get; }
/// <summary>
/// 為操作時間審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之前。<br/>
/// 刪除時間已經由邏輯刪除標記保留,不應該用刪除時間覆蓋更新時間,在邏輯刪除之前使用避免誤操作由邏輯刪除攔截器設置的已編輯的實體。
/// </summary>
/// <param name="scopeFactory"></param>
public OperationTimeAuditableSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ScopeFactory = scopeFactory;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
OnSavingChanges(eventData);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
OnSavingChanges(eventData);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
/// <summary>
/// 處理實體的審計時間
/// </summary>
/// <param name="eventData"></param>
protected virtual void OnSavingChanges(DbContextEventData eventData)
{
ArgumentNullException.ThrowIfNull(eventData.Context);
using var scope = ScopeFactory.CreateScope();
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
eventData.Context.ChangeTracker.DetectChanges();
var entries = eventData.Context.ChangeTracker.Entries()
.Where(static e => e.State is EntityState.Added or EntityState.Modified);
foreach (var entry in entries)
{
if(entry is { Entity: ICreationTimeAuditable creation, State: EntityState.Added })
{
if(creation.CreatedAt is null || creation.CreatedAt == default)
{
creation.CreatedAt = timeProvider.GetLocalNow();
}
}
if (entry is { Entity: ILastUpdateTimeAuditable update, State: EntityState.Added or EntityState.Modified })
{
if (entry.Property(nameof(update.LastUpdatedAt)).IsModified) { }
else if (update.LastUpdatedAt is null || update.LastUpdatedAt == default)
{
update.LastUpdatedAt = timeProvider.GetLocalNow();
}
if (entry is { Entity: ICreationTimeAuditable, State: EntityState.Modified })
{
entry.Property(nameof(ICreationTimeAuditable.CreatedAt)).IsModified = false;
}
}
}
}
}
操作人審計偵聽器
/// <summary>
/// 為操作人審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之後。<br/>
/// 到此處依然處於刪除狀態的實體應該是物理刪除,記錄審計信息沒有意義。
/// </summary>
public class OperatorAuditableSaveChangesInterceptor<TIdentityKey> : SaveChangesInterceptor
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
protected IServiceScopeFactory ScopeFactory { get; }
/// <summary>
/// 為操作人審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之後。<br/>
/// 到此處依然處於刪除狀態的實體應該是物理刪除,記錄審計信息沒有意義。
/// </summary>
/// <param name="scopeFactory"></param>
public OperatorAuditableSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ScopeFactory = scopeFactory;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
OnSavingChanges(eventData);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
OnSavingChanges(eventData);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
/// <summary>
/// 處理實體的審計操作人
/// </summary>
/// <param name="eventData"></param>
protected virtual void OnSavingChanges(DbContextEventData eventData)
{
ArgumentNullException.ThrowIfNull(eventData.Context);
using var scope = ScopeFactory.CreateScope();
var operatorAccessor = scope.ServiceProvider.GetRequiredService<IOperatorAccessor<TIdentityKey>>();
eventData.Context.ChangeTracker.DetectChanges();
var entries = eventData.Context.ChangeTracker.Entries()
.Where(static e => e.State is EntityState.Added or EntityState.Modified);
foreach (var entry in entries)
{
if (entry is { Entity: ICreationUserAuditable<TIdentityKey> creation, State: EntityState.Added })
{
if (creation.CreatedById is null || creation.CreatedById.Value.Equals(default))
{
creation.CreatedById = operatorAccessor.GetUserId();
}
}
if (entry is { Entity: ILastUpdateUserAuditable<TIdentityKey> update, State: EntityState.Added or EntityState.Modified })
{
if (entry.Property(nameof(update.LastUpdatedById)).IsModified) { }
else if (update.LastUpdatedById is null || update.LastUpdatedById.Value.Equals(default))
{
update.LastUpdatedById = operatorAccessor.GetUserId();
}
if (entry is { Entity: ICreationUserAuditable<TIdentityKey>, State: EntityState.Modified })
{
entry.Property(nameof(ICreationUserAuditable<TIdentityKey>.CreatedById)).IsModified = false;
}
}
}
}
}
/// <summary>
/// 實體操作人的用戶Id提供服務
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface IOperatorAccessor<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>
{
/// <summary>
/// 獲取用戶Id
/// </summary>
/// <returns>用戶Id</returns>
TIdentityKey? GetUserId();
/// <summary>
/// 非同步獲取用戶Id
/// </summary>
/// <param name="cancellation">取消令牌</param>
/// <returns>用戶Id</returns>
Task<TIdentityKey?> GetUserIdAsync(CancellationToken cancellation = default);
}
/// <summary>
/// 使用Http上下文獲取實體操作人的用戶Id
/// </summary>
/// <typeparam name="TIdentityKey"><inheritdoc cref="IOperatorAccessor{TIdentityKey}"/></typeparam>
/// <param name="contextAccessor">Http上下文訪問器</param>
/// <param name="options">Identity選項</param>
public class HttpContextUserOperatorAccessor<TIdentityKey>(
IHttpContextAccessor contextAccessor,
IOptions<IdentityOptions> options)
: IOperatorAccessor<TIdentityKey>
where TIdentityKey : struct, IEquatable<TIdentityKey>, IParsable<TIdentityKey>
{
public TIdentityKey? GetUserId()
{
var success = TIdentityKey.TryParse(contextAccessor.HttpContext?.User.Claims.FirstOrDefault(c => c.Type == options.Value.ClaimsIdentity.UserIdClaimType)!.Value, null, out var id);
return success ? id : null;
}
public Task<TIdentityKey?> GetUserIdAsync(CancellationToken cancellation = default)
{
return Task.FromResult(GetUserId());
}
}
實體操作人的獲取在定義偵聽器的時候是未知的,所以獲取方式需要通過介面從外部傳入。此處以用ASP.NET Core Identity獲取用戶Id為例。
偵聽器統一使用作用域工廠服務使其能和依賴註入系統緊密配合,然後使用內部作用域即用即取,用完立即銷毀的方式避免記憶體泄露。
配置服務
一切準備妥當後就可以在主應用里配置相關服務讓功能可以正常運行。
public void ConfigureServices(IServiceCollection services)
{
// 實體操作人審計EF Core攔截器需要使用此服務獲取操作人信息
services.AddScoped(typeof(IOperatorAccessor<>), typeof(HttpContextUserOperatorAccessor<>));
// 註冊基於緩衝池的資料庫上下文工廠
services.AddPooledDbContextFactory<ApplicationDbContext>((sp, options) =>
{
// 註冊攔截器
var scopeFactory = sp.GetRequiredService<IServiceScopeFactory>();
options.AddInterceptors(
new OperationTimeAuditableSaveChangesInterceptor(scopeFactory),
new IdentityOptimisticConcurrencySupportedSaveChangesInterceptor(scopeFactory),
new OperatorAuditableSaveChangesInterceptor<IdentityKey>(scopeFactory));
// 其它代碼
});
// 其它代碼
}
由於攔截器對象是長期存在且脫離依賴註入的特殊對象,因此需要從外部傳入作用域工廠使其能夠使用依賴註入的相關功能和整個ASP.NET Core應用更緊密的集成。攔截器和ASP.NET Core中間件一樣順序會影響結果,因此要認真考慮如何安排。
結語
如此一番操作之後,操作時間、操作用戶審計和樂觀併發就全自動化了,一般業務代碼可以0修改完成集成。如果手動操作相關屬性,偵聽器也會優先採用手動操作的結果保持充足的靈活性。
示例代碼:SoftDeleteDemo.rar。主頁顯示異常請在libman.json上右鍵恢復前端包。
QQ群
讀者交流QQ群:540719365
歡迎讀者和廣大朋友一起交流,如發現本書錯誤也歡迎通過博客園、QQ群等方式告知筆者。
本文地址:https://www.cnblogs.com/coredx/p/18305165.html