[.NET 6] 通過 FluentValidation 驗證 Dictionary<string, object> 資料內容

以往我都是透過 ValidationContext 來進行模型驗證,在 ASP.NET 的模型綁定,骨子裡面也是使用 ValidationContext,他必須要依賴 Validate Attribute,這次我的需求是要驗證  Dictionary<string, object>,ValidationContext 可能就沒有那麼適合,FluentValidation 是 .NET 生態裡的驗證框架, 這次我打算採用它來實作 Dictionary<string,object> 的驗證。

開發環境

  • Windows 11
  • .NET 6
  • Rider 2022.3.3

條件

  1. Dictionary 的 Key 是動態的,但我想要限制 Key 的內容,不應該隨便亂輸入,我定義了每一個 key 的名字,用來約束 Key
  2. Value 也是動態的,可能是單一欄位,也可以是複雜結構
  3. 複雜結構的內容也要被約束,約束方式跟 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.csGenderTypeValues.csNameTypeNames.cs

 

驗證

這裡我使用 FluentValidation 來實作,為什麼?

  1. 完整的驗證框架,支援 Method Chain、Attribute、ASP.NET Core DI Conatiner
  2. 已經實作了許多基本的驗證器,Built-in Validators — FluentValidation documentation
  3. 輕易的實作屬於自己的驗證器,Custom Validators — FluentValidation documentation

ProfileTypeValidator 根據我的需求實作,有幾個重點

  1. 在 PreValidate 決定驗證的方式
  2. 驗證時,檢查字典的 Key 是否有不存在於 FieldNames 的定義
  3. 驗證時,檢查字典的 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.csEmailTypeValidator.csGenderTypeValidator.csNameTypeValidator.cs

結論

為什麼捨棄強型別的 Model,而使用 Dictionary<string,object>?這一切都是為了要動態處理,而衍生出來的動作,實作起來也相當的簡單,反而花比較多的時間在研究 FluentValidation 的生命週期

範例位置

sample.dotblog/ModelValidation/Lab.DictionaryValidation at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo