Genius DM

DataAnnotation > Make a custom attribute 본문

.NET

DataAnnotation > Make a custom attribute

Damon Jung 2017. 12. 22. 10:01

커스텀 Attribute 만들기


지난 포스트에서 DataAnnotation 에 Attribute 들이 어떻게 동작하는 지 알아보았다. 그리고 더 복잡한 Validation 수행을 위한 IValidatableObject 에 대해 소개를 했고, 그 한계에 대해서 알아보았다. 이 포스트에서는 그 한계를 뛰어넘기 위해 취할 수 있는 방법에 대해서 알아보겠다.



커스텀 Attribute

[Required] 같은 클래스는 .NET 에 다음과 같이 선언되어 있다.

public class RequiredAttribute : ValidationAttribute

{

.

. // 기타 Member 변수들

.

public override IsValid(object value)

{

// Validation 내용

}

.

.

.

}

이것이 존재하기 때문에 멤버 변수 혹은 오브젝트 상위에 [Required] 로 선언해서 사용할 수 있는 것이다. 그러면 [GeniusDM] 처럼 내가 직접 Annotation Attribute 를 만들 수 없을까? 당연히 가능하다 !


Step1. 클래스 생성

아래와 같이 클래스를 생성한다. 샘플로 GeniusDM 명칭으로 Annotation 을 생성할 것이다. 이 때 반드시 Annotation 에 사용할 이름 뒤에 Attribute 를 붙혀줘야 한다. 규칙이다. 아래 클래스만 만들어도 곧 바로 [GeniusDM] 을 모델에서 Annotation 으로 바로 사용 가능하다.

public class GeniusDMAttribute : ValidationAttribute

{


}


Step2. Base 클래스 Override

ValidationAttribute Base 클래스는 Validation 호출 Method 로 두 가지를 지니고 있다.

    • public virtual bool IsValue(object value)
      true / false 와 에러메시지를 리턴하고 싶을 때.

    • protected virtual ValidationResult isValue(object value, ValidationContext validationContext)
      ValidationResult 를 리턴하면서, 에러메시지 및 프로퍼티 정보까지 셋업하고 싶을 때.
      두 가지가 같이 구현되어 있다면 이 메서드만 호출된다는 것을 기억하자.

virtual 로 선언되어 있으므로 override 를 해서 구현해야 한다. 

public class GeniusDMAttribute : ValidationAttribute

{

public override bool IsValid(object value)

{

if(value == null)

{

Message = "GeniusDM Validation Tutorial - Fail !";

return false;

}

else

{

Message = "GeniusDM Validation Tutorial - Success !";

return true;

}

}

}


Step3. Annotation 부여

public class TestModel

{

[GeniusDM]

public string Test { get; set; }

}

Name 모델에 [GeniusDM] Annotation 을 부여했다. 이제 잘 동작하는 지 실행해보자.


Step4. 실행 및 결과

public void SomeMethod

{

// 1. Model 가져오기

TestModel model = new TestModel { Name = "Test" };

// 2. Result 선언

List<ValidationResult> results = new List<ValidationResult>();

// 3. Context 선언

ValidationContext context = new ValidationContext(model, null, null);

// 4. Validator 호출

var result = Validator.TryValidateObject(model, context, results, true);

// 5. 최종 결과

// result -> true;

// results.Count == 1

// results[0].ErrorMessage = "GeniusDM Validation Tutorial - Success !";

}


에러가 발생한 Property 명칭과 함께 에러메시지를 만들고 싶을 때?

위 IsValid(object value) 는 순수하게 Value 가 올바르냐, 올바르지 않냐를 판단해서 true / false 만 던지거나 에러메시지 정도를 추가할 수 있는 구현 요소이다. 그런데 보다 더 정확한 Logging 이나, 그 외의 컨텍스트에서 더 자세한 정보를 알고 싶을 때는 object 만 가지고는 부족하다. Reflection 으로 프로퍼티 네임을 취득하거나 객체 정보를 추적한다거나 할 수 없으니까. ( 느리다. ) 

