На переднем крае

Проектирование модели предметной области

Дино Эспозито

 

Дино ЭспозитоНедавний выпуск Entity Framework 4.1 и нового шаблона разработки Code First разрушил фундаментальное правило разработки серверного ПО: не делайте ничего, пока нет базы данных. Code First предлагает сосредоточиться на предметной области бизнеса (бизнес-домене) и моделировать ее в терминах классов. В каком-то смысле Code First способствует применению принципов проектирования, управляемого предметной областью (domain-driven design, DDD) в пространстве .NET. Бизнес-домен заполняется соответствующими взаимосвязанными сущностями, каждая из которых предоставляет свои данные как свойства и может открывать доступ к поведению через методы и события. Еще важнее, что у каждой сущности может быть состояние, и она может быть связана с динамическим списком правил проверки.

При написании объектной модели для реалистичного сценария возникают некоторые проблемы, которым не уделяется внимание в текущих демонстрационных и учебных материалах. В этой статье я попробую устранить этот пробел и покажу создание класса Customer, затронув попутно целый ряд проектировочных шаблонов и методик, таких как шаблон Party, агрегатные корни (aggregate roots), фабрики и технологии вроде Code Contracts и Enterprise Library Validation Application Block (VAB).

Советую также изучить проект с открытым исходным кодом, небольшое подмножество которого обсуждается здесь. Этот проект, Northwind Starter Kit (nsk.codeplex.com), созданный Андреа Салтарелло (Andrea Saltarello), предназначен для иллюстрации эффективных методик проектирования архитектуры многоуровневых решений.

Объектная модель и модель предметной области

Споры насчет того, что применять — объектную модель или модель предметной области, могут показаться бессмысленными, и по большей части это просто вопрос терминологии. Но точная терминология — ключевой фактор, гарантирующий, что все члены группы придерживаются одной концепции при использовании специфических терминов.

Почти для всех работающих в индустрии ПО объектная модель — это набор универсальных, но, возможно, связанных объектов. Чем отличается модель предметной области? В конце концов, модель предметной области все равно является объектной моделью, поэтому использование этих двух терминов не будет ужасной ошибкой. Тем не менее, термин «модель предметной области» может подразумевать некие ожидания в отношении формы составляющих модель объектов.

Применение модели предметной области связано с определением, данным Мартином Фаулером (Martin Fowler): это объектная модель предметной области, которая включает как поведение, так и данные. В свою очередь поведение выражает как правила, так и специфическую логику (см. bit.ly/6Ol6uQ).

DDD добавляет в модель предметной области целый набор практических правил. Согласно этой концепции, модель предметной области отличается от объектной модели интенсивным использованием значимых объектов (value objects), применение которых рекомендуется вместо элементарных типов. Например, целочисленный тип может значения, относящиеся, скажем, к температуре, денежной сумме, размеру, некоему количеству. В модели предметной области для каждого из этих понятий использовался бы свой тип значимого объекта.

Более того, модель предметной области должна идентифицировать агрегатные корни (aggregate roots). Агрегатный корень — это сущность, полученная композицией других сущностей. Объекты в агрегатном корне не имеют релевантности для внешнего мира, а это значит, что ни в каких сценариях применения они не используются без передачи из корневого объекта. Канонический пример агрегатного корня — сущность Order. Она представляет собой агрегатный OrderItem, но не Product. Трудно вообразить (даже когда все определяется только вашими спецификациями), что вам понадобилось бы работать с OrderItem, не исходящим из Order. С другой стороны, вполне могут быть сценарии применения, где вы работаете с сущностями Product безо всяких заказов. Агрегатные корни отвечают за поддержание своих дочерних объектов в допустимом состоянии и за их сохранение.

