愛流浪的小風

技術隨手寫

使用Asp.Net MVC打造Web Api (11) - 使用FluentValidation進行驗證

| Comments

其實在Asp.Net MVC中原本就已經內建了非常好用的驗證機制,它可以透過在Model的屬性加上DataAnnotation的方式,來設定驗證Model的條件,更可以一次套用到Server端和Client端的驗證上,讓開發更加的方便迅速,此外也提供了豐富的擴充彈性,可以將自己常用的驗證方式透過撰寫CustomValidator來讓驗證的套用更加方便。

既然原生得那麼好用,那為什麼今天還要向大家介紹另外一套Validation Library呢?主要是希望可以更進一步的將驗證的邏輯和Model分離(ex.Model在BE專案,驗證在BL專案),讓驗證邏輯和Model之間的耦合性更低,又由於我們的系統已經整合了DI Framework,所以希望可以透過DI Framework來控制Model和Validator之間的關聯,甚至可以動態更換Validator等等,而FluentValidation也很好的符合了我們的需求,也提供給大家另外一個做驗證的選擇。

大家可以從Github ApiSample - Tag Day10開始練習今天的程式

開始撰寫簡單的驗證

FluentValidation的驗證不同於Asp.Net MVC的方式,它會獨立建立一個Validator,將驗證邏輯寫在Validator裡面,而需要驗證時再使用Validator來驗證。接下來我們就要來撰寫InsertProductModel的驗證程式,InsertProductModel在輸入時必須要符合的條件是

* Name - 不得為空,長度需在1~100之間
* Price - 必須大於0,以及大於Cost
* Cost - 必須大於0,以及小於Price
* Introduction - 長度必須小於1000
* StartSellAt - 不得為空,必須早於FinishSellAt
* FinishSellAt - 不得為空,必須晚於FinishSellAt
* StartListingAt - 不得為空,必須早於StartSellAt
* FinishListingAt - 不得為空,必須晚於FinishSellAt
* CategoryId - 不得為空,資料庫必須存在該Category
  1. 在BL建立Validators,用來撰寫Model相關的驗證
  2. 使用Nuget加入FluentValidation函式庫

  3. 建立Category Repository,用來查詢Category是否存在

    public interface ICategoryRepository
    {
        bool IsCategoryExist(int categoryId);
    }
    
    public class CategoryRepository : ICategoryRepository
    {
        public ShopContext ShopContext { get; set; }
    
        public CategoryRepository(ShopContext context)
        {
            this.ShopContext = context;
        }
    
        public bool IsCategoryExist(int categoryId)
        {
            var isExist = this.ShopContext.Categories.Valids()
                                                     .Any(i => i.Id == categoryId);
    
            return isExist;
        }
    }
    
  4. 建立CategoryService

    public interface ICategoryService
    {
        bool IsCategoryExist(int categoryId);
    }
    
    public class CategoryService : ICategoryService
    {
        public ICategoryRepository CategoryRepository { get; set; }
    
        public CategoryService(ICategoryRepository categoryRepository)
        {
            this.CategoryRepository = categoryRepository;
        }
    
        public bool IsCategoryExist(int categoryId)
        {
            return this.CategoryRepository.IsCategoryExist(categoryId);
        }
    }    
    
  5. 建立InsertProductModelValidator,在建構式中撰寫驗證邏輯

    public class InsertProductModelValidator : AbstractValidator<InsertProductModel>
    {
        public ICategoryService CategoryService { get; set; }        
    
        public InsertProductModelValidator(ICategoryService categoryService)
        {
            this.CategoryService = categoryService;
    
            RuleFor(i => i.Name).NotEmpty()
                                .Length(1, 100);
    
            RuleFor(i => i.Price).GreaterThan(0)
                                 .GreaterThan(i => i.Cost);
    
            RuleFor(i => i.Cost).GreaterThan(0)
                                .LessThan(i => i.Price);                              
    
            RuleFor(i => i.Introduction).Length(0, 1000);
    
            RuleFor(i => i.StartSellAt).NotEmpty()
                                       .LessThan(i => i.FinishSellAt);
    
            RuleFor(i => i.FinishSellAt).NotEmpty()
                                        .GreaterThan(i => i.StartSellAt);
    
            RuleFor(i => i.StartListingAt).NotEmpty()
                                          .GreaterThanOrEqualTo(i => i.StartSellAt);
    
            RuleFor(i => i.FinishListingAt).NotEmpty()
                                           .LessThanOrEqualTo(i => i.FinishSellAt);
    
            RuleFor(i => i.CategoryId).NotEmpty()
                                      .Must(i => this.CategoryService.IsCategoryExist(i))
                                      .WithMessage("Category must exist!");
    
        }
    }
    

這樣我們就完成驗證邏輯的撰寫了,如果想要使用Validator也很簡單,可以參考下面的程式碼

InsertProductModel product = new InsertProductModel();
InsertProductModelValidator validator = new InsertProductModelValidator();

ValidationResult results = validator.Validate(product);

if(! results.IsValid) 
{
    foreach(var failure in results.Errors) 
    {
        Console.WriteLine("Property " + failure.PropertyName + " failed     alidation. Error was: " + failure.ErrorMessage);
    }
}

FluentValidation提供的驗證方法

從上面的範例,我們可以看到FluentValidation提供的驗證撰寫方式也是非常直覺的,它透過在Validator中以條列式的方式,一個一個屬性的撰寫測試邏輯,不但寫起來簡單,要核對檢查條件是否完整時也相當輕鬆。

FluentValidation內建基本提供的驗證如下

除此之外,我們同樣的也可以擴充自己的Validator

