利用 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, null, null); 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, null, null); 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 { get; set; } [Required(AllowEmptyStrings = false, ErrorMessage = "First name is required")] public string FirstName { get; set; } public string LastName { get; set; } public DateTime Birthday { get; set; } public string EMail { get; set; } public DateTime StartDate { get; } = DateTime.Parse("3000-01-01"); public DateTime EndDate { get; set; } 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 { get; set; } public DateTime StartDate { get; set; } [GreaterThan("StartDate")] public DateTime EndDate { get; set; } }
當 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, null, null); 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 : class, new() { 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, null, null); 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, null, null); Validator.TryValidateObject(item, context, validationResults, true); validationResults.ForEach(x => compositeResults.Add(x, displayName, index)); index++; } } else { var validationResults = new List<ValidationResult>(); var context = new ValidationContext(entity, null, null); Validator.TryValidateObject(entity, context, validationResults, true); validationResults.ForEach(p => 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(x => 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(x => 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 : class, new() { 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, null, null); 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