深刻理解 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 加個星吧。

祝,編程愉快!

相關文章
相關標籤/搜索