Genius DM

DataAnnotation > DataAnnotation Validation and IValidatableObject 본문

.NET

DataAnnotation > DataAnnotation Validation and IValidatableObject

Damon Jung 2017. 12. 18. 08:55

DataAnnotation Validation and IValidatableObject


DataAnnotation?

[Required] 와 같이 클래스에 선언된 멤버 변수의 유효성을 검사하고자 할 때 사용되는 Annotation 을 의미한다. 데이터 유형이 명확한 데이터 타입에 대한 유효성을 검증하기 위해 만들어졌다. 그렇기 때문에 아래와 같은 유효성 검증 객체가 이미 포함되어 있고, 주요 Attribute 만 정리하면 아래와 같다.


    • CreditCardAttribute
    • EmailAddressAttribute
    • MaxLengthAttribute
    • MinLengthAttribute
    • StringLengthAttribute
    • UrlAttribute
    • RequiredAttribute
    • PhoneAttribute

System.ComponentModel.DataAnnotations 에 포함된 Attribute 클래스들 전부는 이곳에 목록화 되어있다. 


기본적으로 ValidationAttribute Base 클래스를 상속하며, Base 클래스는 Attribute 인터페이스를 상속하고 있다.




Attribute 종류는 모두 해당 인터페이스를 상속한다고 생각하면 된다. PInvoke 사용시 필요한 [DllImport] Attribute 또한 동일하다.



IValidatableObject?

DataAnnotation Attribute 로 해결할 수 없는 복잡한 검증을 수행하기 위해 만든 인터페이스다. Annotation Attribute 는 Property 단위 유효성 검증이 1차 목적이기 때문에 복수 데이터에 조건을 걸어 데이터 유효성을 검증할 경우에 사용할 수 없다. 그래서 이 인터페이스가 존재한다. 



어떻게 동작할까?

실제 사용 예에 대해서 알아보자. MVC App 에서는 기본적으로 Model Property 에 선언된 Annotation 들을 Response 과정에서 자동으로 검사하여, ModelState 라는 ViewBag 객체 내부에 선언된 key / value 객체에 결과를 매핑하고 Response 에 ViewBag 를 포함하여 응답한다. 이 때 Razor 문법에서 곧 바로 접근할 수 있는 Object 이기 때문에 에러메시지를 바인딩해두면, 바로 출력할 수 있는 것이다. 이 포스트에서는 MVC 사용 예제가 아닌, 일반 어플리케이션 코드 수준에서 사용할 수 있는 방법에 대해서만 서술 할 것이다.


샘플 모델

public Company
{
    [Required]
    public string CompanyName { get; set; }

    [Range(1, int.MaxValue)]
    public int Employees { get; set; }

    public List<Employee> EmployeeList { get; set; }

    public List<Department> DepartmentList { get; set; }
}

어플리케이션 코드에서 벨리데이션 수행 방법

public void SomeValidationLogic()
{
    Company company = new Company();						
    List<ValidationResult> results = new List<ValidationResult>();
    ValidationContext context = new ValidationContext(company, null, null);						
    if(Validator.TryValidateObject(company, context, results, true))
    {			
        // Validation passed !
    }
    else 
    {			
        // Validation failed !			
    }
}

위 코드에서 볼 수 있듯, 모델에 대한 DataAnnotation Validation 을 수행을 수동으로 하려면 3개의 준비물이 필요하다.


    • ValidationResult
      Validator 를 호출할 때 List 로 넘겨주면, 에러 발생시 해당 리스트에 ValidationResult 객체가 추가된다. 
      기본적으로 ErrorMessage 와 MemberNames 를 string 리스트를 멤버 변수로 지니고 있어, 여기에 에러 데이터가 바인딩된다.

    • ValidationContext
      벨리데이션 과정으로 생각하면 된다. IServiceProvider 라는 인터페이스를 Dependency Injection 으로 활용할 수 있는데, 여기선 제외한다.

    • Validator
      Static Helper 클래스이다. 사용자는 TryValidateObject 메서드 정도만 알아도 된다. 