Наконец, некоторые классы модели предметной области могут предоставлять для создания новых экземпляров открытые методы-фабрики, а не конструкторы. Когда класс по большей части автономный и не является частью иерархии или когда этапы создания экземпляра класса представляют некоторый интерес для клиента, применение обычного конструктора вполне приемлемо. Однако в случае комплексных объектов вроде агрегатных корней нужен дополнительный уровень абстракции над процессом создания экземпляра. DDD вводит объекты фабрики (или, проще говоря, методы-фабрики в некоторых классах) как способ отделения клиентских требований от внутренних объектов и их взаимосвязей и правил. Очень четкое и понятное введение в DDD можно найти по ссылке bit.ly/oxoJD9.

Шаблон Party

Сосредоточимся на классе Customer. В свете того, что было сказано ранее, вот его возможная сигнатура:

public class Customer : Organization, IAggregateRoot
{
  ...
}

Кто ваш клиент (customer)? Частное лицо, организация или и то, и другое? Шаблон Party предполагает, что вы проводите различие между ними и четко определяете, какие свойства являются общими, а какие относятся только к частным лицам или только к организациям. Код на рис. 1 ограничен Person и Organization; вы можете сделать его более детализированным, разделив организации на коммерческие и некоммерческие, если того требует предметная область вашего бизнеса.

Рис. 1. Классы согласно шаблону Party

public abstract class Party
{
  public virtual String Name { get; set; }
  public virtual PostalAddress MainPostalAddress { get; set; }
}
public abstract class Person : Party
{
  public virtual String Surname { get; set; }
  public virtual DateTime BirthDate { get; set; }
  public virtual String Ssn { get; set; }
}
public abstract class Organization : Party
{
  public virtual String VatId { get; set; }
}

Всегда стоит помнить, что ваша цель — создание модели, которая весьма близко моделирует предметную область вашего бизнеса, а не его абстрактное представление. Если ваши клиенты — исключительно частные лица, тогда применение шаблона Party не является строго обязательным, хотя он создает точку расширения на будущее.

Customer как класс агрегатного корня

Агрегатный корень — это класс в вашей модели, представляющий автономную сущность, у которой нет связей с другими сущностями. По большей части вы имеете дело с агрегатными корнями, являющимися просто индивидуальными классами, которые не управляют никакими дочерними объектами или, возможно, всего-навсего указывают на корень других агрегатов. На рис. 2 показаны дополнительные детали класса Customer.

Рис. 2. Класс Customer как агрегатный корень

public class Customer : Organization, IAggregateRoot
{
  public static Customer CreateNewCustomer(
    String id, String companyName, String contactName)
  {
    ...
  }

  protected Customer()
  {
  }

  public virtual String Id { get; set; }
    ...

  public virtual IEnumerable<Order> Orders
  {
    get { return _Orders; }
  }

  Boolean IAggregateRoot.CanBeSaved
  {
    get { return IsValidForRegistration; }
  }

  Boolean IAggregateRoot.CanBeDeleted
  {
    get { return true; }
  }
}

Как видите, класс Customer реализует (собственный) интерфейс IAggregateRoot. Вот как выглядит этот интерфейс:

public interface IAggregateRoot
{
  Boolean CanBeSaved { get; }
  Boolean CanBeDeleted { get; }
}

Что значит быть агрегатным корнем? Агрегатный корень обрабатывает сохранение своих дочерних агрегированных объектов и отвечает за введение в действие инвариантных условий, которые включают группу. Оказывается, агрегатный корень должен уметь проверять, можно ли сохранить или удалить весь стек. Автономный агрегатный корень просто возвращает true без дальнейшей проверки.

Фабрика и конструктор

Конструктор специфичен для типа. Если объект содержит только один тип — нет никаких агрегатов и сложной логики инициализации, — использования простого конструктора более чем достаточно. В целом, однако, фабрика создает полезный дополнительный уровень абстракции. Фабрикой может быть просто статический метод в классе сущности или отдельный компонент. Наличие метода-фабрики также помогает улучшить читаемость кода, так как становится понятнее, почему вы создаете данный конкретный экземпляр. В случае конструкторов ваши возможности в работе с различными сценариями инициализации более ограничены, поскольку конструкторы не являются именованными методами и их можно отличить лишь по сигнатуре. А если сигнатуры длинные, понять впоследствии, зачем был создан конкретный экземпляр, гораздо труднее. На рис. 3 показан метод-фабрика в классе Customer.

