[ASPNETCORE] 抛砖引玉,EFCORE 软删除和自动添加审计的实现

很久没更新博客了,主要是换了新的环境,从netcore转回了framework,突然没有学习和探究的欲望了。

以前项目首选Dapper来操作数据库,反正就是 SQL 一把梭,很爽很暴力。但新单位要求使用 EntityFramework6,无语凝噎中,写一篇关于EFCore 的文章,算是我最后的倔强吧。

EF 用的真的不多,所以这里就只能聊聊两点,一个是批量注册全局软删除筛选器,以及由此引出的自动添加审计功能的实现。

批量添加软删除全局筛选器

方法一,这是google出来的一个老外的写法,感觉比我常用的(方法二)简洁

第一步:新建文件,内容如下:

public static class SoftDeleteQueryExtension
{
    public static void AddSoftDeleteQueryFilter(this IMutableEntityType entityData)
    {
        var methodToCall = typeof(SoftDeleteQueryExtension)
            .GetMethod(nameof(GetSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)
            ?.MakeGenericMethod(entityData.ClrType);

        if (methodToCall is { })
        {
            var filter = methodToCall.Invoke(null, new object[] { });
            entityData.SetQueryFilter((LambdaExpression)filter);
        }

        entityData.AddIndex(entityData.FindProperty(nameof(ISoftDeleted.IsDeleted)));
    }

    private static LambdaExpression GetSoftDeleteFilter<TEntity>()
        where TEntity : class, ISoftDeleted
    {
        Expression<Func<TEntity, bool>> filter = x => !x.IsDeleted;
        return filter;
    }
}

第二步:重写DbContextOnModelCreating(ModelBuilder builder) 方法

protected override void OnModelCreating(ModelBuilder builder)
{
    foreach (var mutableEntityType in builder.Model.GetEntityTypes())
    {
        if (typeof(ISoftDeleted).IsAssignableFrom(mutableEntityType.ClrType))
        {
            mutableEntityType.AddSoftDeleteQueryFilter();
        }
    }
}

方法二

第一步:在你的 DbContext 文件中增加如下方法:

private static void RegisterGlobalFilter<T>(ModelBuilder builder) where T : class
{
    // ExpressionUtil 后面补充内容的表达式目录树部分有完整代码
    var expr = ExpressionUtil.True<T>();

    if (typeof(ISoftDeleted).IsAssignableFrom(typeof(T)))
    {
        expr = expr.And(t => ((ISoftDeleted)t).IsDel == false);
    }
    if (typeof(ITenantRequired).IsAssignableFrom(typeof(T)))
    {
        expr = expr.And(t => ((ITenantRequired)t).TenantId == _provider.GetTenantId());
    }

    builder.Entity<T>().HasQueryFilter(expr);
}

第二步:重写DbContextOnModelCreating(ModelBuilder builder) 方法

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var method = typeof(你的DbContext文件).GetMethod("RegisterGlobalFilter",
        BindingFlags.Static | BindingFlags.NonPublic);

    if (method != null)
    {
        var types = modelBuilder.Model.GetEntityTypes().Select(t => t.ClrType).ToList();

        foreach (var type in types)
        {
            method.MakeGenericMethod(type).Invoke(this, new object[] { modelBuilder });
        }
    }
}

说起来,第一种的用法我是第一次见到,主要是IMutableEntityType没用过也不了解,所以个人感觉有点厉害,第二个相对更好理解一些,其实就是下面这句的封装:

modelBuilder.Entity<Person>().HasQueryFilter(x => x.IsDel == false);

好了,添加全局筛选器的内容就结束了,很简单,抄来用即可。


其实同样方式是可以用来添加租户的全局筛选器的,问题是租户从哪里来?

不废话,直接上代码了:

public class TestDbContext : DbContext
{
    private readonly IAuditProvider _provider;

    public TestDbContext(DbContextOptions<BodyTemperatureDbContext> options, IAuditProvider provider) : base(options)
    {
        _provider = provider;
    }
    
    // ... 其他代码
}

可以看到,构造函数中多了一个IAuditProvider,这个接口很简单,就是返回一系列跟当前用户有关的内容。

public interface IAuditProvider
{
    /// <summary>
    /// 获取用户名
    /// </summary>
    /// <returns></returns>
    string GetName();

    /// <summary>
    /// 获取用户ID
    /// </summary>
    /// <returns></returns>
    int GetId();

