Использование атрибутов для нормализации и валидации бизнес-сущностей

ОГЛАВЛЕНИЕ

В корпоративном программировании при проектировании уровня доступа к данным часто встает вопрос работы с бизнес-объектами(бизнес-сущностями): это загрузки/изменения/сохранения и перемещения между уровнями. Существует два основных подхода для этого - использование собственных бизнес-сущностей или стандартных средств (ADO.NET предоставляет достаточно удобные способы для этого) - использование DataSet. Как пишет Дино Эспозито:

В объекты DataSet встроена поддержка параллельного доступа с нежесткой блокировкой. Они позволяют определять и использовать сложные отношения между таблицами. Кроме того, объекты DataSet поддерживают сериализацию, а при изменении схемы базы данных, как правило, не требуется вносить соответствующие изменения в API уровня доступа к данным.

Однако не всегда удобно манипулировать DataSet-ами, особенно в случае большой детерминированности структуры данных. Достаточно сложно привязывать(bind) таблицы к спискам (Repeater, DataGrid) в ASP.NET страницах. Кроме того, DataSet при всем своем удобстве передачи между процессами относительно громоздок по размеру и может достаточно долго сериализоваться/десериализоваться, занимая при этом циклы процессора и оперативную память. Основная альтернатива этому способу - создание собственных бизнес-сущностей, более подходящих в случае основного упора на работу с данными экземпляров и скалярными значениями. Бизнес-сущность это класс, инкапсулирующий в себе данные, относящиеся к какой-либо отдельной сущности (например, пользователь, заказ, товар).

Бизнес-сущность может иметь различные методы для работы с содержащимися данными, может использовать методы фабрик данных для управления своим жизненным циклом. Одной из основных операций, производимых с БС, является отображение её данных пользователю (я имею в виду в первую очередь WinForms, но к ASP.NET это относится также) и сохранение внесенных пользователем изменений в БД или других хранилищах. Здесь также возникает дополнительная задача - необходимо позволить пользователю вводить только корректные данные.
В книге "Analyzing Requirements and Defining Microsoft® .NET Solution Architectures" (далее 70-300) написано:

In addition to the performance of the database, you need to ensure the quality of the data stored by the database. This means that the database implements data integrity and stores valid data that conforms to the business rules.

то есть, вы должны обеспечить не только производительность работы с БД, но и качество хранящихся в ней данных.

 

Одна из задач обеспечения целостности данных - это их валидация (Data validation). В отличие от остальных задач, обеспечивающих присутствие данных, валидация определяет годность, адекватность данных установленным бизнес-правилам. Вот основные методы проверки (70-300):

- Range checking involves making sure that the data’s value is within a set of limits determined by the functional specification.

- Data format checking involves making sure that the data conforms to a set of format specifications, such as currency formatting, telephone number formatting, or length-of-character strings.

- Data type checking involves making sure that the data belongs to the proper data type and that an invalid data type is not used to represent the data when it is written to the database.

то есть проверка границ, формата и типа данных.

Для этого есть 2 пути - проверка при вводе (невозможность ввести некорректные данные) при этом код валидации и нормализации привязан к UI-компоненту , или проверка производится при сохранении, код проверки в этом случае может находиться на любом уровне. В зависимости от архитектуры приложения эти проверки могут находиться в самой сущности, в отдельном компоненте (например в фабрике), в DAL-компоненте, с использованием XML-файлов правил и т.д. В 70-300 разделение немного другое (в зависимости от расположения):

- Client-side checks can ensure that invalid data is not posted to a database. These checks can be coded in many ways, including in scripts, in the user interface controls, as part of an executable program, or as part of a Web page. For example, if a field on a Web page denotes a monetary value, validation controls on the page can ensure that the information in that field is the proper data type and format.

- Server-side checks, which are executed by the DBMS engine, can be implemented with field data types, default values, and constraints. These checks can also ensure that invalid data is not stored in the database.

Майкрософт не рекомендует перегружать БД-сервер подобными проверками, поскольку это увеличивает его нагрузку и может привести к возникновению узкого места (bottleneck) если сервер обслуживает большое количество клиентских запросов.