public class NameMustStartWithAValidator : PropertyValidator {

   public NameMustStartWithAValidator() 
        : base("{PropertyName} must start with A") {    
   }

   protected override bool IsValid(PropertyValidatorContext context) {
       var name = context.PropertyValue.toString();

       if(name.StartWith("A")) {
           return true;
       }

       return false;
    }
}

使用上也很簡單

RuleFor(i => i.Name).SetValidator(new NameMustStartWithAValidator());

而如果想要自己定義錯誤訊息的話,也可以在設定完驗證規則後,使用WithMessage指定該驗證條件的錯誤訊息!

RuleFor(i => i.CategoryId).NotEmpty()
                          .Must(i => this.CategoryService.IsCategoryExist(i))
                          .WithMessage("Category must exist!");

延伸閱讀:

測試驗證邏輯

相信經過上面的Sample Code以及簡單介紹之後,大家應該對於如何使用FluentValidation有了初步的認識,接下來我們就要來撰寫驗證的測試程式,來確保我們寫的驗證邏輯是沒有問題的!

  1. 在BL新增Validators.Test測試專案,並使用Nuget加入Specflow、Specrun和Rhino Mock
  2. 新增InsertProductModel驗證功能.feature

    #language: zh-TW
    功能: InsertProductModel驗證功能
        提供給 BL層
        使用者輸入InsertProductModel資料,驗證資料是否正確
    
  3. 撰寫測試案例

    場景: 輸入資料正確,驗證成功
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證成功
    
    場景: 輸入姓名為空,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            |      | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入姓名長度超過100,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            |      | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        並且 姓名長度超過100
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入價格為0,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 0   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入價格小於成本,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 99    | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入成本為0,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 0    |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入成本大於售價,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 300  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入介紹超過1000,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        並且 介紹長度超過1000
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入開賣時間為空,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      |             | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入開賣時間晚於開賣結束時間,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2015-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入開賣結束時間為空,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  |              | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入開賣結束時間早於開賣時間,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2012-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入上架時間為空,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              |                | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入上架時間晚於開賣時間,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2015-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入下架時間為空,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     |                 | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入下架時間早於開賣結束時間,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2012-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入分類資料為空,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 0          |
        假設 資料庫存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
    場景: 輸入分類不存在資料庫中,驗證失敗
        假設 使用者輸入InsertProductModel資料
            | Name | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId |
            | Test | 200   | 100  |              | 2013-10-01     | 2014-10-01      | 2013-10-01  | 2014-10-01   | 1          |
        假設 資料庫不存在分類序號1
        當 執行InsertProductModel驗證
        那麼 驗證失敗
    
  4. 撰寫測試程式

    private InsertProductModel model;
    
    private ICategoryService service;
    
    private ValidationResult result;
    
    [Given(@"使用者輸入InsertProductModel資料")]
    public void 假設使用者輸入InsertProductModel資料(Table table)
    {
        this.model = table.CreateInstance<InsertProductModel>();
    }
    
    [Given(@"資料庫存在分類序號(.*)")]
    public void 假設資料庫存在分類序號(int categoryId)
    {
        this.service = MockRepository.GenerateStub<ICategoryService>();
        this.service.Stub(i => i.IsCategoryExist(Arg<int>.Is.Equal(categoryId)))
                    .Return(true);
    }
    
    [Given(@"資料庫不存在分類序號(.*)")]
    public void 假設資料庫不存在分類序號(int categoryId)
    {
        this.service = MockRepository.GenerateStub<ICategoryService>();
        this.service.Stub(i => i.IsCategoryExist(Arg<int>.Is.Equal(categoryId)))
                    .Return(false);
    }
    
    [Given(@"姓名長度超過(.*)")]
    public void 假設姓名長度超過(int length)
    {
        for (int i = 0; i <= length; i++)
        {
            this.model.Name += "A";
        }
    }
    
    [Given(@"介紹長度超過(.*)")]
    public void 假設介紹長度超過(int length)
    {
        for (int i = 0; i <= length; i++)
        {
            this.model.Introduction+= "A";
        }
    }
    
    [When(@"執行InsertProductModel驗證")]
    public void 當執行InsertProductModel驗證()
    {
        InsertProductModelValidator validator = new InsertProductModelValidator(this.service);
        this.result = validator.Validate(this.model);
    }
    
    [Then(@"驗證成功")]
    public void 那麼驗證成功()
    {
        Assert.IsTrue(this.result.IsValid);
    }
    
    [Then(@"驗證失敗")]
    public void 那麼驗證失敗()
    {
        Assert.IsFalse(this.result.IsValid);
    }
    
  5. 執行測試,但測試失敗!?

  6. 我們可以發現,原來上架時間要早於開賣時間,和下架時間要晚於開賣結束時間的邏輯寫錯了,修正為

    RuleFor(i => i.StartListingAt).NotEmpty()
                                  .LessThanOrEqualTo(i => i.StartSellAt);
    
    RuleFor(i => i.FinishListingAt).NotEmpty()
                                   .GreaterThanOrEqualTo(i => i.FinishSellAt);
    
  7. 再次執行測試,發現測試成功

本日小結

今天的介紹中,我們初步的認識FluentValidation,實際撰寫了驗證的邏輯,並且還可以透過測試程式碼來檢查我們的驗證邏輯有沒有錯誤,發現錯誤後也可以快速的修正,並再跑一次測試來複測,就不需要再擔心是否有在修Bug時改壞了原本好的功能。

接下來在明天將要向大家介紹如何將FluentValidation整合到Asp.Net MVC之中,關於今天的內容歡迎大家一起討論喔^_^

Comments

comments powered by Disqus