위에서 언급했 듯, IsValid 는 두 가지가 존재한다. 이번엔 IsValid(object value, ValidationContext validationContext) 를 구현해보자.

public class GeniusDMAttribute : ValidationAttribute

{

protected override ValidationResult IsValid(object value, ValidationContext validationContext)

{

if(value == null) {

return new ValidationResult(String.Format("GeniusDM Validation Tutorial - Fail at {0}", validationContext.DisplayName));

} else {

return new ValidationResult(String.Format("GeniusDM Validation Tutorial - Success at {0}", validationContext.DisplayName));

}

}

}

이제 아까 실행했던 SomeMethod 를 다시 실행하면, PropertyName 이 추가 된 상태로 메시지가 구성된다.

public void SomeMethod

{

// 1. Model 가져오기

TestModel model = new TestModel { Name = "Test" };

// 2. Result 선언

List<ValidationResult> results = new List<ValidationResult>();

// 3. Context 선언

ValidationContext context = new ValidationContext(model, null, null);


// 4. Validator 호출

var result = Validator.TryValidateObject(model, context, results, true);


// 5. 최종 결과

// result -> true;

// results.Count == 1

// results[0].ErrorMessage = "GeniusDM Validation Tutorial - Success at Name!";

}


IValidatableObject 호출 보장하기

지난 포스트에서 IValidatableObject 가 복잡한 Validation 수행에 적합하다고 하였고, 그 한계에 대하여 설명하였다. 방법은 간단히 두 가지다.
    1. IValidatableObject 강제 호출
    2. 새로운 인터페이스 구현
지난 포스트 샘플 기준으로 IValidatableObject 강제 호출은 다음과 같이 구현할 수 있다.
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" });

            }        
      }        
}

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


// IValidatableObject 캐스팅

IValidatableObject validatable = (IValidatableObject)company;

validatable.Validate(context);

}

이러면 강제로 호출을 할 수 있지만 큰 문제점을 지니고 있다. [Required] 따위의 Validation 을 통과하지 못 했다면 의미있지만, Property Level Validation 이 통과한 경우 TryValidateObject 에서 자동 호출하기 때문에 결과적으로 두 번 중복 호출하는 결과를 낳는다. Property Level 에서 에러가 발생했는 지 안 했는지는 판단하기 어렵다 왜냐하면 Property Level 오류인지, IValidatableObject 에서 발생한 오류인지 알 수 없기 때문이다. 강제로 IValidatableObject 에서 PropertyName 에 식별자를 주지 않는 이상...


그냥 Interface 하나 만들자

.NET Validator 의 TryValidateObject 관리 영역에서 벗어나기 위한 방법은 IValidatableObject 를 탈피하고 새로운 Interface 구현 뿐이다.

public interface IGeniusDMValidatableObject {     IEnumerable<ValidationResult> Validate(ValidationContext context); }

public Company : IGeniusDMValidatableObject {     [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(ValidationContext context)     {         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.DepartmentList.Count > 10)         {          yield return new ValidationResult("이 회사에는 부서가 10개 이상 존재하지 않습니다.", new string[] { "DepartmentList" });             }               }         }

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


IGeniusDMValidatableObject validatable = (IGeniusDMValidatableObject)company;

validatable.Validate(context);

}


이렇게하면, Property Level 에러 여부와는 관계없이 무조건 추가 벨리데이션 코드를 호출할 수 있다. 한 곳에 ValidationResult 를 두기 위해 아래와 같이 Result 수집 작업이 필요하다.

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


IGeniusDMValidatableObject validatable = (IGeniusDMValidatableObject)company;


// IValidatableObject 결과 수집

List<ValidationResult> voResults = validatable.Validate(context).ToList();

foreach(var r in voResults)

{

results.Add(r);

}

}