    /// <summary>
    /// 获取租户ID
    /// </summary>
    /// <returns></returns>
    int GetTenantId();

    /// <summary>
    /// 获取部门ID
    /// </summary>
    /// <returns></returns>
    int GetDepartmentId();
}

先不用管这个接口如何实现,反正有了它,并将其作为依赖项引入到DbContext中,就可以引用了。

在原方法二的基础上给RegisterGlobalFilter方法加点代码:

private static void RegisterGlobalFilter<T>(ModelBuilder builder) where T : class
{
    var expr = ExpressionUtil.True<T>();

    if (typeof(ISoftDeleted).IsAssignableFrom(typeof(T)))
    {
        expr = expr.And(t => ((ISoftDeleted)t).IsDel == false);
    }
    
    // 手动高亮
    if (typeof(ITenantRequired).IsAssignableFrom(typeof(T)))
    {
        expr = expr.And(t => ((ITenantRequired)t).TenantId == _provider.GetTenantId());
    }

    builder.Entity<T>().HasQueryFilter(expr);
}

这样就搞定了... 过于简单,有水字数的嫌疑...

发散一下思维

有了这个IAuditProvider,不但可以添加全局筛选条件,还可以实现自动创建、修改、删除审计,反正好处一堆,但需要重写SaveChange方法,有代码洁癖者慎用

个人对ef用的并不熟练,下面的代码主要目标是实现功能,实现上并没有经过太多考量,算抛砖引玉吧

public override int SaveChanges()
{
    SetAudit();
    return base.SaveChanges();
}

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    SetAudit();
    return base.SaveChangesAsync(cancellationToken);
}

private void SetAudit()
{
    var userId = _provider.GetId();
    var userName = _provider.GetName();
    var tenantId = _provider.GetTenantId();

    // 新增的时候添加自动添加创建操作的审计信息,
    // 个人习惯同时操作更新的审计,毕竟第一次,既是添加也是最后一次修改,没毛病
    var createdEntities = ChangeTracker.Entries()
        .Where(e => e.State == EntityState.Added).Select(e => e.Entity);
    
    foreach (var entity in createdEntities)
    {
        if (entity is ICreateAudit createAudit)
        {
            createAudit.Creator = userName;
            createAudit.CreateAt = DateTime.Now;
            createAudit.CreateBy = userId;
        }

        if (entity is IEditAudit updateAudit)
        {
            updateAudit.Updater = userName;
            updateAudit.UpdateAt = DateTime.Now;
            updateAudit.UpdateBy = userId;
        }

        if (entity is ITenantRequired tenantEntity)
        {
            if (tenantEntity.TenantId == 0)
            {
                tenantEntity.TenantId = tenantId;
            }
        }
    }

    // 如果是修改,要把创建审计、软删除、租户等相关字段忽略掉;
    // 同时要更新 最后一次修改的审计信息
    var modifiedEntities = ChangeTracker.Entries()
        .Where(e => e.State == EntityState.Modified).Select(e => e.Entity);
    foreach (var entity in modifiedEntities)
    {
        if (entity is IEditAudit audit)
        {
            audit.UpdateBy = userId;
            audit.Updater = userName;
            audit.UpdateAt = DateTime.Now;
        }

        if (entity is ICreateAudit createAudit)
        {
            Entry(createAudit).Property(e => e.CreateBy).IsModified = false;
            Entry(createAudit).Property(e => e.Creator).IsModified = false;
            Entry(createAudit).Property(e => e.CreateAt).IsModified = false;
        }

        if (entity is ISoftDeleted deletedAudit)
        {
            Entry(deletedAudit).Property(e => e.IsDel).IsModified = false;
        }

        if (entity is ITenantRequired tenantEntity)
        {
            Entry(tenantEntity).Property(e => e.TenantId).IsModified = false;
        }
    }

    // 删除操作,如果是软删除的实体,要把操作改成update,并且只更新IsDeleted字段为true即可
    // 我这里没有删除审计的功能,如果需要,自己添加即可
    var deletedEntities = ChangeTracker.Entries().Where(e => e.State == EntityState.Deleted);
    foreach (var entry in deletedEntities)
    {
        var entity = entry.Entity;
        if (entity is ISoftDeleted softDeleted)
        {
            softDeleted.IsDel = true;
            entry.State = EntityState.Unchanged;
            entry.Property("IsDel").IsModified = true;
        }
    }
}

