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

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

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

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

本文是“深刻理解 EF Core”系列中的第三篇。如下是本系列文章列表:数据库

概要

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

∮. 在应用程序中使用软删除的主要好处是能够恢复无心的删除和保留历史记录。安全

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

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

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

∮. 不要软删除一对一的实体类,由于它会致使问题。性能

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

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

为何须要软删除

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

但如今对数据重视度愈来愈高的环境下,咱们更须要“我使它离开了,但我还可让它再回来”。在 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。而后:

What Company Quotes
Starting XYZ XYZ-一、XYZ-2
Soft delete the quote XYZ-1 XYZ XYZ-2
Soft delete Company XZ - none - - none -
Reset the soft delete on the company XYZ XYZ XYZ-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 加个星吧。

祝,编程愉快!

相关文章
相关标签/搜索