위 작업은 계속 반복적이기 때문에 .NET Validator 역할을 대행하는 Utility 클래스를 하나 생성해두면 위 코드를 한 줄로 줄일 수 있다.

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = GeniusDMValidator.TryValidateObject(company);

}

public static class GeniusDMValidator

{

public static List<ValidationResult> TryValidateObject(object target)

{

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


IGeniusDMValidatableObject validatable = (IGeniusDMValidatableObject)company;

// IValidatableObject 결과 수집

List<ValidationResult> voResults = validatable.Validate(context).ToList();

foreach(var r in voResults)

{

    results.Add(r);

}


return results;

}

}


커스텀 벨리데이션을 만들어서 IValidatableObject 를 활용하고 그 한계를 극복하는 우회 방법에 대해 알아보았다. 다음 포스트는 Validation 의 마지막 포스트로 단일 Property 가 아닌, 리스트 아이템을 검사해줄 수 있는 커스텀 Attribute 에 대하여 작성해보겠다.










Make a custom attribute

We looked at how attributes in DataAnnotations work in the last post. And I talked about IValidatableObject to perform more complex validation and discussed its limitations. I'm going to look at some ways we can take to overcome those limitations.


Custom Attribute

the attribute [Required] has a method like this.

public class RequiredAttribute : ValidationAttribute

{

.

. // Etc Member Variables

.

public override IsValid(object value)

{

// Validation Content

}

.

.

.

}

You can declare [Required] right above a property or an object because this class exists. Isn't it possible to make my own annotation attribute? yes it is possible.


Step1. Make a class

Generate a class. I'm going to name it GeniusDM as an example. Attribute must come after your class name, it's a strict rule. If you make a class like this below, you can use [GeniusDM] right away.

public class GeniusDMAttribute : ValidationAttribute

{


}


Step2. overriding base class

ValidationAttribute Base class has two method overloadings.

    • public virtual bool IsValue(object value)
      when you want to return true / false and error .

    • protected virtual ValidationResult isValue(object value, ValidationContext validationContext)
      when you want to return ValidationResult, error message, and customized property information at the same time.
      Remember if you implement all of them, this one is going to be invoked by the Validator.

You have to override one of them because it's virtual !

public class GeniusDMAttribute : ValidationAttribute

{

public override bool IsValid(object value)

{

if(value == null)

{

Message = "GeniusDM Validation Tutorial - Fail !";

return false;

}

else

{

Message = "GeniusDM Validation Tutorial - Success !";

return true;

}

}

}


Step3. assign an annotation

public class TestModel

{

[GeniusDM]

public string Test { get; set; }

}

Just assigned [GeniusDM] annotation on TestModel. Let's see if it works.


Step4. Run and the result

public void SomeMethod

{

// 1. Make a model

TestModel model = new TestModel { Name = "Test" };

// 2. Declare a result list

List<ValidationResult> results = new List<ValidationResult>();

// 3. Declare a context

ValidationContext context = new ValidationContext(model, null, null);

// 4. Call the Validator's method

var result = Validator.TryValidateObject(model, context, results, true);

// 5. Final result

// result -> true;

// results.Count == 1

// results[0].ErrorMessage = "GeniusDM Validation Tutorial - Success !";

}


If you want to get the originated property name and error message.

IsValid method above just checks if the value is valid or not and returns true or false, and an error message additionally. But if you need to leave a log in detail and if you need to know the details of validation errors in another context, getting an object will not suffice because you just can't get the property name and object information by reflection. ( It could raise a serious performance issue. )

As I mentioned above, IsValid has two overloadings. I'm going to implement IsValid(object value, ValidationContext validationContext) this time.

public class GeniusDMAttribute : ValidationAttribute

{

protected override ValidationResult IsValid(object value, ValidationContext validationContext)

{

if(value == null) {

return new ValidationResult(String.Format("GeniusDM Validation Tutorial - Fail at {0}", validationContext.DisplayName));

} else {

return new ValidationResult(String.Format("GeniusDM Validation Tutorial - Success at {0}", validationContext.DisplayName));

}

}

}