이 객체들을 선언 한 후에 TryValidateObject 에 각각 넣어주면 Company 모델에 대한 Validation 검사가 이루어지고, DataAnnotation 이 포함된 프로퍼티에 대한 에러 메시지가 자동으로 results 에 들어간다. 예상할 수 있듯, 위 코드에서는 유효성 검사가 당연히 통과하지 못 하며, 2개의 에러를 포함하게 된다.

    1. [Required] 결과 >> CompanyName 이 필요합니다.
    2. [Range] 결과 >> Employees 는 1 과 2,147,483,647 사이에 값이여야 합니다.

CompanyName 은 Null 이고, Employees 는 선언하지 않았을 경우 기본적으로 0 이 들어가기 때문에 위와 같은 에러가 난다. 에러메시지 데이터와 프로퍼티 데이터를 추출하고 싶으면 아래처럼 하면 된다.

public void SomeValidationLogic()
{
    Company company = new Company();
    List<ValidationResult> results = new List<ValidationResult>();			
    ValidationContext context = new ValidationContext(company, null, null);
    if(Validator.TryValidateObject(company, context, results, true)) 
    {
        // Validation passed !
    }
    else 
    {
        foreach(var e in results) 
        {
            string ErrorMessage = e.ErrorMessage;			
            // 한 개 라면.. MemberNames 는 여러 개 일 수도 있다.
            string PropertyName = e.MemberNames.FirstOrDefault();			
        }
    }
}
 

더 복잡한 Validation 을 수행하려면? IValidatableObject

앞서 언급하였듯, Property 단위 Validation 만으로는 부족할 때가 많다. 예를들어 Company 오브젝트에서 Employee 리스트 객체와 Department 리스트 객체에 대한 상세한 Validation 이 필요한 경우, SomeValidationLogic 같은 코드 컨텍스트에서 If else 등을 직접 선언해서 관리해야 하는데, 보다 논리적인 처리 구성을 위해 Validation 영역은 될 수 있는 한 지정된 영역에 선언하는 것이 좋다. 더불어 코드 가독성도 좋아진다.

