愛流浪的小風

技術隨手寫

使用Asp.Net MVC打造Web Api (8) - 使用IsValid欄位取代真正刪除資料

| Comments

昨天介紹了透過EFHook這個好用的Library,它可以幫助我們將一些希望統一在Insert、Update或Delete時執行的動作,透過將DbContext掛載Hook的方式來進行,如此一來我們可以將這些邏輯封裝在獨立的Class中,方便維護也可以讓日後有需要時直接使用。

我們在開發線上網站時,常常會遇到一個問題是要不要讓使用者刪除資料,如果真的讓使用者執行Delete,但資料又沒有備份到的話,萬一使用者抱怨只是電腦Lag或是網路太慢導至不小心刪除,要求我們將資料還原的話,就是一個很讓人頭大的問題,而在這邊讓前端資料看起來像是刪除了,但在資料庫不是真正刪除的方法有很多種,例如搬到歷史資料、設定是否顯示等等,今天我要介紹的就是讓每個Table的Row Data都增加一個欄位IsValid,而當使用者執行刪除時,我們透過Hook來攔截並使用Update IsValid為False取代刪除,日後如果我們需要救回資料也就不會那麼辛苦囉!

大家可以從Github ApiSample - Tag Day07開始練習今天的範例。

實作Hook,在刪除之前攔截它!

  1. 首先我們在Hooks專案定義IsValid欄位的介面IIsValid.cs

    public interface IIsValid
    {
        bool IsValid { get; set; }
    }
    
  2. 讓Tables專案的EntityBase繼承它 (EntityBase是我們所有Table Class繼承的)

    public class EntityBase : ISystemInfo, IIsValid
    {
        public EntityBase()
        {
            this.CreatedAt = DateTime.Now;
            this.UpdatedAt = DateTime.Now;
            this.IsValid = true;
        }
    
        public string CreatedBy { get; set; }
    
        public DateTime CreatedAt { get; set; }
    
        public string UpdatedBy { get; set; }
    
        public DateTime UpdatedAt { get; set; }
    
        public bool IsValid { get; set; }
    }
    

    註: 記得若要讓原本網站可以運作要Update-Database

  3. 在Hooks專案實作ReplaceDeleteByIsValidPreDeleteHook.cs,當執行刪除指令時改變它的行為

    public class ReplaceDeleteByIsValidPreDeleteHook : PreDeleteHook<IIsValid>
    {
        public override void Hook(IIsValid entity, HookEntityMetadata metadata)
        {
            entity.IsValid = false;
    
            metadata.CurrentContext.Entry(entity).State = EntityState.Modified;
        }
    
        public override bool RequiresValidation
        {
            get { return false; }
        }
    }
    
  4. 還記得我們在前一篇文章有在Modules專案中,註冊所有的Hooks,因此我們這邊不需要再做註冊預設就會將所有的Hooks掛載到ShopContext中

這樣就完成了,是不是很簡單呢? 我們透過Hook在執行刪除之前,先將Model的State從刪除改成更新,並且同時對IsValid欄位作變更,因此原本Entity Framework的刪除行為就不會被執行了!

測試取代刪除的功能

為了確保功能的正常運作,以及日後修改時不怕改壞,所以我們接下來要開始撰寫Hook的整合測試,透過測試讓我們的程式碼永遠保持在健康的狀態!

  1. 在Hooks.Test新增刪除資料改為更新IsValid欄位功能.feature.cs,並填寫功能的描述

    #language: zh-TW
    功能: 刪除資料改為更新IsValid欄位功能
        提供給 DA層
        當系統進行Delete時,並不會真的刪除資料,而是以更新IsValid欄位為False取代
        這樣日後想回復資料時,可以方便取回
    
  2. 撰寫Hooks的背景,用來將Hook掛載到ShopContext中

    背景:
        假設 ShopContext刪除時會以更新IsValid為false取代
    
    [Given(@"ShopContext刪除時會以更新IsValid為false取代")]
    public void 假設ShopContext刪除時會以更新IsValid為false取代()
    {
        List<IPreActionHook> hooks = new List<IPreActionHook>();
        hooks.Add(new ReplaceDeleteByIsValidPreDeleteHook());
    
        this.shopContext = new ShopContext(hooks);
    }
    
  3. 撰寫測試案例,驗證刪除資料時,Entity Framework只會將IsValid改為False

    場景: 當執行刪除資料時,以更新IsValid欄位為False取代
        假設 新增分類資料
            | Name   |
            | Fruits |
    當 執行刪除分類Fruits
    那麼 資料庫中包含資料
        | Name   | IsValid |
        | Fruits | false   |
    
    [Given(@"新增分類資料")]
    public void 假設新增分類資料(Table table)
    {
        var categories = table.CreateSet<Category>();
    
        foreach (var category in categories)
        {
            this.shopContext.Categories.Add(category);
        }
    
        this.shopContext.SaveChanges();
    }
    
    [When(@"執行刪除分類(.*)")]
    public void 當執行刪除分類(string name)
    {
        var category = this.shopContext.Categories.Where(i => i.Name == name)
                                                  .First();
    
        this.shopContext.Categories.Remove(category);
    
        this.shopContext.SaveChanges();
    }
    
    [Then(@"資料庫中包含資料")]
    public void 那麼資料庫中包含資料(Table table)
    {
        var categories = this.shopContext.Categories.ToList();
    
        table.CompareToSet(categories);
    }
    
  4. 執行測試,測試成功!

