0%

[DotnetCore]後端驗證神器:Fluent Validation

前提情要

現在開發網頁幾乎都切換成前後端分離的開發方式,前端採用 Angular, Vue, React三大框架之一,後端則只要撰寫API服務即可,一來職責分離,前端適合處理畫面的互動效果,後端搭配資料庫處理商業邏輯的運算,使用json格式傳送結果至前端,多完美的協作阿。

通常簡單驗證邏輯會放在前端做處理,畢竟不用花費傳送成本,例如必填、長度等簡單邏輯驗證,直接在前端實作即可,但畢竟後端需要將資料儲存至資料庫,若沒有再做進一步驗證則資料儲存失敗的風險,以及日後資料運算使用時的困難。又或者系統本身需要透過http與其他系統介接,其他系統直接打我方API,這種情境就不需多說,需要在後端做驗證,連必填、長度等簡單邏輯驗證也必須做在後端。

內容

基於各種理由,我們必須在後端實作資料驗證這個部份,筆者心中想法是這種邏輯驗證必須做在進到action之前,需於Model Binding時期,若驗證不過則由統一格式回傳,既不佔用到運算資源,類似像Dotnet Core中的Middleware設計,責任界線劃分清楚,日後維護或擴充起來會是好的體驗。

跟著筆者一步一步做吧,大概會分為這幾步驟

  • 安裝相關Nuget套件
  • 實作Validator
  • 於StartUp載入使用
  • 設定驗證不過則統一格式回復

安裝相關Nuget套件-FluentValidation

依照專案的角色不同,安裝不同套件,若設定Validator的專案則是安裝 FluentValidation,若最終使用端API專案則需要使用到FluentValidation.AspNetCore,跟著筆者安裝吧

1
2
dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore

實作Validator

主要是繼承AbstractValidator,於Constructor宣告驗證的Rule,直接來看Code吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 驗證類別
public class MyApiRegisterRequestModel
{
/// <summary>
/// 身份證字號/居留證號
/// </summary>
public string Idn { get; set; }
// 以下省略
}
// AbstractValidator角括號中宣告需驗證的類別
public class MyApiRequestModelValidator : AbstractValidator<MyApiRegisterRequestModel>
{
public SKBApiRequestModelValidator()
{
RuleFor(x => x.Idn).NotEmpty()
.WithMessage("身分證或居留證卡號不得為空");
// 以下省略
}
}

主要有哪些驗證邏輯,參考官網會有諸多範例可參考,筆者不在這邊多加贅述,直接參考官網說明即可。

Built-in Validators - FluentValidation documentation

Startup載入宣告

前提需要安裝Nuget套件: FluentValidation.AspNetCore,筆者這邊的作法是會把View Model會獨立於一個專案中宣告,然後相對應的Validators也宣告於此專案,所以筆者利用套件提供的FromAssembly的方式來做註冊,直接看Code吧。

1
2
3
4
5
services.AddControllers()
.AddFluentValidation(opt =>
{
opt.RegisterValidatorsFromAssembly(AppDomain.CurrentDomain.Load("ModelsProjName"));
});

設定驗證不過則統一格式回復

配合上一個步驟,只要宣告AddFluentValidation則FluentValidation會在Model Binding時期就會做驗證並將ModelState改變,會去改變ModelState.IsValid的值,所以我們只要Configure ApiBehaviorOptions中的InvalidModelStateResponseFactory做設定,直接回傳結果。

筆者這邊有定義統一回傳格式,依照各位的需求,可以再調整其格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/// <summary>
/// Weak type of response result.
/// </summary>
[XmlRoot(ElementName = "result")]
public class Result
{
/// <summary>
/// Response status (default is fail)
/// </summary>
[XmlElement(ElementName = "status")]
public ResponseStatus Status { get; set; } = ResponseStatus.Failure;

/// <summary>
/// Messages to be showed in response
/// </summary>
[XmlElement(ElementName = "message")]
public string Message { get; set; } = string.Empty;

public override string ToString()
{
return JsonConvert.SerializeObject(this, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
});
}
}

/// <summary>
/// Generic version of response result.
/// </summary>
/// <typeparam name="T"></typeparam>
public class Result<T> : Result
{
/// <summary>
/// T is always object (多筆的 Pager 資訊是包在這個 Payload 物件裡面)
/// </summary>
public T ResultObj { get; set; }
}

/// <summary>
/// 預設Result
/// </summary>
public static class ResultExtension
{
/// <summary>
/// 返回成功 (無文字訊息)
/// </summary>
/// <param name="helper"> <see cref="Result" /> </param>
/// <returns></returns>
public static Result Success(this Result helper)
{
return helper.Success("");
}

/// <summary>
/// 返回成功
/// </summary>
/// <param name="helper"> <see cref="Result" /> </param>
/// <param name="message"> 提示訊息 </param>
/// <returns></returns>
public static Result Success(this Result helper, string message)
{
helper.Status = ResponseStatus.Success;
helper.Message = message;
return helper;
}

/// <summary>
/// 返回失敗
/// </summary>
/// <param name="helper"> <see cref="Result" /> </param>
/// <param name="resultMsg"> 提示/錯誤訊息 </param>
/// <returns></returns>
public static Result Fail(this Result helper, string resultMsg)
{
helper.Status = ResponseStatus.Failure;
helper.Message = resultMsg;
return helper;
}

/// <summary>
/// 返回失敗-錯誤訊息=查無資料
/// </summary>
/// <param name="helper"> <see cref="Result" /> </param>
/// <returns></returns>
public static Result DbNotFound(this Result helper)
{
helper.Status = ResponseStatus.NotFound;
helper.Message = "查無資料";
return helper;
}

public static Result DbException(this Result helper)
{
helper.Status = ResponseStatus.DatabaseError;
helper.Message = "資料庫異常";
return helper;
}

}

