论如何直接用EF Core实现创建更新时间、用户审计,自动化乐观并发、软删除和树形查询(上)

前言

数据库并发,数据审计和软删除一直是数据持久化方面的经典问题。早些时候,这些工作需要手写复杂的SQL或者通过存储过程和触发器实现。手写复杂SQL对软件可维护性构成了相当大的挑战,随着SQL字数的变多,用到的嵌套和复杂语法增加,可读性和可维护性的难度是几何级暴涨。因此如何在实现功能的同时控制这些SQL的复杂度是一个很有价值的问题。而且这个问题同时涉及应用软件和数据库两个相对独立的体系,平行共管也是产生混乱的一大因素。

EF Core作为 .NET平台的高级ORM框架,可以托管和数据库的交互,同时提供了大量扩展点方便自定义。以此为基点把对数据库的操作托管后便可以解决平行共管所产生的混乱,利用LINQ则可以最大程度上降低软件代码的维护难度。

由于项目需要,笔者先后开发并发布了通用的基于EF Core的国际化资源管理服务和Serilog日志持久化服务,不过这两个功能包并没有深度利用EF Core,虽然主要是因为没什么必要。但是项目还需要提供常用的数据审计和软删除功能,因此对EF Core进行了一些更深入的研究。

起初有考虑过是否使用现成的ABP框架来处理这些功能,但是在其他项目的使用体验来说并不算好,其中充斥着大量上下文依赖的功能,而且这些依赖信息能轻易藏到和最终业务代码相距十万八千里的地方(特别是代码还是别人写的时候),然后在不经意间给你一个大惊喜。对于以代码正交性、非误导性,纯函数化为追求的一介码农(看过我发布的那两个功能包的朋友应该有感觉,一个功能笔者也要根据用途划分为不同的包,确保解决方案中的各个项目都能按需引用,不会残留无用的代码),实在是喜欢不起来ABP这种全家桶。

鉴于项目规模不大,笔者决定针对这些需求做一个专用功能,目标是尽可能减少依赖,方便将来复用到其他项目,降低和其他功能功能冲突的风险。现在笔者将用一系列博客做成果展示。由于这些功能没有经过大范围测试,不确定是否存在未知缺陷,因此暂不打包发布。

新书宣传

有关新书的更多介绍欢迎查看《C#与.NET6 开发从入门到实践》上市,作者亲自来打广告了!

正文

由于这些功能设计的代码量和知识点较多,为控制篇幅,本文介绍数据审计和乐观并发功能。

EF Core 3.0新增了侦听器功能,允许在实际执行操作之前或之后插入自定义操作,利用这个功能可以实现数据审计的自动化。为此需要做些前期准备。

审计实体接口

乐观并发接口

csharp 复制代码
/// <summary>
/// 乐观并发接口
/// </summary>
public interface IOptimisticConcurrencySupported
{
    /// <summary>
    /// 行版本,乐观并发锁
    /// </summary>
    [ConcurrencyCheck]
    string? ConcurrencyStamp { get; set; }
}

SqlServer数据库支持自动的行版本功能,但是大多数其他数据库并不支持,因此选用兼容性更好的方案。Identity Core为了兼容性也不用行版本实现乐观并发。

时间审计接口

csharp 复制代码
/// <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; }
}

操作人审计接口

csharp 复制代码
/// <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方便准确控制外键可空性。

需要的辅助方法

csharp 复制代码
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的泛型和非泛型实体构造器无法直接转换,只能通过反射动态生成泛型方法复用单体配置扩展。这样能保证配置代码只有唯一一份,避免重复代码导致维护时出现疏漏。

实体模型配置扩展

乐观并发扩展

csharp 复制代码
/// <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;
}

时间审计扩展

csharp 复制代码
/// <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在不同数据库下有差异,因此需要从外部传入,方便根据数据库类型切换。

csharp 复制代码
/// <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() { }
}

操作人审计扩展

csharp 复制代码
/// <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;
    }
}

没有导航属性的接口是为用户表在其他数据库的情况预留的,因此这个版本的接口不做作任何特殊配置。

数据库上下文

csharp 复制代码
// 其中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侦听器

并发检查侦听器

csharp 复制代码
/// <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相关实体进行排除,防止重复处理引起异常。

时间审计侦听器

csharp 复制代码
/// <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;
                }
            }
        }
    }
}

操作人审计侦听器

csharp 复制代码
/// <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为例。

侦听器统一使用作用域工厂服务使其能和依赖注入系统紧密配合,然后使用内部作用域即用即取,用完立即销毁的方式避免内存泄露。

配置服务

一切准备妥当后就可以在主应用里配置相关服务让功能可以正常运行。

csharp 复制代码
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

相关推荐
数据的世界016 小时前
.NET开发人员学习书籍推荐
学习·.net
paixiaoxin8 小时前
CV-OCR经典论文解读|An Empirical Study of Scaling Law for OCR/OCR 缩放定律的实证研究
人工智能·深度学习·机器学习·生成对抗网络·计算机视觉·ocr·.net
19004317 小时前
.NET重点
.net
m0_6632340117 小时前
在 .NET 5.0 运行 .NET 8.0 教程:使用 ASP.NET Core 创建 Web API
前端·asp.net·.net
小码编匠1 天前
.NET 下 RabbitMQ 队列、死信队列、延时队列及小应用
后端·c#·.net
真想骂*2 天前
vmime.net_4.dll详解:它是什么,有何用途?
.net
lzhdim2 天前
2、C#基于.net framework的应用开发实战编程 - 设计(二、二) - 编程手把手系列文章...
开发语言·c#·.net
工业3D_大熊2 天前
HOOPS Communicator功能剖析:3D Web模型树交互的实用指南!
linux·windows·macos·3d·docker·c#·.net
小码编匠3 天前
C# 使用心跳机制实现TCP客户端自动重连
后端·c#·.net