[Validation] 自訂模型驗證

利用 ValidationAttribute 把資料驗證的邏輯抽離應用程式,以提升程式的維護,這裡分享一下我常用的做法

ValidationAttribute

.NET 提供了許多的ValidationAttribute,在 DTO 的欄位標上 Attribute 就可以讓 進行驗證,比如常見的以下

  • 必填:[Required]
  • 字串長度:[StringLength(5)]
  • 最小字串長度:[MinLength(2)]
  • 最大字串長度:[MaxLength(2)]
  • 數值範圍:[Range(18, 100)]
  • Email格式:[EmailAddress]
public class Contact
{
	[Required]
	public int Id { get; set; }

	[Required(AllowEmptyStrings = false, ErrorMessage = "First name is required")]
	public string FirstName { get; set; }

	public string LastName { get; set; }

	[DataType(DataType.DateTime)]
	public DateTime Birthday { get; set; }

	[EmailAddress]
	public string EMail { get; set; }
}

驗證模型

在 System.ComponentModel.DataAnnotations NameSpace 的 Validator 靜態類別用來檢查資料內容,他會處理上述的 Attribute,這裡列出了我常用的兩個方法

TryValidateObject

驗證失敗回傳false並得到錯誤訊息

範例如下:

[TestMethod]
public void TryValidateObject_Test()
{
    var contact = new Contact
    {
        FirstName = "Armin",
        LastName = "Zia",
        EMail = "mail"
    };
    var context = new ValidationContext(contact, nullnull);
    var errors = new List<ValidationResult>();
    if (!Validator.TryValidateObject(contact, context, errors, true))
    {
        Assert.AreEqual(1, errors.Count);
    }
}

 

ValidateObject

驗證失敗拋出ValidationException

範例如下:

[TestMethod]
public void ValidateObject_Test()
{
    var contact = new Contact
    {
        FirstName = "Armin",
        LastName = "Zia",
        EMail = "mail"
    };
    var context = new ValidationContext(contact, nullnull);
    Action action = () => Validator.ValidateObject(contact, context, true);
    action.Should().Throw<ValidationException>();

 

自訂驗證

透過.NET提供的Atturibute就能完成一定的驗證,不過往往我們還是需要進階的驗證方式

 

IValidatableObject

自我驗證自訂驗證如果內建的 Attribute 還不夠用的話,實作IValidatableObject,然後在 Validate 加上驗證邏輯,這樣也可以讓 Validator 運作,範例如下:

private class Contact : IValidatableObject
{
    [Required]
    public int Id { getset}
 
    [Required(AllowEmptyStrings = false, ErrorMessage = "First name is required")]
    public string FirstName { getset}
 
    public string LastName { getset}
 
    public DateTime Birthday { getset}
 
    public string EMail { getset}
 
    public DateTime StartDate { get} = DateTime.Parse("3000-01-01");
 
    public DateTime EndDate { getset}
 
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        int result = DateTime.Compare(this.StartDate, this.EndDate);
        if (result >= 0)
        {
            yield return new ValidationResult("start date must be less than the end date!",
                                              new[] {"ConfirmEmail"});
        }
    }
}

 

自訂 ValidationAttribute

內建的 ValidationAttribute 無法滿足時,可以自訂自己的 Validation Attribute

