以往我都是透過 ValidationContext 來進行模型驗證,在 ASP.NET 的模型綁定,骨子裡面也是使用 ValidationContext,他必須要依賴 Validate Attribute,這次我的需求是要驗證 Dictionary<string, object>,ValidationContext 可能就沒有那麼適合,FluentValidation 是 .NET 生態裡的驗證框架, 這次我打算採用它來實作 Dictionary<string,object> 的驗證。
開發環境
- Windows 11
- .NET 6
- Rider 2022.3.3
條件
- Dictionary 的 Key 是動態的,但我想要限制 Key 的內容,不應該隨便亂輸入,我定義了每一個 key 的名字,用來約束 Key
- Value 也是動態的,可能是單一欄位,也可以是複雜結構
- 複雜結構的內容也要被約束,約束方式跟 Key 一樣
流程
- 第一關驗證 Key,只要輸入的內容在約束的範圍內,第一關的欄位驗證就算是通過
- 第二關就是每一個 Value 裡面的狀態驗證
實作
定義欄位名稱
一開始要先決定程式碼要怎麼被使用, 我想要調用 ProfileValidator.Validate(Dictionary<string, object>) 方法就能完成驗證,代碼如下:
[TestMethod]
public void 通過驗證()
{
var data = new Dictionary<string, object>
{
{ "name", new { firstName = "yao", lastName = "yu", fullName = "yao-chang.yu" } },
{ "birthday", new { year = 2000, month = 2, day = 28 } },
{ "contactEmail", "yao@aa.bb" },
};
var profileValidator = new ProfileValidator();
var validationResult = profileValidator.Validate(data);
Assert.AreEqual(true, validationResult.IsValid);
}
ProfileTypeNames 有很多的欄位,每一個欄位所裝載的內容,有的是單一結構,有的是複雜結構
public class ProfileTypeNames
{
public const string Name = "name";
public const string Gender = "gender";
public const string Birthday = "birthday";
public const string ContactEmail = "contactEmail";
private static readonly Lazy<Dictionary<string, string>> s_fieldNamesLazy =
new(() => ProfileAssistants.GetFieldNames<ProfileTypeNames>());
private static Dictionary<string, string> FieldNames => s_fieldNamesLazy.Value;
public static Dictionary<string, string> GetFieldNames()
{
return FieldNames;
}
}
依序替每一個欄位定義它有哪一些欄位,為了節省篇幅,其餘的定義請參考以下連結
BirthdayTypeNames.cs、GenderTypeValues.cs、NameTypeNames.cs
驗證
這裡我使用 FluentValidation 來實作,為什麼?
- 完整的驗證框架,支援 Method Chain、Attribute、ASP.NET Core DI Conatiner
- 已經實作了許多基本的驗證器,Built-in Validators — FluentValidation documentation
- 輕易的實作屬於自己的驗證器,Custom Validators — FluentValidation documentation
ProfileTypeValidator 根據我的需求實作,有幾個重點
- 在 PreValidate 決定驗證的方式
- 驗證時,檢查字典的 Key 是否有不存在於 FieldNames 的定義
- 驗證時,檢查字典的 Value,裡面的內容是否都符合期望的格式,比如 Email 格式、內容為必填
public class ProfileTypeValidator : AbstractValidator<Dictionary<string, object>>
{
private static readonly Lazy<EmailTypeValidator> s_emailTypeValidatorLazy =
new(() => new EmailTypeValidator(ProfileTypeNames.ContactEmail));
private static readonly Lazy<NameTypeValidator> s_nameTypeValidator =
new Lazy<NameTypeValidator>(() => new NameTypeValidator(ProfileTypeNames.Name));
private static readonly Lazy<BirthdayTypeValidator> s_birthdayTypeValidatorLazy =
new(() => new BirthdayTypeValidator(ProfileTypeNames.Birthday));
private static readonly Lazy<GenderTypeValidator> s_genderTypeValidatorLazy =
new(() => new GenderTypeValidator(ProfileTypeNames.Gender));
private static EmailTypeValidator EmailTypeValidator => s_emailTypeValidatorLazy.Value;
private static NameTypeValidator NameTypeValidator => s_nameTypeValidator.Value;
private static BirthdayTypeValidator BirthdayTypeValidator => s_birthdayTypeValidatorLazy.Value;
private static GenderTypeValidator GenderTypeValidator => s_genderTypeValidatorLazy.Value;
private static bool IsNotSupportFields(ValidationContext<Dictionary<string, object>> context)
{
var instances = context.InstanceToValidate;
var isNotSupports = new List<bool>();
foreach (var item in instances)
{
var fieldName = item.Key;
var fieldValue = item.Value;
switch (fieldName)
{
case ProfileTypeNames.Name:
isNotSupports.Add(IsNotSupportNestFields(NameTypeNames.GetFieldNames(), fieldValue, context));
break;
case ProfileTypeNames.Birthday:
isNotSupports.Add(IsNotSupportNestFields(BirthdayTypeNames.GetFieldNames(), fieldValue, context));
break;
default:
isNotSupports.Add(IsNotSupportFields(ProfileTypeNames.GetFieldNames(), fieldName, context));
break;
}
}
return isNotSupports.Any(p => p);
}
private static bool IsNotSupportFields(Dictionary<string, string> sourceFields,
string destFieldName,
ValidationContext<Dictionary<string, object>> context)
{
var isNotSupport = sourceFields.ContainsKey(destFieldName) == false;
if (isNotSupport)
{
var failure = new ValidationFailure(destFieldName,
$"'{destFieldName}' column not support")
{
ErrorCode = "NotSupportValidator",
};
context.AddFailure(failure);
}
return isNotSupport;
}
private static bool IsNotSupportNestFields(Dictionary<string, string> sourceFields,
object destValue,
ValidationContext<Dictionary<string, object>> context)
{
if (destValue == null)
{
return false;
}
var isNotSupports = new List<bool>();
var propertyInfos = destValue.GetType().GetProperties();
foreach (var propertyInfo in propertyInfos)
{
isNotSupports.Add(IsNotSupportFields(sourceFields, propertyInfo.Name, context));
}
return isNotSupports.Any(p => p);
}
protected override bool PreValidate(ValidationContext<Dictionary<string, object>> context, ValidationResult result)
{
if (IsNotSupportFields(context))
{
return false;
}
var instances = context.InstanceToValidate;
this.SetValidateRule(instances);
return true;
}
private void SetValidateRule(Dictionary<string, object> instances)
{
foreach (var item in instances)
{
var fieldName = item.Key;
var fieldValue = item.Value;
if (fieldValue == null)
{
continue;
}
switch (fieldName)
{
case ProfileTypeNames.ContactEmail:
{
this.RuleFor(p => p[fieldName])
.SetValidator(p => EmailTypeValidator)
;
break;
}
case ProfileTypeNames.Name:
{
this.RuleFor(p => p[fieldName])
.SetValidator(p => NameTypeValidator)
;
break;
}
case ProfileTypeNames.Birthday:
{
this.RuleFor(p => p[fieldName])
.SetValidator(p => BirthdayTypeValidator)
;
break;
}
case ProfileTypeNames.Gender:
{
this.RuleFor(p => p[fieldName])
.SetValidator(p => GenderTypeValidator)
;
break;
}
}
}
}
}
老樣子,為了節省篇幅其餘的驗證請參考以下連結
BirthdayTypeValidator.cs、EmailTypeValidator.cs、GenderTypeValidator.cs、NameTypeValidator.cs
結論
為什麼捨棄強型別的 Model,而使用 Dictionary<string,object>?這一切都是為了要動態處理,而衍生出來的動作,實作起來也相當的簡單,反而花比較多的時間在研究 FluentValidation 的生命週期
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET