先前在[料理佳餚] 使用 Decorator Pattern 分離參數檢查與資料處理這篇文章裡分享過如何分離參數的條件檢查及資料處理的邏輯,讓程式碼的職責可以更聚焦,不過那篇文章裡面參數檢查條件的範例部分是單純用 if...else... 兜出來的,看起來有點「阿雜」。
有一個 Package 叫 FluentValidation,它可以將單純用 if...else... 兜出來的參數檢查條件,用口語化的方式來表達,讓程式碼可以更貼進人的閱讀習慣。
在之前分享的範例我原本的參數檢查條件長這樣,雖然註解跟要吐出來的訊息很清楚,這樣寫也是一般的寫法,沒有什麼不好,但總覺得應該可以表達得更好。
口語化參數的檢查條件
我就拿這個例子用 FluentValidation 來改寫,從 NuGet 上把 Package 加進專案後,首先要定義一個繼承自 AbstractValidator<T>
的驗證器,我要檢查的對象是 Order,所以就建立一個繼承自 AbstractValidator<Order>
的驗證器。
驗證器中需要建立一個 constructor,裡面就用 RuleFor
方法來描述檢查條件,每個 RuleFor 方法需要指定一個被驗證物件的屬性,接著用方法鏈把檢查條件及驗證不通過時要帶的訊息串接起來。
WithMessage
方法中有一個{PropertyName}
這個特殊的格式,它會自動幫忙在訊息裡面帶出被驗證的 PropertyName。ValidatorOptions.CascadeMode = CascadeMode.StopOnFirstFailure;
是宣告一個 RuleFor 有多個檢查條件時,只要檢查到第一個錯就停止往下檢查。
class OrderValidator : AbstractValidator<Order>
{
public OrderValidator()
{
ValidatorOptions.CascadeMode = CascadeMode.StopOnFirstFailure;
RuleFor(order => order.Id)
.GreaterThan(0).WithMessage("{PropertyName} 必須大於 0。");
RuleFor(order => order.CustomerName)
.NotNull().WithMessage("{PropertyName} 不可以是 null。")
.NotEmpty().WithMessage("{PropertyName} 不可以是空字串。");
RuleFor(order => order.CustomerName)
.Length(0, 30).WithMessage("{PropertyName} 的長度必須小於 30。");
RuleFor(order => order.ProductName)
.NotNull().WithMessage("{PropertyName} 不可以是 null")
.NotEmpty().WithMessage("{PropertyName} 不可以是空字串");
RuleFor(order => order.ProductName)
.Length(0, 20).WithMessage("{PropertyName} 的長度必須小於 20。");
RuleFor(order => order.Amount)
.GreaterThan(0).WithMessage("{PropertyName} 必須大於 0。");
RuleFor(order => order.UnitPrice)
.GreaterThanOrEqualTo(0).WithMessage("{PropertyName} 必須大於等於 0。");
RuleFor(order => order.UnitPrice)
.LessThan(1000).WithMessage("{PropertyName} 必須小於 1000。");
RuleFor(order => order.TotalPrice)
.GreaterThanOrEqualTo(0).WithMessage("{PropertyName} 必須大於等於 0。");
}
}
基本上驗證器的部分這樣就準備完成了,只要在原本的範例中去置換掉那一長串的 if...else... 就可以了。
public class OrderServiceWithValidation : OrderServiceWrapper
{
public OrderServiceWithValidation(IOrderService orderService)
: base(orderService)
{
}
public override OrderCreatingResult AddOrder(Order order)
{
var result = new OrderCreatingResult();
var validator = new OrderValidator();
var validationResult = validator.Validate(order);
if (validationResult.IsValid)
{
result = this.orderService.AddOrder(order);
}
else
{
result.Message = string.Join("\r\n", validationResult.Errors.Select(e => e.ErrorMessage));
}
return result;
}
}
驗證後會回傳一個 ValidationResult
的結果,如果有驗證不通過的部分會被丟在 ValidationResult.Errors
裡面,再從 Errors 把 ErrorMessage 挑出來回傳給呼叫端即可。
驗證失敗
有 4 項條件驗證不通過
自訂 PropertyName
剛剛有講到 WithMessage 裡面有一個 {PropertyName} 會自帶屬性名稱,那如果想要自訂屬性名稱的話只要加上 WithName
這個方法,裡面填入自訂的屬性名稱即可。
RuleFor(order => order.Id)
.GreaterThan(0).WithName("訂單代號").WithMessage("{PropertyName} 必須大於 0。");
自訂驗證方法
FluentValidation 內建提供的驗證條件有以下這些:
- NotNull Validator
- NotEmpty Validator
- NotEqual Validator
- Equal Validator
- Length Validator
- Less Than Validator
- Less Than Or Equal Validator
- Greater Than Validator
- GreaterThan Or Equal Validator
- RegEx Validator
- Email Validator
如果內建的驗證條件不夠用 RuleFor 的方法鏈中還提供了一個 Must
方法,可以讓我們去寫自訂條件,舉例來說 CustomerName 必須要是姓「林」的,就可以這樣寫。
RuleFor(order => order.CustomerName)
.NotNull().WithMessage("{PropertyName} 不可以是 null。")
.NotEmpty().WithMessage("{PropertyName} 不可以是空字串。")
.Must(c => c.StartsWith("林")).WithMessage("{PropertyName} 必須姓'林'。");
另外 AbstractValidator<T> 也提供了一個 Custom
方法,可以讓我們寫更彈性的自訂條件。
public OrderValidator()
{
ValidatorOptions.CascadeMode = CascadeMode.StopOnFirstFailure;
Custom(order =>
{
return order.CustomerName.StartsWith("林") == false
? new ValidationFailure("CustomerName", "CustomerName 必須姓'林'。")
: null;
});
...
}
單元測試
FluentValidation 連單元測試的 Assertion 都幫我們想好了,using 一下 FluentValidation.TestHelper
這個 namespace,裡面就有提供在假設的情況之下應該有錯誤或是不該有錯誤的 Assertion 方法。
[TestMethod]
public void Test_OrderValidator()
{
var validator = new OrderValidator();
var validationResults = validator.Validate(new Order());
// Id = 0 驗證應該要有錯
validator.ShouldHaveValidationErrorFor(order => order.Id, 0);
// Id = 1 驗證不應該有錯
validator.ShouldNotHaveValidationErrorFor(order => order.Id, 1);
}
美中不足
在使用 FluentValidation 的時候我發現了兩個 issues
1. 被驗證的參數只能是型別參數。
我們不太可能做一個 StringValidator 來專門驗證 string,那這樣全天下的 string 都會跑一樣的驗證條件,所以如果被驗證的參數是 string、int、double、bool…等,最好再用一個型別封裝起來。
2. 被驗證的參數本身就是 null。
如果被驗證的參數本身是 null 的時候,FluentValidation 預設會 throw NullReferenceException,如果想要把被驗證的參數本身是否為 null 也列為是一個條件的話,我們可以覆寫 AbstractValidator<T>.Validate
方法,在進入驗證之前先檢查參數是否為 null。
/// <summary>
/// 覆寫 Validate 方法,檢查傳進來的 instance 是否為 null。
/// </summary>
/// <param name="instance"></param>
/// <returns></returns>
public override ValidationResult Validate(Order instance)
{
return instance == null
? new ValidationResult(new[] { new ValidationFailure("Order", "Order cannot be null") })
: base.Validate(instance);
}
參考資料
< Source Code >