public Company : IValidatableObject
{
    [Required]
    public string CompanyName { get; set; }

    [Range(1, int.MaxValue)]
    public int Employees { get; set; }
    
    public List<Employee> EmployeeList { get; set; }

    public List<Department> DepartmentList { get; set; }

    public IEnumerable<ValidationResult> Validate(object value) 
    {
        Company company = value as company;
        if(company != null) {
        if(company.EmployeeList.Count <= 0 && company.DepartmentList.Count <= 0) 
        {
        	//               ValidationResult(" Error Message ", " MemberNames " )
        	yield return new ValidationResult("부서 정보와 직원 정보가 없습니다", new string[] { "DepartmentList & EmployeeList" });
        }
        if(company.EmployeeList.Count > 10) 
        {
        	yield return new ValidationResult("이 회사에는 부서가 10개 이상 존재하지 않습니다.", new string[] { "DepartmentList" });

            }        
      }        
}

위 처럼 더욱 상세한 Validation 을 수행할 수 있다. ErrorMessage 는 예제 처럼 어플리케이션에 맞는 에러 메시지를 출력하면 된다. MemberNames 는 생략해도 무관하다.


IValidatableObject 주의사항

기본적으로 Validator.TryValidateObject 메서드에는 함정(?)이 숨어있다. 바로 Validation Priority 가 존재한다는 것. 즉, 높은 우선순위의 Validation 과정에서 실패 처리된 것이 있다면, 그 이하의 Validation 요소는 체크하지 않는다는 의미이다. 우선순위는 아래와 같다.

    1. [Required]
    2. 기타 Attribute
    3. IValidatableObject Implementation

그러니까, Required 에서 에러가 터지지 않아야 기타 Attribute 를 검사해준다는 의미이고 Required 와 Range 같은 다른 Attribute 에서 에러가 나오지 않아야 비로소 IValidatableObject 구현 메서드를 실행한다는 것이다. 2번 우선순위까지 Validation 을 보장할 수 있는 옵션이 있는데, 그게 바로 위 사용 예제에 나온 파라메터 true 이다.
if(Validator.TryValidateObject(company, context, results, true)) // << 이 마지막 녀석.
{
    // Validation passed !
}
else 
{
    // Validation failed !
}


저 true 의 의미는 validateAllProperties 이다. 문자 그대로, 모든 프로퍼티를 검사 할 것이냐, 말 것이냐를 의미한다. true 로 해주면, Company 의 대한 검사 결과에 Range Attribute 결과까지 포함될 것이다. False 인 경우는 [ Required ] 만 검사한다. 다른 Attribute 는 무시된다. 어플리케이션 개발시 1차 벨리데이션 결과만 갖고 판단해도 충분한 상황이라면 괜찮지만, 모든 에러 결과를 1차적으로 알고 난 이후에 판단해야 하는 경우도 존재한다. 


DataAnnotation Validation 에서는 결국 정석만 가지고는 해당 요구사항을 만족시킬 수 없다. 어떻게 해야 될까? 다음 포스트에서 커스텀 Attribute 만드는 방법과, IValidatableObject 를 Property 레벨에서 에러가 발생했어도 호출을 보장할 수 있는 방법에 대하여 다뤄보도록 하겠다.






















DataAnnotation Validation and IValidatableObject


DataAnnotation?

Means annotations that validate member variables in a class like [Required]. The main purpose is to validate data types that are explicit and have a clear format. So .NET already has a series of classes for certain data validation, see the list below.


    • CreditCardAttribute
    • EmailAddressAttribute
    • MaxLengthAttribute
    • MinLengthAttribute
    • StringLengthAttribute
    • UrlAttribute
    • RequiredAttribute
    • PhoneAttribute

You can see all list of classes in System.ComponentModel.DataAnnotations here.  


It basically inherits the base class ValidationAttribute and the base class implements Attribute interface.




All the attributes that developers can put up on a member or class is derived from Attribute interface, including [DllImport] for Pinvoke.



IValidatableObject?

It provides a way to validate a complex object validation that a single data annotation attribute cannot just deal with. Annotation Attribute can not be used when verifying validity of data by applying conditions to multiple data because validation of property unit is the primary purpose. So that's what the IValidatableObject interface for.



How it works?

Let's see an actual example. in MVC app, it automatically processes validations on properties on a model by detecting the attributes on them and put the results in ModelState, the key/value pair object, in ViewBag object, and eventually returns it with http response. You can access the key value object in Razor directly so once you bind the message key on the cshtml, MVC now can show the result automatically in the front. This time, I'm not going to deal with MVC example. I'm going to show how it works in an application like console, winform, wpf app.


Sample Model

public Company
{
    [Required]
    public string CompanyName { get; set; }

    [Range(1, int.MaxValue)]
    public int Employees { get; set; }

    public List<Employee> EmployeeList { get; set; }

    public List<Department> DepartmentList { get; set; }
}

Perform validation in application code

public void SomeValidationLogic()
{
    Company company = new Company();						
    List<ValidationResult> results = new List<ValidationResult>();
    ValidationContext context = new ValidationContext(company, null, null);						
    if(Validator.TryValidateObject(company, context, results, true))
    {			
        // Validation passed !
    }
    else 
    {			
        // Validation failed !			
    }
}

As you can see above, you will need three prerequisites to manually perform validation on a model.


    • ValidationResult
      It has ErrorMessage and MemberNames as its member variable. The error message and the property name will be bound automatically while the validation process. And you just toss the list of ValidationResult into TryValidateObject method, and it internally adds the result.

    • ValidationContext
      It's literally a context of validation process. It provides IServiceProvider for you to perform DI ( Dependency Injection ) later, but I'm not going to skip it.

    • Validator
      It's a static helper class. All you have to remember is TryValidatObject.

You just declare these classes and pass them into TryValidateObject method, the it will do all the heavy lefting for you. What you're gonna do is just check the ValidationResult list. As you expected, the code above will throws 2 errors.

    1. [Required] validation result >> CompanyName is required.
    2. [Range] validation result >> Employees value should be in range between 1 and 2,147,483,647.

CompanyName is supposed to be Null there, and if you don't declare nothing on Employees, it automatically sign 0 value, so you will have those errors. If you want to get the message and property name respectably, see below code.

public void SomeValidationLogic()
{
    Company company = new Company();
    List<ValidationResult> results = new List<ValidationResult>();			
    ValidationContext context = new ValidationContext(company, null, null);
    if(Validator.TryValidateObject(company, context, results, true)) 
    {
        // Validation passed !
    }
    else 
    {
        foreach(var e in results) 
        {
            string ErrorMessage = e.ErrorMessage;			
            // Suitable if there is only one property name. It could have multiple names.
            string PropertyName = e.MemberNames.FirstOrDefault();			
        }
    }
}
 

If you want more complex validation... use IValidatableObject

As I mentioned before, validating property unit is not enough. If you need to scrutinize the nested Employee and Department model list in Company model, for example, you are going to use if else in the SomeValidationLogic context. But it'd be better if you can isolate the validation area for composing more logical process and it will improve readability of your codes.


public Company : IValidatableObject
{
    [Required]
    public string CompanyName { get; set; }

    [Range(1, int.MaxValue)]
    public int Employees { get; set; }
    
    public List<Employee> EmployeeList { get; set; }

    public List<Department> DepartmentList { get; set; }

    public IEnumerable<ValidationResult> Validate(object value) 
    {
        Company company = value as company;
        if(company != null) {
        if(company.EmployeeList.Count <= 0 && company.DepartmentList.Count <= 0) 
        {
        	//               ValidationResult(" Error Message ", " MemberNames " )
        	yield return new ValidationResult("No Department and Employees information", new string[] { "DepartmentList & EmployeeList" });
        }
        if(company.EmployeeList.Count > 10) 
        {
        	yield return new ValidationResult("This company doesn't have more than 10 departments", new string[] { "DepartmentList" });

            }        
      }        
}

You can tailor the validation job by implementing IValidatableObject. You just use yield return with ValidationResult instance, providing your own error messages and member names. You can ignore the member name by the way.


Caution on IValidatableObject

TryValidateObject has a hidden, default behaviour. It has a certain priority when it tries to validate an ojbect. So it means, if there's an error in higher priority checking sequence, the lower priority will be completely ignored. see the priority below.

    1. [Required]
    2. Other attributes
    3. IValidatableObject Implementation

So [Required] takes the top priority. Other validations will be executed eventually only if [Required] validation has been passed. And IValidatableObject takes the lowest priority. It's going to be called only if there's no [Required] error and no other errors like [Range]. It provides an option to guarantee to perform validation to the second priority. Let's see below boolean parameter.
if(Validator.TryValidateObject(company, context, results, true)) // << That last parameter.
{
    // Validation passed !
}
else 
{
    // Validation failed !
}


It means validateAllProperties. It is literally a decision to check all the properties or not. If you set true, it will validate all the annotations in the Company model, including [Required] and [Range]. If you set false, it will validate [Required] property only. All the other annotations will be completely ignored. It's quite alright when validating [Required] will suffice for you, but there's a certain circumstances that you need a total set of validation results.


Seeing the limitations on data annotations and IValidatableObject implementation, it's impossible to make sure to validate all properties and invoke IValidatableObject implementation in a single validation context. How can I achieve that? I'm going to write another post about how to make a custom attribute and a way to guarantee to call the interface implemented method even if there's an prior error in the validation context.
























Comments