Liam W
封面

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

作者
王亮·发表于 3 年前

原文:https://bit.ly/2Cy3J5f
作者:Jon P Smith
翻译:王亮
声明:我翻译技术文章不是逐句翻译的,而是根据我自己的理解来表述的。其中可能会去除一些本人实在不知道如何组织但又不影响理解的句子。

这篇文章是关于如何使用 EF Core 实现软删除的,即表面上删除了数据,但数据并没有被物理删除,在需要的时候你还是可以把它读取出来的。软删除有很多好处,但也有一些值得注意的问题。这篇文章会教你使用 EF Core 实现一般的软删除和复杂的级联软删除。在此过程中,我还会介绍如何编写可重用代码来提高软删除解决方案的开发效率。

我假设你对 EF Core 已经有了一定的认识。但在真正讲软删除实现的方案之前,我们先来了解一下如何使用 EF Core 实现删除和软删除的一些基本知识。

本文是“深入理解 EF Core”系列中的第三篇。以下是本系列文章列表:

概要

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

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

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

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

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

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

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

∮. 我介绍了一种实现级联软删除的方法,它适用于需要软删除其依赖关系的实体。

为什么需要软删除

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

但现在对数据重视度越来越高的环境下,我们更需要“我使它离开了,但我还可以让它再回来”。在 Windows 上,它是回收站;如果你在编辑器中删除了一些文本,你可以用 ctrl-Z 取回它,等等。软删除就是是 EF Core 版本的实体类回收站(实体类是通过 EF Core 映射到数据库的类的术语),它从正常使用中消失了,但是你可以取回它。

我的客户的两个应用程序广泛地使用了软删除。任何“删除”的普通用户确实设置了软删除标志,但一个管理员用户可以重置软删除标志为“取回”用户。事实上,我的一个客户用“删除”来表示软删除,用“销毁”来表示硬删除。保存被软删除的数据的另一个好处是历史记录——即使是被软删除的数据,你也可以看到过去发生了什么变化。大多数客户的软删除数据在数据库中保留一段时间,只在数月甚至数年后才把这些数据备份或真正删除。

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

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

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

为你的应用添加软删除

在本节中,我将逐一介绍在应用程序中添加软删除的如下步骤:

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

在下一节中,我将详细描述这些阶段。我假设一个典型的 EF Core 类具有普通的读/写属性,但是你可以将它适应其他实体类样式,比如域驱动设计(DDD)风格的实体类。

1. 添加软删除属性

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

public class Book : ISoftDelete
{
    public int BookId { get; set; }
    public string Title { get; set; }
    //... 其它无关属性

    public bool SoftDeleted { get; set; }
}

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

我还添加了一个 Book 类的 ISoftDelete 接口(第 1 行),这个接口表示该类必须有一个可以读写的公共 SoftDeleted 属性。这个接口将使得在 DbContext 中配置软删除查询过滤器变得更加容易。

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.SoftDeleted);
    }
}

这很好,但是让我向你展示一种自动添加查询过滤器的方法。

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

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 省略其它无关的代码

    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        // 省略其它无关的代码

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

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

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);
        var filter = methodToCall.Invoke(null, new object[] { });
        entityData.SetQueryFilter((LambdaExpression)filter);
    }

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

我真的很喜欢这个操作,因为它可以节省我的时间,也避免我忘记配置每一个查询过滤器。

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

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

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

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

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

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

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

使用软删除注意事项

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

但有几点你需要注意。

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

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

可以参考我的另一篇关于使用查询过滤器实现数据访问控制的文章 bit.ly/3hg6Ptg

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

modelBuilder.Entity<MyEntity>()
    .HasQueryFilter(x => !x.SoftDeleted
      && 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 SoftDeleted = 0

关于处理因索引问题而出现的异常,请参阅我的文章“Entity Framework Core - validating data and capture SQL error”(地址:bit.ly/3jpRA2W),这篇文章展示了如何将 SQL 异常转换为用户友好的错误表示。

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

到目前为止,我们一直在关注软删除/重置单个实体,但 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.SoftDeleteServices

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

总结

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

根据我的经验,我知道软删除在商业应用程序中非常好用。我也知道也有一些真实的场景会用到级联软删除(正如我客户的一系统)。希望有一天我能有时间去实现一个通用的软删除库。但目前这个库已经有了一个原型版本:

github.com/JonPSmith/EfCore.SoftDeleteServices

如果你认为你会使用一个既能处理简单的软删除又能处理级联软删除的库,那就给此 repo 加个星吧。

祝,编程愉快!