上面的代码粘贴上来后,手动改动了一点,如果跑起来有问题,请留言提醒,还有就是上面用到了一些自定义的类,都补充在下面了

一. 表达式目录树的扩展,就是上面用到的ExpressionUtil以及表达式目录树AndOr 拼接方法的封装

下面的代码有的是网上抄的,有的自己写的,至少应该是可以用的

先创建这个工具类 ParameterRebind

public class ParameterRebind : ExpressionVisitor
{
    /// <summary>
    /// 参数表达式的映射,前面是原始参数表达式,后面是要替换的参数表达式
    /// </summary>
    readonly Dictionary<ParameterExpression, ParameterExpression> _map;

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="map">The map.</param>
    private ParameterRebind(Dictionary<ParameterExpression, ParameterExpression> map)
    {
        _map = map ?? new Dictionary<ParameterExpression, ParameterExpression>();
    }

    /// <summary>
    /// 访问参数
    /// </summary>
    /// <param name="p">The p.</param>
    /// <returns>Expression</returns>
    protected override Expression VisitParameter(ParameterExpression p)
    {
        if (_map.TryGetValue(p, out var replacement))
        {
            p = replacement;
        }
        return base.VisitParameter(p);
    }

    /// <summary>
    /// 静态方法,替换参数
    /// </summary>
    /// <param name="map">参数映射</param>
    /// <param name="exp">要处理的表达式</param>
    /// <returns>Expression</returns>
    public static Expression ReplaceParameters(Dictionary<ParameterExpression, ParameterExpression> map, Expression exp)
    {
        return new ParameterRebind(map).Visit(exp);
    }
    
}

接着创建 ExpressionUtil

public static class ExpressionUtil
{
    // 返回一个恒定为true的表达式,相当于 where 1=1,一般要拼接表达式,都是从这一句开始
    public static Expression<Func<T, bool>> True<T>()
    {
        return obj => true;
    }

    // 这个从来没用过,hahaha...
    public static Expression<Func<T, bool>> False<T>()
    {
        return obj => false;
    }

    /// <summary>
    /// 组合And
    /// </summary>
    /// <returns></returns>
    public static Expression<Func<T, bool>> And<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
    {
        return first.Compose(second, Expression.AndAlso);
    }

    /// <summary>
    /// 组合Or
    /// </summary>
    /// <returns></returns>
    public static Expression<Func<T, bool>> Or<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
    {
        return first.Compose(second, Expression.OrElse);
    }

    private static Expression<T> Compose<T>(this Expression<T> first, Expression<T> second, Func<Expression, Expression, Expression> merge)
    {
        var map = first.Parameters
            .Select((f, i) => new { f, s = second.Parameters[i] })
            .ToDictionary(p => p.s, p => p.f);
        var secondBody = ParameterRebind.ReplaceParameters(map, second.Body);
        return Expression.Lambda<T>(merge(first.Body, secondBody), first.Parameters);
    }
}

二. 那个IAuditProvider 到底怎么用?

定义一个实现,从登录用户中获取,类似 HzcClaimTypes 就是自己定义的字符串,主要是嫌弃官方提供的太长了。

这个实现比较简单,有些库里面是尝试从多个渠道获取用户数据:

  1. 登录用户获取(通过cookie,token,session等)
  2. 请求头
  3. 请求参数

我个人比较喜欢微软封装的认证和鉴权的做法,所以所有项目无论如何都会把用户信息附加到HttpContext的User对象上,所以这个 DefaultAuditProvider 足够用了。

public class DefaultAuditProvider : IAuditProvider
{
    private readonly IHttpContextAccessor _accessor;

    public AuditProvider(IHttpContextAccessor accessor)
    {
        _accessor = accessor;
    }

    public string GetName()
    {
        return _accessor.HttpContext.User?.Identity.Name ?? "";
    }

    public int GetId()
    {
        return _accessor.HttpContext.User.GetInt(HzcClaimTypes.Id);
    }

    public int GetTenantId()
    {
        return _accessor.HttpContext.User.GetInt(HzcClaimTypes.TenantId);
    }

    public int GetDepartmentId()
    {
        return 0;
    }
}

在 Startup 的 ConfigureServices 方法中添加:

services.AddSingleton<IAuditProvider, DefaultAuditProvider>();

搞定收工...

posted @ 2021-03-08 00:05  没追求的码农  阅读(408)  评论(0编辑  收藏  举报