Рис. 3. Метод-фабрика в классе Customer

public static Customer CreateNewCustomer(
  String id, String companyName, String contactName)
{
  Contract.Requires<ArgumentNullException>(
           id != null, "id");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(id), "id");
  Contract.Requires<ArgumentNullException>(
           companyName != null, "companyName");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(companyName),
           "companyName");
  Contract.Requires<ArgumentNullException>(
           contactName != null, "contactName");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(contactName),
           "contactName");

  var c = new Customer
              {
                Id = id,
                Name = companyName,
                  Orders = new List<Order>(),
                ContactInfo = new ContactInfo
                              {
                                 ContactName = contactName
                              }
              };
  return c;
}

Метод-фабрика является атомарным, принимает входные параметры, выполняет свою работу и возвращает новый экземпляр заданного типа. Возвращаемый экземпляр должен гарантированно находиться в допустимом состоянии. Фабрика ответственна за выполнение всех определенных внутренних правил проверки.

Фабрике также нужно проверять входные данные. В этом случае применение предусловий Code Contracts позволяет сохранить четкость и хорошую читаемость кода. Чтобы гарантировать допустимое состояние возвращаемого экземпляра, можно также использовать постусловия, например так:

Contract.Ensures(Contract.Result<Customer>().IsValid());

Что касается применения инвариантов в классе, то опыт показывает, что они годятся не всегда. Инварианты могут оказаться чрезмерно инвазивными, особенно в больших сложных моделях. Инварианты Code Contracts иногда слишком усиленно соблюдают набор правил, уменьшая гибкость вашего кода. В таких случаях предпочтительнее ограничивать области действия инвариантов.

Проверка

Свойства класса предметной области скорее всего нужно проверять, чтобы все требуемые поля были заполнены, в ограниченных контейнерах не был размещен слишком длинный текст, значения находились в должном диапазоне и т. д. Кроме того, стоит подумать о проверке между свойствами и о более сложных бизнес-правилах. Как кодировать проверку?

Проверку выполняет условный код, поэтому в конечном счете все сводится к комбинации выражений if и возврату булевых значений. Написание уровня проверки без какой-либо инфраструктуры или технологии может сработать, но в действительности это не очень хорошая идея. Код, который вы получите, будет плохо читаем и сложен в дальнейшем развитии, хотя некоторые библиотеки помогают упростить его. Реальные бизнес-правила потребуют от вашего кода проверки высокой гибкости, и вам нужно учитывать это в своей реализации. В конечном счете нельзя просто писать код, который выполняет проверку; вы должны создать код, способный проверять те же данные с применением разных правил.

При проверке иногда требуется сообщать о передаче неправильных данных, а иногда — просто собирать ошибки и передавать их другим уровням кода. Помните, что Code Contracts не осуществляют проверку данных — они проверяют условия, а затем генерируют исключение, если условие не выполнено. Используя централизованный обработчик ошибок, вы можете осуществлять восстановление после исключений и корректную деградацию (сокращение функциональности). В целом, советую использовать Code Contracts в сущности предметной области только для отлова потенциально опасных ошибок, которые могут приводить к появлению несогласованных состояний. Code Contracts имеет смысл применять в фабрике — в этом случае, если переданные данные неправильны, код должен генерировать исключение. Стоит ли использовать Code Contracts в set-методах свойствах, — дело ваше. Я предпочитаю более мягкий способ — проверку через атрибуты. Но какие это атрибуты?

Аннотации данных и VAB

Пространство имен Data Annotations и Enterprise Library VAB очень похожи. Обе инфраструктуры основаны на атрибутах и могут быть расширены с помощью собственных классов, представляющих собственные правила. В обоих случаях вы можете определить проверку между свойствами. Наконец, в обеих инфраструктурах есть API верификатора, который оценивает экземпляр и возвращает список ошибок. В чем же тогда разница?

