Паттерн "Спецификация" - отделяем условия

Большинство паттернов проектирования довольно изощренные. Иногда схема, объясняющая, как работает паттерн и для чего он нужен, может занять несколько страниц. Что отличает паттерн "Спецификация", придуманный Мартином Фаулером и Эриком Эвансом, так это его простота. Его и паттерном-то назвать трудно, так, паттерныш какой-то, однако польза от него несомненная. Применить его можно практически в любом приложении.

Для примера возьмем излюбленный пример системы учета заказов. В системе имеются объекты заказчик (Customer) и заказ (Order).

Некоторые заказы требуют особого внимания, так как они являются критическими - пусть это будут заказы на сумму, большую миллиона долларов. Это условие выражается в коде

order.Amount >= 1000000M


Скорее всего, подобное условие будет дублироваться в коде в нескольких местах. Чтобы избежать дублирования, введем спецификацию CriticalOrder.

Начнем, как всегда, с написания теста:

[TestClass]
public class SpecificationTests {
[TestMethod]
public void CriticalOrderTest() {
  var specification = new CriticalOrder();

  Assert.IsFalse(specification.IsSatisfiedBy(new Order() {Amount = 0}));
  Assert.IsFalse(specification.IsSatisfiedBy(new Order() {Amount = 999999}));
  Assert.IsTrue(specification.IsSatisfiedBy (new Order() {Amount = 1000000}));
  Assert.IsTrue(specification.IsSatisfiedBy (new Order() {Amount = 2000000}));
 }
}


И собственно имплементация
public class CriticalOrder {
 public bool IsSatisfiedBy(Order order) {
  return order.Amount >= 1000000;
 }
}


Какой прикуп мы получили от введения спецификации? Имеет ли смысл ли вводить новый класс из-за какого-то одного условия? Возможно, не всегда, но в этом случае имеет. Ценность спецификации в том, что она

  • Отделяет логику условия от объекта, хранящего данные

  • Легко тестируется в изоляции

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

  • Дает условиям понятные мнемонические имена, отражающие их бизнес смысл



Преймущества инкапсуляции, я думаю, объяснять не надо. Бизнес-правила могут измениться, а лучше сказать, что они точно изменятся. Например, в следующей итерации нужно будет выделить крупных заказчиков в особую категорию "KeyCustomers" и считать критическими все заказы этой категории, независимо от суммы. Или по прежнему зависимо, но от другой величины, например $200 000 (сказываются последствия кризиса). Можно сделать спецификацию параметризуемой пределом суммы:

[TestClass]
 public class SpecificationTests {
 [TestMethod]
 public void CriticalOrderTest() {
  var specification = new CriticalOrder(200000);

  var keyCustomer = new Customer {Type = CustomerType.KeyCustomer};
  var otherCustomer = new Customer {Type = CustomerType.Other};

  Assert.IsFalse(specification.IsSatisfiedBy(new Order(keyCustomer) {Amount = 0}));
  Assert.IsFalse(specification.IsSatisfiedBy(new Order(keyCustomer) {Amount = 199999}));
  Assert.IsTrue(specification.IsSatisfiedBy(new Order(keyCustomer) {Amount = 200000}));
  Assert.IsFalse(specification.IsSatisfiedBy(new Order(otherCustomer) {Amount = 200000}));
 }
}

public class CriticalOrder {
 private readonly int minimalAmount;

 public CriticalOrder(int minimalAmount) {
  this.minimalAmount = minimalAmount;
 }

 public bool IsSatisfiedBy(Order order) {
  return order.Customer.Type == CustomerType.KeyCustomer && order.Amount >= minimalAmount;
 }
}


Композитные спецификации
Часто возникает задача выборки элементов, отвечающим каким-то условиям. К примеру, может понадобиться выбрать все заказы определенного заказчика или все заказы за год или все заказы в Россию и так далее. Можно было бы сделать несколько методов в фасаде слоя доступа к данным (Data Access Layer = DAL) типа GetOrdersByCustomer(Customer), GetOrdersByYear(int), GetOrdersByCountry(string) и т.д. А что делать, если условия нужно комбинировать, например получить все заказы в Россию за год? Если разных вариантов запросов много, интерфейс DAL будет неоправданно разрастаться. Можно эту проблему решить с помощью паттерна Query, а можно использовать спецификации.

Спецификация легко комбинируется с паттерном Composite, что позволяет объединять спецификации в сложные условия. В этом случае все спецификации будут наследоваться от базового класса Specification<T>


public abstract class Specification<T> {
 public abstract bool IsSatisfiedBy(T item);

 public static Specification<T> operator !(Specification<T> specification) {
  return new NotSpecification<T>(specification);
 }

 public static Specification<T> operator |(Specification<T> left, Specification<T> right) {
  return new OrSpecification<T>(left, right);
 }

 public static Specification<T> operator &(Specification<T> left, Specification<T> right) {
  return new AndSpecification<T>(left, right);
 }
}


public abstract class CompositeSpecification<T> : Specification<T> {
 protected readonly List<Specification<T>> specifications;

 protected CompositeSpecification(params Specification<T>[] specifications) {
  this.specifications = new List<Specification<T>>(specifications);
 }

 public ReadOnlyCollection<Specification<T>> Specifications {
  get { return specifications.AsReadOnly(); }
 }

