深入理解 EF Core之使用查询过滤器实现数据软删除

一、EFCore软删除

软删除,即表面上删除了数据,但数据并没有被物理删除,在需要的时候你还是可以把它读取出来的。

二、概要

1. 你可以使用全局查询过滤器(现在称为查询过滤器)为你的 EF Core 应用程序添加软删除功能。

2.在应用程序中使用软删除的主要好处是可以恢复无意的删除和保留历史记录。

3.在应用程序中添加软删除功能包含以下三个部分:

  • 向每个想要软删除的实体类添加一个新的软删除属性。
  • 在应用程序的 DbContext 中配置查询过滤器。
  • 创建用于设置或重置软删除属性的代码。

4. 你可以将软删除与查询过滤器的用途(如多租户使用)结合使用,但是在查找软删除条目时需要更加小心。

5.不要软删除一对一的实体类,因为它会导致问题。

6.对于具有关联关系的实体类,你需要考虑当顶级实体类被软删除时,依赖关系会发生什么。

三、为什么要软删除

当你硬删除(也叫物理删除)数据时,数据会从你的数据库中彻底消失。此外,硬删除还可能硬删除依赖于所删除行的行(译注:默认未设置级联删除规则的情况下,删除一行数据时,其它通过外键关联该行的数据都会被级联删除)。就像俗话说的那样,“当它离开了,它就永远离开了”——除非你有备份,否则无法取回它。

但现在对数据重视度越来越高的环境下,我们更需要“我使它离开了,但我还可以让它再回来”。在 Windows 上,它是回收站;如果你在编辑器中删除了一些文本,你可以用 ctrl-Z 取回它,等等。软删除就是是 EF Core 版本的实体类回收站(实体类是通过 EF Core 映射到数据库的类的术语),它从正常使用中消失了,但是你可以取回它。保存被软删除的数据的另一个好处是历史记录——即使是被软删除的数据,你也可以看到过去发生了什么变化。

你可以使用 EF Core 查询过滤器实现软删除功能。查询过滤器也用于多租户系统,其中每个租户的数据只能由属于同一租户的用户访问。在这种情况下,数据被软删除,意味着 EF Core 查询过滤器在隐藏信息时非常安全的。

使用软删除也有一些缺点,最主要的缺点是性能。使用软删除在每个实体类的查询中包含一个额外隐藏的 SQL WHERE 子句。

与硬删除相比,软删除处理依赖关系的方式也有所不同。默认情况下,如果您软删除一个实体类,那么它的依赖关系不会被软删除,而实体类的硬删除通常会删除依赖关系。这意味着如果我软删除了一个 Book 实体类,那么这本书的评论仍然是可见的,这在某些情况下可能是个问题。

四、为你的应用添加软删除

  • 向需要软删除的实体类添加软删除属性
  • 向 DbContext 中添加代码,以对这些实体类应用查询过滤器
  • 如何设置/重置软删除

1.添加软删除属性

对于标准的软删除实现,你需要一个布尔标志来控制软删除。例如,这里有一个名叫 Deleted 属性的 Book 实体。

using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cari.IBP.Platform.Utility.Utility;