Мы не будем рассматривать методы валидации на сервере БД, рассмотрим клиентские.

You can enforce data integrity through logic that is encapsulated in a component and is called or invoked as needed. The criteria for the logic are determined by the business rules, including any additional data integrity requirements that have been identified for the solution.

После некоторого количества времени проведенного за написанием кода-по-книжкам (подавляющее большинство примеров используют методы с DataSet), я решил для себя, что лично мне ближе "точная" работа с объектами - ручная загрузка и сохранение. Валидация на UI конечно более гибкая и мощная (например, можно менять содержимое/доступность/видимость одних контролов в зависимости от содержания других), но написание такого кода сложно в создании и поддержке, если UI становится относительно сложным (50-100 контролов разбитых по вкладкам). Кроме того этот код очень сложно использовать повторно, разве что отдельным кусочками.

После написания многочисленных методов проверки отдельных полей или их групп, после появления большого количества повторяющегося кода обычно возникает желание написать собственный "движок", который универсальным образом обрабатывает возможные ситуации.

Такое желание возникло и у меня, и свой "движок" я решил написать с использованием атрибутов, это решало бы сразу несколько задач. Во-первых, это простота создания и изменения - чтобы добавить проверку поля достаточно приписать к нему требуемый атрибут и указать дополнительные параметры (если они нужны). Во-вторых, это простота понимания и создания правил непрограммистами, то есть атрибуты можно описать в доступной форме и на основании этого составлять ТЗ.