 public void Add(Specification<T> specification) {
  specifications.Add(specification);
 }

 public void Remove(Specification<T> specification) {
  specifications.Remove(specification);
 }
}


public class AndSpecification<T> : CompositeSpecification<T> {
 public AndSpecification() {}
 public AndSpecification(params Specification<T>[] specifications) : base(specifications) {}

 public override bool IsSatisfiedBy(T item) {
  foreach (var specification in specifications) {
   if (!specification.IsSatisfiedBy(item)) {
    return false;
   }
  }
  return true;
 }
}


public class OrSpecification<T> : CompositeSpecification<T> {
 public OrSpecification() {}
 public OrSpecification(params Specification<T>[] specifications) : base(specifications) {}

 public override bool IsSatisfiedBy(T item) {
  foreach (var specification in specifications) {
   if (specification.IsSatisfiedBy(item)) {
    return true;
   }
  }
  return false;
 }
}


public class NotSpecification<T> : Specification<T> {
 private readonly Specification<T> specification;

 public Specification<T> Specification {
  get { return specification; }
 }

 public NotSpecification(Specification<T> specification) {
  this.specification = specification;
 }

 public override bool IsSatisfiedBy(T item) {
  return !specification.IsSatisfiedBy(item);
 }
}


Это позволят создавать сложные спецификации. Вот эта, к примеру, выберет все критические заказы в Россию в 2009 году:


var specification =
 new CriticalOrder(CustomerType.KeyCustomer, 200000) &
 new SoldTo(new Country("Russia")) &
 new AsOf(2009);


Фасад DAL в этом случае может иметь всего лишь один метод, принимающий спецификацию и возвращающий список объектов соответствующего типа

public List<T> Get<T>(Specification<T> specification);


Чтобы получить заказы, удовлетворяющие нашей спецификации, достаточно вызвать
infrastructureFacade.Get(specification);


Спецификации валидации
Спецификации удобно использовать для проверки валидности объекта. К примеру, заказ должен удовлетворять требованиям:

  • Сумма от $0 до $10 000 000

  • Дата заказа >= 1 Января 2008

  • Дата отгрузки >= Даты заказа

  • Дата оплаты >= Даты заказа



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


public abstract class ValidationSpecification<T> : Specification<T> {
 public string Validate(T item) {
  return IsSatisfiedBy(item) ? string.Empty : BuildErrorMessage(item);
 }

 protected abstract string BuildErrorMessage(T item);
}


Теперь мы можем создавать валидационные спецификации для каждого случая:

public class ValidAmount : ValidationSpecification {
 public override bool IsSatisfiedBy(Order order) {
  return order.Amount >= 0 && order.Amount < 10000000;
 }
 public override string BuildErrorMessage(Order order) {
  return "Сумма заказа должна лежать в пределах от 0 до 10000000.";
 }
}


public class ValidDate : ValidationSpecification {
 public override bool IsSatisfiedBy(Order order) {
  return order.Date >= new DateTime(2008, 1, 1);
 }
 public override string BuildErrorMessage(Order order) {
  return "Дата заказа должна быть позже 1 Января 2008.";
 }
}


public class ValidShipDate : ValidationSpecification {
 public override bool IsSatisfiedBy(Order order) {
  return order.ShipDate >= order.Date;
 }
 public override string BuildErrorMessage(Order order) {
  return string.Format("Дата отгрузки {0} не может быть меньше даты заказа {1}", order.ShipDate, order.Date);
 }
}


public class ValidPayDate : ValidationSpecification {
 public override bool IsSatisfiedBy(Order order) {
  return order.PayDate >= order.Date;
 }
 public override string BuildErrorMessage(Order order) {
  return string.Format("Дата оплаты {0} не может быть меньше даты заказа {1}", order.PayDate, order.Date);
 }
}


Теперь можно построить композитную спецификацию ValidOrder


public class ValidOrder : AndSpecification {
 public ValidOrder() : base(
  new ValidAmount(),
  new ValidDate(),
  new ValidShipDate(),
  new ValidPayDate()
 ) {}
}


которую можно использовать в нескольких местах:

  • На экране при создании/редактировании заказа

  • При массовом импорте из внешней системы

  • При реконструировании из базы данных



Интеграция с расширенными методами .Net
Добавление к базовому классу Specification явного преобразования к Predicate

public static implicit operator Predicate(Specification specification) {
 return specification.IsSatisfiedBy;
}


волшебным образом позволяет легко и просто использовать спецификации в расширенных методах:

orders.FindAll(new CriticalOrder());


Что мы узнали?
Спецификация - это абстракция условия, накладываемое на объект. Типичные области применения

  • Фильтрация, например при получении списка объектов, удовлетворяющих некоторому условию

  • Валидация, например перед сохранением нужно проверить, что объект находится в корректном состоянии


Спецификация часто может использоваться совместно с паттерном Composite, что позволяет создавать сложные вложенные спецификации. Спецификации легко использовать в расширенных методах языков .Net

Огромное спасибо тем, кто помогал при создании этого материала:
Стас Неверов
Женя Сорокин
Дима Новоселов

Ссылки
/sites/default/files/m/4/2/c/spec.pdf

Уфф :)
Per informazioni complete sulle ottimizzazioni del compilatore, consultare l'Avviso sull'ottimizzazione