下面範例用來比不同的欄位的內容

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class GreaterThanAttribute : ValidationAttribute
{
    private readonly string _targetName;
 
    public GreaterThanAttribute(string targetFieldName)
    {
        this._targetName = targetFieldName;
    }
 
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        this.ErrorMessage = this.ErrorMessageString;
        var sourceType = value.GetType();
        var sourceName = validationContext.MemberName;
 
        if (sourceType == typeof(IComparable))
        {
            throw new ArgumentException("value has not implemented IComparable interface");
        }
 
        var sourceValue = (IComparable) value;
        var comparisonPropertyInfo = validationContext.ObjectType.GetProperty(this._targetName);
        if (comparisonPropertyInfo == null)
        {
            throw new ArgumentException("Comparison property with this name not found");
        }
 
        var targetValue = comparisonPropertyInfo.GetValue(validationContext.ObjectInstance);
        var targetType = targetValue.GetType();
        if (targetType == typeof(IComparable))
        {
            throw new ArgumentException("Comparison property has not implemented IComparable interface");
        }
 
        if (!ReferenceEquals(sourceType, targetType))
        {
            throw new ArgumentException("The properties types must be the same");
        }
 
        if (sourceValue.CompareTo((IComparable) targetValue) < 0)
        {
            this.ErrorMessage = $"{this._targetName} property must be less than the {sourceName} property";
            return new ValidationResult(this.ErrorMessage, new[] {this._targetName, sourceName});
        }
 
        return ValidationResult.Success;
    }
} 

 

使用方式

private class Contact2
{
    [Required]
    public int Id { getset}
 
    public DateTime StartDate { getset}
 
    [GreaterThan("StartDate")]
    public DateTime EndDate { getset}
}

 

當 EndDate 小於 StartDate 時,驗證就失敗

[TestMethod]
public void GreaterThan_Test()
{
    var contact = new Contact2
    {
        StartDate = DateTime.Parse("2000,1,1"),
        EndDate = DateTime.Parse("1999,1,1")
    };
    var context = new ValidationContext(contact, nullnull);
    var errors = new List<ValidationResult>();
    if (!Validator.TryValidateObject(contact, context, errors, true))
    {
        Assert.AreEqual(1, errors.Count);
    }

 

Validation Extension

因為 Validator.TryValidateObject 無法驗證集合物件,以下用擴充方法封裝集合物件的驗證

public static class ValidationExtensions
{
    public static bool TryValidate<T>(this T instance, ICollection<ValidationResult> validationResults)
        where T : classnew()
    {
        var items = instance as IEnumerable;
        if (items == null)
        {
            TryValidateObject(instance, validationResults);
        }
        else
        {
            foreach (var item in items)
            {
                TryValidateObject(item, validationResults);
            }
        }
 
        return !validationResults.Any();
    }
 
    private static bool TryValidateObject<T>(T instance, ICollection<ValidationResult> validationResults)
    {
        var isValid = false;
 
        var context = new ValidationContext(instance, nullnull);
        isValid = Validator.TryValidateObject(instance, context, validationResults, true);
        return isValid;
    }
}

 

這樣就可支援單一物件與集合物件,範例如下:

[TestMethod]
public void GIVEN_Collection_WHEN_Call_TryValidate_THEN_Valid_Faild_Count_Be_2()
{
    var contacts = new List<Contact>
    {
        new Contact
        {
            StartDate = DateTime.Parse("2000,1,1"),
            EndDate = DateTime.Parse("1999,1,1")
        }
    };
    var contact = new Contact
    {
        StartDate = DateTime.Parse("2000,1,1"),
        EndDate = DateTime.Parse("1999,1,1")
    };
 
    var validationResults = new List<ValidationResult>();
    var validCollection = contacts.TryValidate(validationResults);
    var validInstance = contact.TryValidate(validationResults);
    var isValid = validCollection & validInstance;
    Assert.AreEqual(false, isValid);
    Assert.AreEqual(2, validationResults.Count);
}

 

驗證複雜型別屬型

預設複雜型別的屬性,不會幫我們驗證,要做的話必須要分段調用驗證,現在我想要一起驗證,我新增一個 ComplexValidationAttribute,把複查型別的內容攤開,看它是單筆還是多筆,多筆就跑集合,然後把錯誤放到CompositeValidationResult.ValidationResults

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class ComplexValidationAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(object entity, ValidationContext validationContext)
    {
        if (validationContext == null)
        {
            throw new ArgumentNullException(nameof(validationContext));
        }
 
        if (entity == null)
        {
            return ValidationResult.Success;
        }
 
        var displayName = validationContext.DisplayName;
        var compositeResults = new CompositeValidationResult($"{displayName} validate failed!");
 
        var items = entity as IEnumerable;
 
        if (items != null)
        {
            var index = 0;
            foreach (var item in items)
            {
                var validationResults = new List<ValidationResult>();
 
                var context = new ValidationContext(item, nullnull);
                Validator.TryValidateObject(item, context, validationResults, true);
 
                validationResults.ForEach(=> compositeResults.Add(x, displayName, index));
                index++;
            }
        }
        else
        {
            var validationResults = new List<ValidationResult>();
 
            var context = new ValidationContext(entity, nullnull);
            Validator.TryValidateObject(entity, context, validationResults, true);
 
            validationResults.ForEach(=> compositeResults.Add(p, displayName));
        }
 
        return compositeResults.ValidationResults.Any() ? compositeResults : ValidationResult.Success;
    }
}

 