Из минусов такого подхода - использование механизма отражения (Reflection) снижает быстродействие кода, и невозможность изменить правила без перекомпиляции кода. И если вторую проблему можно смягчить, вынося сущность в отдельную сборку (достаточно перекомпилировать только её), то с первой проблемой, по-видимому, ничего сделать нельзя :( Вообще этот механизм (валидации) не критичен по времени, в крупных приложениях большее знчение имеет его обслуживаемость. Например при изменении любого правила необходимо применить это изменение к клиентским приложениям (эта проблема решается определенными типами архитектуры приложения). Кроме того, поскольку атрибут применяется только для одного поля невозможно создавать проверки взаимосвязанных полей (на самом деле можно, но эти проверки уже hardcoded, т.е. зашиты в программном коде).

В качестве примера будем использовать такую сущность:

/// <summary>

/// Person Entity

/// <para>1.15</para>

/// </summary>

public class Person
{
public Person(int id)
{
m_ID = id;
}

#region Properties
#region Members

int m_ID = -1;
string m_Resume = string.Empty;
string m_Communication = string.Empty;
string m_ExtraInfo = string.Empty;
string m_Email = string.Empty;
string m_Name = string.Empty;

DateTime m_Birthday = DateTime.Today;

int m_Sex = -1;

ArrayList m_languages = new ArrayList();

#endregion

public int ID
{
get { return m_ID;}
}

public string Name
{
get { return m_Name;}
set { m_Name = value;}
}


public string Resume
{
get { return m_Resume;}
set { m_Resume = value;}
}

public string ExtraInfo
{
get { return m_ExtraInfo;}
set { m_ExtraInfo = value;}
}

public string Email
{
get { return m_Email;}
set { m_Email = value;}
}

public DateTime Birthday
{
get { return m_Birthday;}
set { m_Birthday = value;}
}

public int Sex
{
get { return m_Sex;}
set { m_Sex = value;}
}

public ArrayList Language
{
get { return m_languages;}
set { m_languages = value;}
}

#endregion
}  

Думаю с полями все понятно, в списке ArrayList Language мы буду хранить идентификаторы иностранных языков (первичный ключ в справочнике).

 

Определим атрибут, который будет определять отображаемое имя поля, как псевдоним. Для этого отнаследуемся от стандартного класса Attribute (в VS2005 это делается достаточно просто, "Insert snippet->attribute"). Определим строковое поле и конструктор, принимающий значение этого поля.

/// <summary>

/// Базовый класс-атрибут для определения отображаемого имени поля

/// </summary>

[AttributeUsage(AttributeTargets.Property)]
public class DisplayNameAttribute : Attribute
{
string m_name = "";
/// <summary>

/// Отображаемое имя

/// </summary>

public virtual string Name
{
get {return m_name;}
}

public DisplayNameAttribute(string displayName)
{
m_name = displayName;
}
}
Обратите внимание, что мы добавили перед классом атрибут AttributeUsageAttribute, определяющий ряд важных параметров нашего атрибута.
Вот что написано о AttributeUsageAttribute в MSDN:

При определении своего собственного класса атрибутов, можно управлять способом его использования путем размещения в нем AttributeUsageAttribute. Заданный класс атрибутов должен прямым или косвенным образом наследоваться из класса Attribute.
Классы атрибутов имеют позиционные и именованные параметры. Любой открытый конструктор для класса атрибутов определяет допустимую последовательность позиционных параметров для этого класса. Именованные параметры определяются нестатическими, открытыми и предназначенными для чтения и записи полями или свойствами класса атрибутов.
Три свойства AttributeUsageAttribute устанавливаются путем определения следующих параметров:
- ValidOn
Этот позиционный параметр определяет элементы программы, в которых может быть размещен указанный атрибут. Набор всех возможных элементов, в которых можно разместить атрибут, приведен в перечислении AttributeTargets. Можно объединить значения AttributeTargets при помощи битовой операции «OR» для получения требуемых сочетаний допустимых элементов программы.
- AllowMultiple
Этот именованный параметр определяет, может ли указанный атрибут определяться более одного раза для заданного элемента программы.
- Inherited
Этот именованный параметр определяет, может ли указанный атрибут наследоваться производными классами и переопределенными членами.

я указал AttributeTargets.Property, то есть данный атрибут может применяться только к свойствам, остальные параметры по умолчанию: AllowMultiple=false (этот атрибут может быть применен к свойству только 1 раз) и Inherited=true (этот атрибут будет наследоваться)




Что можно сделать с этим атрибутом? Применить к свойству! Давайте добавим псевдонимы к нескольким свойствам и напишем метод, который с помощью отражения получает список всех свойств объекта this и выводить на консоль их названия, и если у свойства есть DisplayNameAttribute, то вместо имени свойства выводится его псевдоним.

	public int ID
...

<b>[DisplayName(&quot;ФИО&quot;)]</b>
public string Name
...

public string Resume
...

<b>[DisplayName(&quot;Дополнительная информация&quot;)]</b>
public string ExtraInfo
...

public string Email
...

<b>[DisplayName(&quot;Дата Рождения&quot;)]</b>
public DateTime Birthday
...

public int Sex
...

public ArrayList Language
...


/// <summary>

/// Отображает список имен свойств

/// </summary>

public void ShowNames()
{
IDictionary result = new Hashtable();

// Получить атрибуты уровня свойств.

// Получить все свойства данного класса и поместить их в массив

PropertyInfo[] pInfo = this.GetType().GetProperties();

for (int j=0; j<pInfo.Length; j++)
{
string _fieldName = pInfo[j].Name;
Attribute dna = Attribute.GetCustomAttribute(pInfo[j], typeof(DisplayNameAttribute));
if (dna != null)
_fieldName = ((DisplayNameAttribute)dna).Name;

Console.WriteLine("- " + _fieldName);
}
}
}

/// <summary>

/// The main entry point for the application.

/// </summary>