就在Startup設定統一回傳固定的Result,將錯誤訊息打包到一個ResultObj中,型別為List<string>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = (context) =>
{
var errors = context.ModelState
.Values
.SelectMany(x => x.Errors
.Select(p => p.ErrorMessage))
.ToList();

var result = new Result<List<string>>()
{
ResultObj = errors
};
result.Fail(errors.FirstOrDefault());

return new OkObjectResult(result);
};
});

跟著筆者來看一下效果吧,就使用Postman來試打一下API,故意傳空值,效果如下:

複雜物件類別驗證設定

實際專案往往會遇到不是單純的單層結構,一次接主要資料含明細資料,跟著筆者一起來研究一下遇到這種比較複雜類型要怎麼設定吧,筆者也是跟著官網教學做,因此就follow官網提供的範例了,這邊主要分為明細為單筆或明細為多筆,依照這兩種狀況不同而設定方式有所不同。

單筆明細

先來看看主要及明細的j物件類別、屬性:

1
2
3
4
5
6
7
8
9
10
11
12
public class Customer {
public string Name { get; set; }
public Address Address { get; set; }
}

public class Address {
public string Line1 { get; set; }
public string Line2 { get; set; }
public string Town { get; set; }
public string County { get; set; }
public string Postcode { get; set; }
}

針對Address新增一個Validator:

1
2
3
4
5
6
public class AddressValidator : AbstractValidator<Address> {
public AddressValidator() {
RuleFor(address => address.Postcode).NotNull();
//etc
}
}

接下來於Customer新增一個Validator,並宣告針對Address屬性的驗證類別:

1
2
3
4
5
6
public class CustomerValidator : AbstractValidator<Customer> {
public CustomerValidator() {
RuleFor(customer => customer.Name).NotNull();
RuleFor(customer => customer.Address).SetValidator(new AddressValidator());
}
}

多筆明細

筆者這邊就拿官網上的Customer與Order的例子來說明,一個客戶雍有多筆訂單,這個時候需要使用到RuleForEach,一般單個屬性設定為RuleFor,先宣告對應的物件類別:

1
2
3
4
5
6
7
public class Customer {
public List<Order> Orders { get; set; } = new List<Order>();
}

public class Order {
public double Total { get; set; }
}

接下來要針對Order先宣告其Validator:

1
2
3
4
5
public class OrderValidator : AbstractValidator<Order> {
public OrderValidator() {
RuleFor(x => x.Total).GreaterThan(0);
}
}

最後針對Customer設定其Validator時使用RuleForEach設定其擁有的Order屬性使用OrderValidator:

1
2
3
4
5
public class CustomerValidator : AbstractValidator<Customer> {
public CustomerValidator() {
RuleForEach(x => x.Orders).SetValidator(new OrderValidator());
}
}

Custom Validator-進階邏輯驗證

筆者會用到的另一個情境是,有些識別序號欄位就錯了,其實根本不需要進到邏輯層去處理相關邏輯運算,這種牽扯自身邏輯相關的必須要實作Custom Validator來完成。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ChargeListValidator : AbstractValidator<List<ChargeRequestModel>>
{
// 依照各位環境中資料庫連接方式實作,筆者這邊是注入DBContext物件,使用EF Core實作資料庫串接
private readonly MyDBContext _db;
// 參考筆者另一篇使用EF Core的文章,筆者這邊使用Constructor注入方式注入
public ChargeListValidator(MyDBContext db)
{
_db = db;
RuleFor(x => x)
.Must(ValidateCaseNo).WithMessage("契約編號有誤,請重新輸入")
.DependentRules(() =>
{
RuleFor(x => x)
.Must(ValidateTicketNo).WithMessage("票券編號有誤,請重新輸入");
});
RuleForEach(x => x).SetValidator(new ChargeValidator());
}

// Custom Validator
private bool ValidateCaseNo(List<ChargeRequestModel> chargeRequestModelList
, List<ChargeRequestModel> targetModels)
{
var distinctCaseNoList = targetModels.Select(x => x.CaseNo).Distinct().ToList();
var existCount = _db.AuthMainDts.Count(x => distinctCaseNoList.Contains(x.CaseNo));
return distinctCaseNoList.Count == existCount;
}

private bool ValidateTicketNo(List<ChargeRequestModel> chargeRequestModelList
, List<ChargeRequestModel> targetModels)
{
var existCount = _db.Tkdetails.Count(x => targetModels.Select(t => t.TicketNo).ToList().Contains(x.Tkno));
return targetModels.Count == existCount;
}
}

以上,筆者需要解說一下,由註解可以看到,我這邊舉的例子使用EF Core操作資料庫,把相對應的檢查邏輯寫在Custom Validator

結論

筆者在ASP.NET MVC 5時期就開始使用Fluent Validation,覺得很靈活且方便的設定及使用,內建Validation就已足夠應付一般驗證需求,基本上也可以完全走自定義的Validator,換到ASP.NET CORE,將使用及設定方式更為彈性,尤其搭配ApiBehaviorOption,可以完美地統一處理Response,是一件值得花時間研究一下的套件。

參考