Now invoking SomeMethod again will make it possible for you to build an error message with the originated property name.

public void SomeMethod

{

// 1. Make a model

TestModel model = new TestModel { Name = "Test" };

// 2. Declare a result list

List<ValidationResult> results = new List<ValidationResult>();

// 3. Declare a context

ValidationContext context = new ValidationContext(model, null, null);


// 4. Call the Validator's method

var result = Validator.TryValidateObject(model, context, results, true);


// 5. Final result.

// result -> true;

// results.Count == 1

// results[0].ErrorMessage = "GeniusDM Validation Tutorial - Success at Name!";

}


Make sure to invoke IValidatableObject implementation no matter what

I explained IValidatableObject about its usage and the limitation in the last post. There're two ways to call the interface implementation.
    1. Force to invoke IValidatableObject implementation
    2. Make a new interface.
The number one method looks like this below, based on the sample in the last post.

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 employee info", new string[] { "DepartmentList & EmployeeList" });         }         if(company.DepartmentList.Count > 10)         {          yield return new ValidationResult("This company doesn't have more than 10 departments.", new string[] { "DepartmentList" });             }               }         }


public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


// IValidatableObject casting

IValidatableObject validatable = (IValidatableObject)company;

validatable.Validate(context);

}

Now it's guaranteed to be called even if there was an error during the initial validation process. It's nice when there wasn't any errors on property level validation, but when there was, this code results in calling the same method twice. Checking if there was property errors or not is difficult. Because there will be two types of error. One could be from the attribute on a property and another could be from the interface implementation unless you strictly specified some kind of identifiers in IValidatableObject validation context.


Just making an interface will simply solve this problem.

What's itching is the fact that .NET Validator automatically manages the property level validation and the Validate method implementation. Getting out of it is the exact solution !

public interface IGeniusDMValidatableObject {     IEnumerable<ValidationResult> Validate(ValidationContext context); }

public Company : IGeniusDMValidatableObject
{
    [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(ValidationContext context) 
    {
        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 employee info", new string[] { "DepartmentList & EmployeeList" });
        }
        if(company.DepartmentList.Count > 10) 
        {
        	yield return new ValidationResult("This company doesn't have more than 10 departments.", new string[] { "DepartmentList" });

            }        
      }        
}

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


IGeniusDMValidatableObject validatable = (IGeniusDMValidatableObject)company;

validatable.Validate(context);

}


Now you can seamlessly call the additional validation method in the model no matter what. To collect a series of ValidationResult into a single list, you have to do something like this.

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


IGeniusDMValidatableObject validatable = (IGeniusDMValidatableObject)company;


// Collecting IValidatableObject results.

List<ValidationResult> voResults = validatable.Validate(context).ToList();

foreach(var r in voResults)

{

results.Add(r);

}

}

This is completely a boilerplate. You gotta prepare an utility class or method that wraps the .NET Validator for a simple method call.

public void SomeMethod

{

Company company = new Company();

List<ValidationResult> results = GeniusDMValidator.TryValidateObject(company);

}

public static class GeniusDMValidator

{

public static List<ValidationResult> TryValidateObject(object target)

{

List<ValidationResult> results = new List<ValidationResult>();

ValidationContext context = new ValidationContext(company, null, null);

Validator.TryValidateObject(company, context, results, true);


IGeniusDMValidatableObject validatable = (IGeniusDMValidatableObject)company;

// Collecting IValidatableObject results.

List<ValidationResult> voResults = validatable.Validate(context).ToList();

foreach(var r in voResults)

{

    results.Add(r);

}


return results;

}

}


Now we learned how to make a custom validation attribute, how to use IValidatableObject, and how to work around the limitations. I'm going to post how to make a custom attribute for list items in a model in the next post. It will be the last post for validation attribute.











Comments