深入理解 EF Core:使用查詢過濾器實現資料軟刪除

精緻碼農發表於2020-07-22

原文: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。然後:

What Company Quotes
Starting XYZ XYZ-1、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 加個星吧。

祝,程式設計愉快!

相關文章