[STAThread]
static void Main(string[] args)
{
Person p = new Person(-1);
<b>p.ShowNames();</b>
Console.ReadLine();
...
Мы получили то, что и нужно было получить, теперь у нас есть дружелюбное к пользователю название поля сущности.
А что будет, если применить этот атрибут к одному свойству несколько раз?
[DisplayName("ФИО")]
[DisplayName("Имя")]
public string Name
{
get { return m_Name;}
set { m_Name = value;}
}

Из-за того, что мы не указали явно, что разрешаем несколько экземпляров этого атрибута для одного элемента (AllowMultiple=true) мы получим ошибку при компиляции:

c:\csharp\consoleapplication1\consoleapplication1\class1.cs(56,3): error CS0579: Duplicate 'DisplayName' attribute


 

Теперь мы создаём базовый класс, определяющий общие свойства и методы для всех атрибутов нормализации. Под нормализацией я понимаю приведение значения свойства к одному из возможных валидных(корректных) значений. Свойство NormalizationOrder будет объяснено чуть позже, а виртуальный метод Normalize это тот самый метод атрибута, который и будет выполнять основную работу. Он должен быть переопределен в дочерних классах.
/// <summary>

/// Базовый класс-атрибут для создания атрибутов нормализации

/// </summary>

[AttributeUsage(AttributeTargets.Property)]
public abstract class NormalizationBaseAttribute : Attribute
{
int m_normalizationOrder = 0;
/// <summary>

/// Порядковый номер при нормализации

/// </summary>

public virtual int NormalizationOrder
{
get {return m_normalizationOrder;}
}

public NormalizationBaseAttribute(int normalizationOrder)
{
m_normalizationOrder = normalizationOrder;
}

/// <summary>

/// Нормализовать указанное значение

/// </summary>

/// <param name="value">Значение для нормализации</param>

/// <returns>Нормализованный объект</returns>

public virtual object Normalize(object value)
{
return "#NormalizationBase - Normalize method not overrided!";
}
}
А теперь определим несколько атрибутов, которые мы будем практически использовать. Атрибут, выполняющий обрезание определенных символов с начала и/или конца строки. Обрезаемый символ по умолчанию - пробел.
/// <summary>

/// Атрибут нормализации - отсечение символов строки слева и/или справа

/// </summary>

public class NormalizationStringTrimAttribute : NormalizationBaseAttribute
{

#region members

char m_trimChar = ' ';
/// <summary>

/// Отсекаемый символ

/// </summary>

public virtual char TrimChar
{
get {return m_trimChar;}
set {m_trimChar = value;}
}

bool m_trimLeft = true;
/// <summary>

/// Отсекать слева (с начала строки)

/// </summary>

public virtual bool TrimLeft
{
get {return m_trimLeft;}
set {m_trimLeft = value;}
}

bool m_trimRight = true;
/// <summary>

/// Отсекать справа (с конца строки)

/// </summary>

public virtual bool TrimRight
{
get {return m_trimRight;}
set {m_trimRight = value;}
}

#endregion

public NormalizationStringTrimAttribute(int normalizationOrder) : base(normalizationOrder)
{}

public override object Normalize(object value)
{
if (m_trimChar == ' ')
{
if (m_trimLeft & m_trimRight)
return value.ToString().Trim();
else if (m_trimLeft)
return value.ToString().TrimStart();
else if (m_trimRight)
return value.ToString().TrimEnd();
else
return value;
}
else
{
if (m_trimLeft & m_trimRight)
return value.ToString().Trim(m_trimChar);
else if (m_trimLeft)
return value.ToString().TrimStart(m_trimChar);
else if (m_trimRight)
return value.ToString().TrimEnd(m_trimChar);
else
return value;
}
}
}
Ещё один атрибут - сведения нескольких пробелов к одному.
/// <summary>

/// Атрибут нормализации строки - сведение нескольких пробелов подряд к одному \
/// (максимально 24->1)

/// </summary>

public class NormalizationStringWhiteSpaceReduceAttribute : NormalizationBaseAttribute
{

public NormalizationStringWhiteSpaceReduceAttribute(int normalizationOrder):
base(normalizationOrder)
{}

public override object Normalize(object value)
{
return value.ToString().Replace(" ", " ").Replace(" ", " ").Replace(" ", " ");
}
}
Итак у нас есть 2 атрибута и с ними уже можно что-то сделать. Например написать такой код
[DisplayName("ФИО")]
[NormalizationStringTrim()]
[NormalizationStringWhiteSpaceReduce()]
public string Name
{
get { return m_Name;}
set { m_Name = value;}
}
то есть мы предполагаем что написанная нами функция получит все атрибуты нормализации для данного свойства и последовательно перебирая их обработает свойство. Однако возникает вопрос в какой последовательности они будут возвращены отражением (а это может быть очень важно)? В той ли в которой были объявлены в коде? К сожалению, ответ - 'Нет'. Приходится извращаться и добавлять параметр, который определял порядок(последовательность) применения атрибутов. Для этого и предназначено виртуальное свойство NormalizationOrder. То есть наш сценарий действий таков:
1 получаем для свойства массив атрибутов основанных на NormalizationBaseAttribute с помощью метода Attribute.GetCustomAttributes.
2 Сортируем его в требуемом порядке.
3 Последовательно применяем к объекту нормализацию.

Для второго пункта нам понадобится класс NormalizationComparer, предназначенный для сравнения двух атрибутов, наследующихся от NormalizationBaseAttribute. Сравнение производится по свойству NormalizationOrder.
/// <summary>

/// Класс-сравнитель для NormalizationBaseAttribute

/// (сравнивает по NormalizationOrder)

/// </summary>

public class NormalizationComparer : IComparer
{

#region IComparer Members

public int Compare(object x, object y)
{
return ((NormalizationBaseAttribute)x).NormalizationOrder.CompareTo(
((NormalizationBaseAttribute)y).NormalizationOrder );
}

#endregion

}
основная функция нормализации
/// <summary>

/// Нормализует поля объекта согласно установленным правилам

/// </summary>

public void Normalize()
{
// Получить атрибуты уровня свойств.

// Получить все свойства данного класса и поместить их в массив

PropertyInfo[] pInfo = this.GetType().GetProperties();

// атрибуты всех свойств класса

for (int j=0; j<pInfo.Length; j++)
{
Attribute[] atts = Attribute.GetCustomAttributes(pInfo[j], typeof(NormalizationBaseAttribute));
Array.Sort(atts, new NormalizationComparer());
for (int k=0; k<atts.Length; k++)
{
NormalizationBaseAttribute att = (NormalizationBaseAttribute)atts[k];
if (att != null)
pInfo[j].SetValue(this, att.Normalize(pInfo[j].GetValue(this, null)), null);
}
}
}
и небольшой тестовый код
/// <summary>

/// The main entry point for the application.

/// </summary>

[STAThread]
static void Main(string[] args)
{
Person p = new Person(-1);
p.ShowNames();
Console.ReadLine();

p.Name = " Иванов Иван Иванович ";
Console.WriteLine("'{0}'", p.Name);
<b>p.Normalize();</b>
Console.WriteLine("'{0}'", p.Name);
Console.ReadLine();
...

Как вы видите, оба атрибута отработали и привели наше свойство к требуемому виду.
Отлично! Но нормализацией следует пользоваться с осторожностью, ведь пользователь уверен, что ввел одно значение, а перед записью в БД произошла нормализация, и значение могло измениться... Поэтому возможно 3 варианта:
- проверять введенные пользователем данные и если они не соответствуют правильным предупреждать пользователя об этом и не позволять продолжать, пока они не будут исправлены;
- проверять введенные пользователем данные и если они не соответствуют правильным нормализовывать и извещать пользователя об этом;
- нормализовывать данные, а пользователю ничего не сообщать :)

Я предпочитаю второй вариант, если ошибок нет данные сохраняются и актуальные значения отображаются пользователю.




А теперь напишем аналогичный базовый класс для атрибутов валидации.

/// <summary>

/// Базовый класс-атрибут для создания атрибутов валидации

/// </summary>

[AttributeUsage(AttributeTargets.Property)]
public abstract class ValidationBaseAttribute : Attribute
{

int m_validationOrder = 0;
/// <summary>

/// Порядок проведения валидации

/// </summary>

public virtual int ValidationOrder
{
get {return m_validationOrder;}
}

WarningLevel m_level = WarningLevel.Warning;
/// <summary>

/// Уровень предупреждения

/// </summary>

public virtual WarningLevel Level
{
get {return m_level;}
set {m_level = value;}
}

/// <summary>

/// Базовый атрибут валидации

/// </summary>

/// <param name="validationOrder">Порядковый номер в процессе валидации</param>

public ValidationBaseAttribute(int validationOrder)
{
m_validationOrder = validationOrder;
}

/// <summary>

/// Произвести валидацию

/// </summary>

/// <param name="value">значение для проверки</param>

/// <returns>Строку с предупреждением, либо пустую строку если всё в порядке</returns>

public virtual string Validate(object value)
{
return "#ValidationBase - Validate method not overrided!";
}
}
также напишем сортировщик (по аналогии с нормализацией) и несколько атрибутов валидации (их код можно найти в прилагаемом архиве)
  • ValidationEmptyAttribute (Атрибут проверки свойства на пустоту (null, DBNull, string.empty, (int)0))

  • ValidationStringLengthAttribute (Атрибут проверки свойства на длину строки (может применяться несколько раз))

  • ValidationIntValueAttribute (Атрибут проверки свойства на значение целочисленного типа (может применяться несколько раз))

  • ValidationCollectionEmptyAttribute (Атрибут проверки свойства на пустоту коллекции (списка))

  • ValidationDateYearRangeAttribute (Атрибут проверки свойства на нахождение года даты в указанных пределах (может применяться несколько раз))

Напишем достаточно несложный код проверки. Он получает список всех свойств объекта, для каждого свойства запрашивается список атрибутов, дочерних для ValidationBaseAttribute. Затем вызывается виртуальный (переопределенный в дочерних классах) метод Validate который возвращает пустую строку, если предупреждений не было или строку сообщения в противном случае. Полученную строку сообщения мы добавляем в словарь (Hashtable)
/// <summary>

/// Проверить объект на соответствие установленным ограничениям

/// </summary>

/// <returns>Словарь найденных несоответствий</returns>

public IDictionary Validate()
{
IDictionary result = new Hashtable();

// Получить атрибуты уровня свойств.

// Получить все свойства данного класса и поместить их в массив

PropertyInfo[] pInfo = this.GetType().GetProperties();

// атрибуты всех свойств класса

for (int j=0; j<pInfo.Length; j++)
{
Attribute[] atts = Attribute.GetCustomAttributes(pInfo[j], typeof(ValidationBaseAttribute));
if (atts.Length == 0)
continue;

Array.Sort(atts, new ValidationComparer());
string _fieldName = pInfo[j].Name;
Attribute dna = Attribute.GetCustomAttribute(pInfo[j], typeof(DisplayNameAttribute));
if (dna != null)
_fieldName = ((DisplayNameAttribute)dna).Name;

for (int k=0; k<atts.Length; k++)
{
ValidationBaseAttribute att = (ValidationBaseAttribute)atts[k];
if (att != null)
{
string vr = att.Validate(pInfo[j].GetValue(this, null));
if (vr != "")
{
result.Add(_fieldName +": "+ vr, att.Level);
//если ошибка - прекратить проверку данного поля

if (att.Level == WarningLevel.Error)
break;
}
}
}
}

//additional :(
return result;
}
В строчке //additional можно добавить вызовы собственных, комплексных функций проверки введенных данных, определенных в этом же классе. Словарь возвращенных предупреждений мы выводим в диалоговом окне в виде списка, чтобы пользователь мог с ними ознакомиться (или в данном случае в консоль).

и результаты выполнения следующего кода
/// <summary>

/// The main entry point for the application.

/// </summary>

[STAThread]
static void Main(string[] args)
{
Person p = new Person(-1);
p.ShowNames();
Console.ReadLine();

p.Name = " Иванов Иван Иванович ";
Console.WriteLine("'{0}'", p.Name);
p.Normalize();
Console.WriteLine("'{0}'", p.Name);
Console.ReadLine();

<b>p.Validate();</b>
Console.ReadLine();
}
Итак, несмотря на некоторые недостатки, данный подход имеет право на существование.