namespace Cari.IBP.Platform.Utility.Models
{
    /// <summary>
    /// model基类
    /// </summary>
    public abstract class BaseEntity
    {
        private string id;
        /// <summary>
        /// 
        /// </summary>
        public BaseEntity()
        {
            CreatedOn = DateTime.Now;
            Id = IdWorker.Instance.NextId().ToString();
        }
        /// <summary>
        /// 
        /// </summary>
        public virtual void Create()
        {
            CreatedOn = DateTime.Now;
            Id = IdWorker.Instance.NextId().ToString();
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="Id"></param>
        public virtual void Modify(string Id)
        {
            this.Id = Id;
            ModifiedOn = DateTime.Now;
        }
        public virtual void SoftDelete()
        {
            Deleted = false;
        }
        /// <summary>
        /// id
        /// </summary>
        [Key]
        [MaxLength(50)]
        [Description("主键")]
        public string Id
        {
            get => id;

            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    id = value;
                }
            }
        }
        /// <summary>
        /// 是否删除
        /// </summary>
        [Description("是否删除")]
        public bool Deleted { get; set; }
        /// <summary>
        /// domain数据域
        /// </summary>
        [MaxLength(50)]
        [Description("domain数据域")]
        public string Domain { get; set; }
        /// <summary>
        /// 创建时间
        /// </summary>
        [Description("创建时间")]
        public DateTime CreatedOn { get; set; }
        /// <summary>
        /// 创建人
        /// </summary>
        [MaxLength(50)]
        [Description("创建人")]
        public string CreatedBy { get; set; }
        /// <summary>
        /// 修改时间
        /// </summary>
        [Description("主键")]
        public DateTime? ModifiedOn { get; set; }
        /// <summary>
        /// 修改人
        /// </summary>
        [MaxLength(50)]
        [Description("修改人")]
        public string ModifiedBy { get; set; }
        /// <summary>
        /// 租户id
        /// </summary>
        [MaxLength(50)]
        [Description("主键")]
        public string TenantId { get; set; }
        /// <summary>
        /// 排序
        /// </summary>
        [Description("排序")]
        public int Sort { get; set; }
        /// <summary>
        /// 记录创建人员所属部门id
        /// </summary>
        [MaxLength(50)]
        [Description("记录创建人员所属部门id")]
        public string CreateByDepId { get; set; }
        /// <summary>
        /// 记录创建人员所属部门名称
        /// </summary>
        [MaxLength(50)]
        [Description("记录创建人员所属部门名称")]
        public string CreateByDepName { get; set; }
    }
}
public class Book:BaseEntity
{
    public int BookId { get; set; }
    public string Title { get; set; }
}

通过 Deleted 来区分软删除属性,如果它的值是 true 则该实体软删除了。这意味着当你创建一个新实体时,它不会被软删除。

2.配置查询过滤器

你必须告诉 EF Core 哪个实体类需要一个查询过滤器,该过滤器是查询表达式,用来把不需要被看到的实体过滤掉。你可以在 DbContext 中使用以下代码手动完成此操作。

public class EfCoreContext : DbContext
{
    public EfCoreContext(DbContextOptions<EfCoreContext> option)
        : base(options)
    {}

