論如何直接用EF Core實現建立更新時間、使用者審計,自動化樂觀併發、軟刪除和樹形查詢(上)

coredx發表於2024-07-18

前言

資料庫併發,資料審計和軟刪除一直是資料持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者透過儲存過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的巢狀和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些SQL的複雜度是一個很有價值的問題。而且這個問題同時涉及應用軟體和資料庫兩個相對獨立的體系,平行共管也是產生混亂的一大因素。

EF Core作為 .NET平臺的高階ORM框架,可以託管和資料庫的互動,同時提供了大量擴充套件點方便自定義。以此為基點把對資料庫的操作託管後便可以解決平行共管所產生的混亂,利用LINQ則可以最大程度上降低軟體程式碼的維護難度。

由於專案需要,筆者先後開發併發布了通用的基於EF Core儲存的國際化服務基於EF Core儲存的Serilog持久化服務,不過這兩個功能包並沒有深度利用EF Core,雖然主要是因為沒什麼必要。但是專案還需要提供常用的資料審計和軟刪除功能,因此對EF Core進行了一些更深入的研究。

起初有考慮過是否使用現成的ABP框架來處理這些功能,但是在其他專案的使用體驗來說並不算好,其中充斥著大量上下文依賴的功能,而且這些依賴資訊能輕易藏到和最終業務程式碼相距十萬八千里的地方(特別是程式碼還是別人寫的時候),然後在不經意間給你一個大驚喜。對於以程式碼正交性、非誤導性,純函式化為追求的一介碼農(看過我釋出的那兩個功能包的朋友應該有感覺,一個功能筆者也要根據用途劃分為不同的包,確保解決方案中的各個專案都能按需引用,不會殘留無用的程式碼),實在是喜歡不起來ABP這種全家桶。

鑑於專案規模不大,筆者決定針對這些需求做一個專用功能,目標是儘可能減少依賴,方便將來複用到其他專案,降低和其他功能功能衝突的風險。現在筆者將用一系列部落格做成果展示。由於這些功能沒有經過大範圍測試,不確定是否存在未知缺陷,因此暫不打包釋出。

新書宣傳

有關新書的更多介紹歡迎檢視《C#與.NET6 開發從入門到實踐》上市,作者親自來打廣告了!
image

正文

由於這些功能設計的程式碼量和知識點較多,為控制篇幅,本文介紹資料審計和樂觀併發功能。

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&lt;&gt;)</c>,<c>typeof(List&lt;int&gt;)</c>,<c>typeof(IDictionary&lt;,&gt;)</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
image

歡迎讀者和廣大朋友一起交流,如發現本書錯誤也歡迎透過部落格園、QQ群等方式告知筆者。

本文地址:https://www.cnblogs.com/coredx/p/18305165.html

相關文章