CompositeValidationResult.Add 是用來包裝錯誤訊息,把索引值呈現出來

public class CompositeValidationResult : ValidationResult
{
    private readonly List<ValidationResult> _validationResults;
 
    public CompositeValidationResult(string errorMessage)
        : base(errorMessage)
    {
        if (this._validationResults == null)
        {
            this._validationResults = new List<ValidationResult>();
        }
    }
 
    public IEnumerable<ValidationResult> ValidationResults
    {
        get { return this._validationResults; }
    }
 
    public void Add(ValidationResult validationResult, string displayName)
    {
        if (validationResult == null)
        {
            throw new ArgumentNullException(nameof(validationResult));
        }
 
        var fieldName = validationResult.MemberNames.FirstOrDefault();
        if (fieldName != null)
        {
            var propertyName = $"{displayName}.{fieldName}";
            var errorMessage = validationResult.ErrorMessage.Replace(fieldName, propertyName);
 
            var memberNames = validationResult.MemberNames.Select(=> propertyName).ToList();
            var result = new ValidationResult(errorMessage, memberNames);
 
            this._validationResults.Add(result);
        }
    }
 
    public void Add(ValidationResult validationResult, string displayName, int index)
    {
        if (validationResult == null)
        {
            throw new ArgumentNullException(nameof(validationResult));
        }
 
        var fieldName = validationResult.MemberNames.FirstOrDefault();
        if (fieldName != null)
        {
            var propertyName = $"{displayName}[{index}].{fieldName}";
            var errorMessage = validationResult.ErrorMessage.Replace(fieldName, propertyName);
 
            var memberNames = validationResult.MemberNames.Select(=> propertyName).ToList();
            var result = new ValidationResult(errorMessage, memberNames);
 
            this._validationResults.Add(result);
        }
    }
}

 

TryValidate2 用來把 CompositeValidationResult.ValidationResults 的內容攤平

public static bool TryValidate2<T>(this T instance, List<ValidationResult> validationResults)
    where T : classnew()
{
    var items = instance as IEnumerable;
    if (items == null)
    {
        TryValidateObject2(instance, validationResults);
    }
    else
    {
        foreach (var item in items)
        {
            TryValidateObject2(item, validationResults);
        }
    }
 
    return !validationResults.Any();
}
 
private static bool TryValidateObject2<T>(T instance, List<ValidationResult> validationResults)
{
    var isValid = false;
 
    var context = new ValidationContext(instance, nullnull);
    var errors = new List<ValidationResult>();
    isValid = Validator.TryValidateObject(instance, context, errors, true);
    foreach (var error in errors)
    {
        if (error is CompositeValidationResult)
        {
            var result = error as CompositeValidationResult;
            validationResults.AddRange(result.ValidationResults);
        }
        else
        {
            validationResults.Add(error);
        }
    }
 
    return isValid;
}

 

執行結果如下圖:


 

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


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

Image result for microsoft+mvp+logo