    protected override OnModelCreating(ModelBuilder modelBuilder)
    {
        // 省略其它和软删除无关的代码

        modelBuilder.Entity<Book>().HasQueryFilter(p => !p.Deleted);
    }
}

 在 DbContext 中的 OnModelCreating 方法中,你可以通过 Fluent API 配置 EF Core。但是也有一种方法可以判断每个实体类并决定如何配置它。在下面的代码中,你可以看到 foreach 循环依次遍历每个实体类,检查实体类是否继承了 BaseEntity基类,如果实现了,它将调用我创建的扩展方法来应用正确的软删除过滤器配置。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{

    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {

        if (typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
        {
            entityType.AddSoftDeleteQueryFilter();
        }
    }
}

有许多配置可以直接应用于 GetEntityTypes 方法返回的类型,但是设置查询过滤器需要更多的工作。这是因为查询过滤器中的 LINQ 查询需要实体类的类型来创建正确的 LINQ 表达式。为此,我创建了一个小型扩展类,它可以动态创建正确的 LINQ 表达式来配置查询过滤器。

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Cari.IBP.Platform.Utility.Models;

namespace Cari.Disp.Context
{
    /// <summary>
    /// 软删除过滤器
    /// </summary>
    public static class SoftDeleteQueryExtension
    {
        /// <summary>
        /// 软删除过滤器
        /// </summary>
        /// <param name="entityData"></param>
        public static void AddSoftDeleteQueryFilter(this IMutableEntityType entityData)
        {
            var methodToCall = typeof(SoftDeleteQueryExtension) .GetMethod(nameof(GetSoftDeleteFilter),BindingFlags.NonPublic | BindingFlags.Static).MakeGenericMethod(entityData.ClrType);
            var filter = methodToCall.Invoke(null, new object[] { });
            entityData.SetQueryFilter((LambdaExpression)filter);
        }
        /// <summary>
        /// 软删除
        /// </summary>
        /// <typeparam name="TEntity"></typeparam>
        /// <returns></returns>
        private static LambdaExpression GetSoftDeleteFilter<TEntity>() where TEntity : BaseEntity
        {
            Expression<Func<TEntity, bool>> filter = x => !x.Deleted;
            return filter;
        }
    }
}

 3. 如何设置/重置软删除

将“软删除”属性设置为 true 很容易,对应的场景是用户选择一个条目并单击(软)“删除”,这会返回实体的主键。用代码实现如下:

var entity = context.Books.Single(x => x.BookId == id);
entity.Deleted = true;
context.SaveChanges();

重置软删除属性在实际的业务场景中有点复杂。首先,你很可能想要向用户显示一个已删除实体的列表——把它想象成显示某个实体类类型的实例回收站,例如 Book。要做到这一点,需要在你的查询中使用 IgnoreQueryFilters 方法,这意味着你将得到所有的实体(包括那些没有被软删除的和被软删除的),然后再根据需要选出那些 Deleted 属性为 true 的。 

var softDelEntities = _context.Books.IgnoreQueryFilters()
    .Where(x => x.SoftDeleted).ToList();

相应的,当你收到一个重设 Deleted 属性的请求时(它通常包含实体类的主键),则要加载此条目时,需要在查询中使用 IgnoreQueryFilters 方法。 

var entity = context.Books.IgnoreQueryFilters()
     .Single(x => x.BookId == id);
entity.Deleted = false;
context.SaveChanges();

 五、使用软删除注意事项

首先,需要说的是查询过滤器是非常安全的。我的意思是,如果查询过滤器返回 false,那么特定的实体/行将不会包含在查询(包括 Find 和 Include 等)返回的结果集中。你可以使用直接 SQL 绕过它,但除此之外,EF Core 会隐藏你软删除的数据。

但有几点你需要注意:

小心软删除过滤器与其它过滤器的混合使用

查询过滤器非常适合于软删除,但是查询过滤器更适合于控制对数据组的访问。例如,假设您想要构建一个 Web 应用程序来为多个公司提供服务,比如工资单。在这种情况下,你需要确保 A 公司看不到 B 公司的数据,反之亦然。这种类型的系统称为多租户应用程序,而查询过滤器非常适合此类场景。

问题是,每个实体类型只允许使用一个查询过滤器,因此,如果您想在多租户系统中使用软删除,那么您必须将这两个部分结合起来形成查询过滤器——下面是查询过滤器的示例:

modelBuilder.Entity<MyEntity>()
    .HasQueryFilter(x => !x.Deleted
      && x.TenantId == currentTenantId);

这看上去很好,但是当你使用 IgnoreQueryFilters 方法忽略软删除标记进行查询时,它会忽略整个查询过滤器,包括多租户部分。因此,如果不小心,还会显示多租户数据!

答案是为自己构建一个特定于应用程序的 IgnoreSoftDeleteFilter 方法,如下所示:

public static IQueryable<TEntity> IgnoreSoftDeleteFilter<TEntity>(
    this IQueryable<TEntity> baseQuery, string currentTenantId)
    where TEntity : class, ITenantId
{
    return baseQuery.IgnoreQueryFilters()
        .Where(x => x.TenantId == currentTenantId)
}

这将忽略所有筛选器,然后把多租户筛选器添加回去。这将使它更容易更安全地处理显示/重置被软删除的实体。

不要软删除一对一关系的实体类

如果你软删除一个一对一关系,并试图添加一个替换的一对一实体,那么它将失败。这是因为一对一关系有一个唯一的外键,而且这个外键已经被软删除实体设置好了,所以在数据库级别上,你无法提供另一个一对一关系,因为已经存在一个。

一对一关系很少,所以在您的系统中它可能不是问题。但如果您确实需要软删除一对一关系中的实体,那么我建议将其转换为一对多关系,确保只有一个实体关闭了软删除,我将在下一个问题中介绍。

译注:对于大多数一对一场景,当软删除一个实体时,与其一对一关联的实体应当也标记为软删除。

注意多版本数据的软删除

在一些业务案例中,你可以创建一个实体,然后软删除它,然后创建一个新版本。例如,假设您正在为订单 1234 创建发票,然后您被告知订单已经停止,因此你将其软删除(这样您可以保留历史记录)。然后,其他人(不知道软删除版本的人)被告知创建 1234 的发票。现在你有两个版本的发票 1234。这就可能会导致业务上的有问题的发票,特别是当有人重置软删除的数据版本时。

你有以下方式处理这种情况:

  • 将 DateTime 类型的 LastUpdated 属性添加到你的 Invoice 实体类中,使用的是最新的条目,而不是软删除条目。
  • 每个新条目都有一个版本号,因此在我们的示例中,第一个发票的版本号可以是 1234-1,依次为 1234-2。那么,就像 LastUpdated 的版本一样,版本号最高且没有被软删除的发票才是要使用的。
  • 通过使用唯一过滤索引,确保只有一个非软删除版本。这是通过为所有未被软删除的条目创建一个惟一的索引来实现的,这意味着如果你试图重置已被软删除的发票,但那里已经存在一个已被非软删除的发票,那么你将会得到一个异常。但同时,你可以有很多历史软删除版本。Microsoft SQL Server RDBMS, PostgreSQL RDBMS, SQLite RDBMS 都有这个特性(PostgreSQL 和 SQLite 称为部分索引),据说 MySQL 出有类似的东西。下面的代码是 SQL Server 创建唯一过滤索引的示例:
CREATE UNIQUE INDEX UniqueInvoiceNotSoftDeleted
ON [Invoices] (InvoiceNumber)
WHERE Deleted = 0

六、如何处理与软删除关联的实体

到目前为止,我们一直在关注软删除/重置单个实体,但 EF Core 是关于关系的。那么,我应该如何处理那些链接到被软删除的实体类的关系呢?为了帮助我们理解,让我们看看不同业务需求的两种关系的场景示例。

示例

关系示例 1:书籍/评论 (Book/Reviews)

在我编写的书“Entity Framework Core in Action”中,我建立了一个超级简单的图书销售网站,其中包含书,作者,评论等。在这个应用程序中,我可以删除一本书。事实证明,一旦我删除了这本书,就真的没有其他途径可以得到评论了。所以,在这种情况下,我不必担心被软删除的书的评论。

在本书的示例中,我添加了一个后台任务来计算评论的数量。下面是我编写的用于统计评论的代码:

var numReviews = await context.Set<Review>().CountAsync();

当然,无论是否软删除,这都会得到相同的计数,这与硬删除不同(因为硬删除也会删除书的评论)。稍后我将介绍如何解决这个问题。

关系示例 2:公司/报价 (Company/Quotes)

在这个关系示例中,我向许多公司销售产品,每个公司都有一组我们发送给该公司的报价。这是与书籍/评论相同的一对多关系,但是在本例中,我们有一个公司列表和一个单独的报价列表。所以,如果我软删除一个公司,所有与该公司关联的报价附也应该被软删除。

软删除关联实体解决方案

对于刚才描述的两个软删除关系示例,我提出了三个有用的解决方案。

方案 1:什么也不做,因为这无关紧要

有时你软删除的一些东西并不重要,但它的关系仍然可用。如果我软删除一本书,在我添加后台任务来对评论计数之前,我的应用程序一直是工作良好的。

译注:这种情况指的是,当软删除书籍实体类时,其关联的评论数据一般也不会被访问到,或者即使被访问到也无关紧要。

方案 2:使用聚合根方式

在我那本书中的后台评论计数的示例中,我使用了被称为聚合的领域驱动设计(DDD)方法作为解决方案。它表示你可以将一起工作的实体分组,在本例中是 Book、Review 和连接到 Author 表的 BookAuthor。在这样的组中有一个根实体,在本例中是 Book。

正如 Eric Evans 定义 DDD 说的那样,应该始终通过根聚合访问聚合。在 DDD 中这样说是有很多原因的,但在这种情况下,它也解决了我们的软删除问题,因为我们只通过 Book(书籍) 访问 Review(评论) 数据。所以 Book 被软删除时,与它关联的评论计数自然就消失了。因此,可以用下面的代码替换后台任务对 Review 计数:

var numReviews = await context.Books
    .SelectMany(x => x.Reviews).CountAsync();

你还可以通过此方式来查询公司下面的所有报价数据。但是还有另一个方案——模仿数据库级联删除的处理方式,我将在下面介绍。

方案 3:模仿数据库级联删除的方式

数据库有一个称为级联删除的设置,EF Core 有两种删除行为(译注:确切地说有 6 种,这里说两种应该是指其中的与当前所讲内容相关的两种),Cascade 和 ClientCascade。这些行为导致硬删除一行也硬删除任何依赖于该行的数据。例如,在我的图书销售应用程序中,Book 被称为主体实体,而 BookAuthor 链接表则是依赖实体,因为它们依赖于 Book 的主键。因此,如果你硬删除一个 Book 实体,那么所有链接到该实体的 Review 和 BookAuthor 也会被删除。如果那些依赖实体有它们自己的依赖实体,那么它们也会被删除——会依次按层次删除所有依赖实体。

因此,如果我们复制级联删除的依赖实体,将 SoftDeleted 属性设置为 true,那么它将软删除所有依赖实体。这是可行的,但当你想要重置软删除时,它会变得有点复杂,这就要通过下一部分“处理级联软删除与重置”来细说了。

七、处理级联软删除与重置

我决定编写一个能够提供级联软删除解决方案的代码库。当我开始真正开始编写此库时,我发现各种有趣的事情,我必须解决这个问题:当我们重置软删除时,我们希望相关联的实体回到它们原始的软删除状态。结果我发现我有点复杂,让我们用一个示例来探讨我发现的这个问题。

回到我们的 Company/Quotes 的例子,来看看如果我们从 Company 到 Quotes 依次设置其 SoftDeleted 的布尔值会发生什么(提示:它在某些情况下不起作用)。起先假设我们有一个名为 XYZ 的公司,它有两个报价 XYZ-1 和 XYZ-2。然后:

WhatCompanyQuotes
StartingXYZXYZ-1、XYZ-2
Soft delete the quote XYZ-1XYZXYZ-2
Soft delete Company XZ- none -- none -
Reset the soft delete on the company XYZXYZXYZ-1 (wrong!) XYZ-2

这里发生的事情是,当我重置 Company XYZ 时,它也重置了所有的 Quotes,而不是上一个状态(译注:即只有 XYZ-2)。这样我们就需要知道哪些实体需要保留软删除,哪些实体需要被重置软删除,所以一个布尔值来表示状态是不够的,我们需要用一个字节来表示。

我们需要做的是制造一个软删除级别,这个级别告诉你这个软删除设置到了哪些层。使用这个我们可以确定我们是否应该重置软删除。这很复杂,所以我用一个图来表示它是如何工作的。浅色矩形表示被软删除的实体,红色表示发生了变化。

 

这样,你可以处理级联软删除/重置问题了。在代码中有很多小规则,比如,如果一个实体的 SoftDeleteLevel 不是 1,就不能对它的重置,因为一个更高级别的实体软删除了它。

我认为这种级联软删除方法是有用的,我已经创建了一些原型代码来实现到这一点,但还需要更多的完善才会把它变成一个 NuGet 库以便可以在任何系统中使用。如果你对此库感兴趣可以访问 GitHub 地址:

github.com/JonPSmith/EfCore.SoftDeleteServicesicon-default.png?t=M4ADhttps://github.com/JonPSmith/EfCore.SoftDeleteServices

注:这个库是在 EF Core 5 预览版上构建的。

八、总结

我们已经很清楚地看到了 EF Core 软删除所能做的(和不能做的)事情。软删除主要的好处是可以恢复无意删除的数据和保留历史记录。其主要缺点是,软删除过滤器可能会降低查询速度,但可以通过在软删除属性上添加索引来改善性能问题。

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值