修改原本的Repository,改為只讀取IsValid等於True的欄位

還記得我們在第五天的文章實做了一個讀取分類下有哪些商品的Api嗎? 當時我們搜尋的條件只有判斷上架時間和商品所屬的分類序號,由於今天我們實做了IsValid的欄位功能,因此我們要修改一下程式碼,讓被刪除的資料不會出現在Api之中。

修改ProductRepository的查詢語法

    public IEnumerable<ProductForCategoryModel> GetProductByCategoryId(int categoryId)
    {
        var result = this.ShopContext
                         .Products
                         .Where(i => i.ListingStartTime < DateTime.Now &&
                                     i.ListingEndTime >= DateTime.Now &&
                                     i.Category.Id == categoryId &&
                                     i.IsValid)
                         .Select(i => new ProductForCategoryModel()
                         {
                             Id = i.Id,
                             Name = i.Name,
                             Price = i.Price
                         });

        return result;
    }

測試Repository看看

因為我們改變得我們的查詢語法,所以我們之前針對Repository所撰寫的測試案例就已經不夠滿足我們的使用情境,如果我們可以確保資料來源是正確的,那麼如果當我們系統發生問題時,也就可以更快速的找出問題發生可能的原因,如果每一層的系統邊界和輸出輸入都在我們掌控之中的話,那麼大部分的靈異現象也就不會發生了~

  1. 增加分類商品查詢功能.feature的測試案例,多增加一個分類,並增加視為刪除的商品

    背景:
        假設 資料庫中有分類資料
            | Id | Name   |
            | 1  | Foods  |
            | 2  | Drinks |
            | 3  | Fruits |
        並且 資料庫中有產品資料
            | CategoryId | Name         | Price | Cost | ListingStartTime | ListingEndTime | SellingStartTime | SellingEndTime | IsValid |
            | 1          | Hamburger    | 99    | 50   | 2013-10-01       | 2014-10-01     | 2013-10-01       | 2014-10-01     | true    |
            | 1          | Sandwitch    | 89    | 40   | 2013-10-01       | 2014-10-01     | 2013-10-01       | 2014-10-01     | true    |
            | 2          | Orange Juice | 40    | 20   | 2013-10-01       | 2014-11-01     | 2013-10-01       | 2014-10-01     | true    |
            | 2          | Milk         | 35    | 20   | 2013-11-01       | 2014-11-01     | 2013-11-01       | 2014-10-01     | true    |
            | 3          | Watermelon   | 50    | 25   | 2013-10-01       | 2014-11-01     | 2013-11-01       | 2014-10-01     | true    |
            | 3          | Banana       | 50    | 25   | 2013-10-01       | 2014-11-01     | 2013-11-01       | 2014-10-01     | false   |
    
  2. 增加測試案例,測試Repository是否只有讀取到IsValid=true的商品

    場景: 根據分類序號查詢商品,過濾IsValid為false(代表刪除)的商品,只查詢服和的商品
        假設 當查詢分類3的商品時
        當 執行分類商品查詢
        那麼 得到商品
            | Name         | Price |
            | Watermelon   | 50    |
    

    註: 你有發現到現在我們增加測試案例時所需要寫的程式碼越來越少了嗎? 這要歸功於Specflow的自動綁定以及讀取資料功能,它讓我們可以用表單或關鍵字的方式帶入參數到測試程式,不但讓測試程式變得更容易閱讀,也讓測試案例的維護更加的人性化!

  3. 執行測試,測試成功!

增加Extension Method,讓過濾變得更簡單

在上面的例子中,我們直接在Repository的Where條件加入IsValid來判斷,但如果Table數量多時,反而容易影響我們閱讀程式的Where條件,因此我們可以透過Extension的方式,讓這個判斷可以不用加在Where條件之中,在前面就處理掉

  1. 在Hooks增加IsValidExtension.cs

    public static class IsValidExtensions
    {
        public static IQueryable<T> Valids<T>(this IDbSet<T> dbSet)
            where T : class, IIsValid
        {
            return dbSet.Where(i => i.IsValid);
        }
    }
    
  2. 如此一來,我們在ProductRepository的方法可以改寫為

    public IEnumerable<ProductForCategoryModel> GetProductByCategoryId(int categoryId)
    {
        var result = this.ShopContext
                         .Products
                         .Valids()
                         .Where(i => i.ListingStartTime < DateTime.Now &&
                                     i.ListingEndTime >= DateTime.Now &&
                                     i.Category.Id == categoryId)
                         .Select(i => new ProductForCategoryModel()
                         {
                             Id = i.Id,
                             Name = i.Name,
                             Price = i.Price
                         });
    
        return result;
    }
    
  3. 這樣就算有多個Table,我們也只要記得在每個Table後面加上Valids方法即可

  4. 重新執行測試,發現測試成功的通過了!

本日小結

今天我們又實做了一個在開發網站時很常見的功能,而且你會發現我們所做出來的功能都是可以隨時抽換移除的,若是我們希望取消某一個Hooks的功能,也只要透過修改Modules或是設定Autofac的Config就可以輕鬆搞囉!

我們在開發系統通用功能的時候要盡量保持一個原則,不要破壞原本測試碼的易讀性,使用非侵入的做法來增加程式碼功能 (例如少用繼承),合理的增加一些Extension來讓程式碼更容易維護,若是為了實現方便的功能,反而增加了開發者的開發門檻的話就得不償失囉!關於今天的問題歡迎大家一起討論喔^_^

Comments

comments powered by Disqus