Data Annotations являются частью Microsoft .NET Framework и не требует отдельной загрузки в отличие от Enterprise Library; невелика проблема в крупном проекте, но в корпоративных сценариях все же может потребовать утверждения. Enterprise Library можно легко установить через NuGet (см. статью «Управление библиотеками проекта с помощью NuGet» в этом номере).

Enterprise Library VAB превосходит Data Annotations в одном отношении: ее можно конфигурировать через XML-набор правил. XML-набор правил — это запись в конфигурационном файле, где вы описываете требуемые проверки. Разумеется, вы можете менять многие вещи декларативно, не трогая код. Пример набора правил показан на рис. 4.

Рис. 4. Наборы правил Enterprise Library

<validation>
  <type assemblyName="..." name="ValidModel1.Domain.Customer">
    <ruleset name="IsValidForRegistration">
      <properties>
        <property name="CompanyName">
          <validator negated="false"
            messageTemplate="The company name cannot be null"
            type="NotNullValidator" />
          <validator lowerBound="6" lowerBoundType="Ignore"
            upperBound="40" upperBoundType="Inclusive"
            negated="false"
            messageTemplate="Company name cannot be longer ..."
            type="StringLengthValidator" />
        </property>
        <property name="Id">
          <validator negated="false"
            messageTemplate="The customer ID cannot be null"
            type="NotNullValidator" />
        </property>
        <property name="PhoneNumber">
          <validator negated="false"
            type="NotNullValidator" />
          <validator lowerBound="0" lowerBoundType="Ignore"
            upperBound="24" upperBoundType="Inclusive"
            negated="false"
            type="StringLengthValidator" />
        </property>
        <property name="FaxNumber">
          <validator negated="false"
            type="NotNullValidator" />
          <validator lowerBound="0" lowerBoundType="Ignore"
            upperBound="24" upperBoundType="Inclusive"
            negated="false"
            type="StringLengthValidator" />
        </property>
      </properties>
    </ruleset>
  </type>
</validation>

В наборе правил перечисляются атрибуты, которые вы хотите применить к данному свойству данного типа. В коде вы проверяете набор правил так:

public virtual ValidationResults ValidateForRegistration()
{
  var validator = ValidationFactory
    .CreateValidator<Customer>("IsValidForRegistration");
  var results = validator.Validate(this);
  return results;
}

Этот метод применяет верификаторы, перечисленные в наборе правил IsValidForRegistration для указанного экземпляра.

И последнее замечание по проверки и библиотекам. Я не рассматривал все популярные библиотеки проверки, но между ними нет особой разницы. Важно понять, меняются ли ваши бизнес-правила и насколько часто. Исходя из этого, вы можете выбрать Data Annotations, VAB, Code Contracts или какую-то другую библиотеку. Как показывает мой опыт, если вы точно знаете, чего хотите добиться, то выбрать «правильную» библиотеку не составит труда.

Заключение

Объектная модель для реалистичного бизнес-домена вряд ли окажется простым набором свойств и классов. Более того, выбору технологий предшествуют проектировочные решения. Хорошо продуманная объектная модель выражает все необходимые аспекты предметной области. По большей части это означает, что у вас есть классы, которые легко инициализировать и проверять, с богатыми свойствами и логикой. Методике DDD не нужно следовать догматически — считайте ее путеводной нитью.


Дино Эспозито (Dino Esposito) — автор книги «Programming Microsoft ASP.NET MVC3» (Microsoft Press, 2011) и соавтор «Microsoft .NET: Architecting Applications for the Enterprise» (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. Читайте его заметки на twitter.com/despos.

Выражаю благодарность за рецензирование статьи экспертам Мануэлю Фандриху (Manuel Fahndrich) и Андреа Салтарелло (Andrea Saltarello).