Паттерн проектирования "Спецификация" в .NET
Сталкиваясь с DDD парадигмой вы точно столкнетесь с спецификацией. Спецификация часто используется вместе с паттерном «Репозиторий». Паттерн "спецификация" предоставляет возможность описывать требования к бизнес-объектам, и затем использовать их (и их композиции) для фильтрации не дублируя запросы.
в Википедии этот паттерн описан так:
«Спецификация» в программировании — это шаблон проектирования, посредством которого представление правил бизнес логики может быть преобразовано в виде цепочки объектов, связанных операциями булевой логики.
Изучив несколько статей про спецификацию с других источников:
- Шаблон проектирования «Спецификация»;
- Шаблон проектирования «Спецификация» в C#;
- Прогноз на Specification pattern в Domain layer — ожидаются проблемы;
- Specification pattern: C# implementation;
я решил объединить информацию с нескольких источников и описать полноценно этот паттерн в этой статье.
Начнем с того что рассмотрим UML схему классического шаблона спецификация:
Классическая реализация спецификации в .NET будет выглядеть следующим образом:
public interface ISpecification
{
bool IsSatisfiedBy(object candidate);
ISpecification And(ISpecification other);
ISpecification AndNot(ISpecification other);
ISpecification Or(ISpecification other);
ISpecification OrNot(ISpecification other);
ISpecification Not();
}
public abstract class CompositeSpecification : ISpecification
{
public abstract bool IsSatisfiedBy(object candidate);
public ISpecification And(ISpecification other)
{
return new AndSpecification(this, other);
}
public ISpecification AndNot(ISpecification other)
{
return new AndNotSpecification(this, other);
}
public ISpecification Or(ISpecification other)
{
return new OrSpecification(this, other);
}
public ISpecification OrNot(ISpecification other)
{
return new OrNotSpecification(this, other);
}
public ISpecification Not()
{
return new NotSpecification(this);
}
}
public class AndSpecification : CompositeSpecification
{
private readonly ISpecification _leftCondition;
private readonly ISpecification _rightCondition;
public AndSpecification(ISpecification left, ISpecification right)
{
_leftCondition = left;
_rightCondition = right;
}
public override bool IsSatisfiedBy(object candidate)
{
return _leftCondition.IsSatisfiedBy(candidate) && _rightCondition.IsSatisfiedBy(candidate);
}
}
public class AndNotSpecification : CompositeSpecification
{
private readonly ISpecification _leftCondition;
private readonly ISpecification _rightCondition;
public AndNotSpecification(ISpecification left, ISpecification right)
{
_leftCondition = left;
_rightCondition = right;
}
public override bool IsSatisfiedBy(object candidate)
{
return _leftCondition.IsSatisfiedBy(candidate) && _rightCondition.IsSatisfiedBy(candidate) != true;
}
}
public class OrSpecification : CompositeSpecification
{
private readonly ISpecification _leftCondition;
private readonly ISpecification _rightCondition;
public OrSpecification(ISpecification left, ISpecification right)
{
_leftCondition = left;
_rightCondition = right;
}
public override bool IsSatisfiedBy(object candidate)
{
return _leftCondition.IsSatisfiedBy(candidate) || _rightCondition.IsSatisfiedBy(candidate);
}
}
public class OrNotSpecification : CompositeSpecification
{
private readonly ISpecification _leftCondition;
private readonly ISpecification _rightCondition;
public OrNotSpecification(ISpecification left, ISpecification right)
{
_leftCondition = left;
_rightCondition = right;
}
public override bool IsSatisfiedBy(object candidate)
{
return _leftCondition.IsSatisfiedBy(candidate) || _rightCondition.IsSatisfiedBy(candidate) != true;
}
}
public class NotSpecification : CompositeSpecification
{
private readonly ISpecification _wrapped;
public NotSpecification(ISpecification x)
{
_wrapped = x;
}
public override bool IsSatisfiedBy(object candidate)
{
return !_wrapped.IsSatisfiedBy(candidate);
}
}
С появлением Generic'ов код можно переписать проще:
public interface ISpecification<T>
{
bool IsSatisfiedBy(T candidate);
ISpecification<T> And(ISpecification<T> other);
ISpecification<T> AndNot(ISpecification<T> other);
ISpecification<T> Or(ISpecification<T> other);
ISpecification<T> OrNot(ISpecification<T> other);
ISpecification<T> Not();
}
public abstract class LinqSpecification<T> : CompositeSpecification<T>
{
public abstract Expression<Func<T, bool>> AsExpression();
public override bool IsSatisfiedBy(T candidate) => AsExpression().Compile()(candidate);
}
public abstract class CompositeSpecification<T> : ISpecification<T>
{
public abstract bool IsSatisfiedBy(T candidate);
public ISpecification<T> And(ISpecification<T> other) => new AndSpecification<T>(this, other);
public ISpecification<T> AndNot(ISpecification<T> other) => new AndNotSpecification<T>(this, other);
public ISpecification<T> Or(ISpecification<T> other) => new OrSpecification<T>(this, other);
public ISpecification<T> OrNot(ISpecification<T> other) => new OrNotSpecification<T>(this, other);
public ISpecification<T> Not() => new NotSpecification<T>(this);
}
public class AndSpecification<T> : CompositeSpecification<T>
{
readonly ISpecification<T> _left;
readonly ISpecification<T> _right;
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override bool IsSatisfiedBy(T candidate) => _left.IsSatisfiedBy(candidate) &amp;amp;amp;&amp;amp;amp; _right.IsSatisfiedBy(candidate);
}
public class AndNotSpecification<T> : CompositeSpecification<T>
{
readonly ISpecification<T> _left;
readonly ISpecification<T> _right;
public AndNotSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override bool IsSatisfiedBy(T candidate) => _left.IsSatisfiedBy(candidate) &amp;amp;amp;&amp;amp;amp; !_right.IsSatisfiedBy(candidate);
}
public class OrSpecification<T> : CompositeSpecification<T>
{
readonly ISpecification<T> _left;
readonly ISpecification<T> _right;
public OrSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override bool IsSatisfiedBy(T candidate) => _left.IsSatisfiedBy(candidate) || _right.IsSatisfiedBy(candidate);
}
public class OrNotSpecification<T> : CompositeSpecification<T>
{
readonly ISpecification<T> _left;
readonly ISpecification<T> _right;
public OrNotSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override bool IsSatisfiedBy(T candidate) => _left.IsSatisfiedBy(candidate) || !_right.IsSatisfiedBy(candidate);
}
public class NotSpecification<T> : CompositeSpecification<T>
{
readonly ISpecification<T> _other;
public NotSpecification(ISpecification<T> other) => _other = other;
public override bool IsSatisfiedBy(T candidate) => !_other.IsSatisfiedBy(candidate);
}
Перейдем от абстракций к более-менее реальному примеру.
Допустим, у нас есть портал который хранит в себе список фильмов и отдает их по определенному запросу:
public class Movie : Entity
{
public string Name { get; }
public DateTime ReleaseDate { get; }
public MpaaRating MpaaRating { get; }
public string Genre { get; }
public double Rating { get; }
}
public enum MpaaRating
{
G,
PG13,
R
}
Давайте представим, что пользователям нужно будет получать фильмы по дате, рейтингу, жанру.
Для этого мы создадим IMovieRepository
:
public interface IMovieRepository
{
IReadOnlyList<Movie> GetByReleaseDate(DateTime maxReleaseDate);
IReadOnlyList<Movie> GetByRating(double minRating);
IReadOnlyList<Movie> GetByGenre(string genre);
}
Теперь, когда мы захотим объединить все критерии поиска, код может усложниться, пока добавим еще один метод Find, который обработает все возможные критерии и вернет консолидированный результат:
public class MovieRepository
{
public IReadOnlyList<Movie> Find(
DateTime? maxReleaseDate = null,
double minRating = 0,
string genre = null)
{
/* ... */
}
}
Проблема возникает тогда, когда нам нужно делать проверку не только на этапе запроса к БД. но и в бизнес логике как тут:
public ResultBuyChildTicket(int movieId)
{
Movie movie = _repository.GetById(movieId);
if (movie.MpaaRating != MpaaRating.G)
return Error("The movie is not eligible for children");
return Ok();
}
Если на уровне БД нам так же нужно фильтровать фильмы, мы добавим такой метод:
public class MovieRepository
{
public IReadOnlyList<Movie> FindMoviesForChildren()
{
return db
.Where(x => x.MpaaRating == MpaaRating.G)
.ToList();
}
}
Проблема этих двух примеров в том, что они нарушают DRY принцип, поскольку метод проверки того, что является детским фильмом теперь "расплывается" в 2 метода (а по факту даже в 2 слоя приложения DAL и BLL). В таких случаях нас может выручить паттерн "спецификация". Мы можем создать класс-спецификацию который будет проверять можно ли показывать этот фильм детям и выдавать нам результат:
public ResultBuyChildTicket(int movieId)
{
Movie movie = _repository.GetById(movieId);
var spec = new MovieForKidsSpecification();
if (!spec.IsSatisfiedBy(movie))
return Error("The movie is not eligible for children");
return Ok();
}
public class MovieRepository
{
public IReadOnlyList<Movie> Find(Specification<Movie> specification)
{
/* ... */
}
}
Этот подход позволяет не только убрать дублирование кода в разных доменных областях, но и позволяет комбинировать спецификации. Это в свою очередь позволяет "Аплаить" сколько спецификаций, сколько нам нужно.
Три случая когда стоит использовать шаблон проектирования "спецификация":
- Поиск данных в базе данных. Это поиск записей который соответствуют спецификации, которую мы имеем реализовали.
- Проверка объектов в памяти. Другими словами, проверка того, что объект, который мы извлекли из БД соответствует спецификации.
- Создание нового экземпляра, который соответствует критериям. Это полезно в тех случаях, когда вы не заботитесь о реальном содержании экземпляров, но все же должны иметь определенные атрибуты.
GenericSpecification
Создавая спецификацию. у может нас появится желание создать генерик который будет обслуживать нужную нам спецификацию, например:
public class GenericSpecification<T>
{
public Expression<Func<T , bool>> Expression { get; }
public GenericSpecification(Expression<Func<T , bool>> expression)
{
Expression = expression;
}
public bool IsSatisfiedBy(T entity)
{
return Expression.Compile().Invoke(entity);
}
}
// Controller
public void SomeMethod()
{
var specification = new GenericSpecification<Movie>(
m => m.MpaaRating == MpaaRating.G);
bool isOk = specification.IsSatisfiedBy(movie); // Exercising a single movie
var movies = _repository.Find(specification); // Getting a list of movies
}
// Repository
public IReadOnlyList<Movie> Find(GenericSpecification<Movie> specification)
{
return db
.Where(specification.Expression)
.ToList();
}
Проблема такой реализации заключается в том что, хоть мы и создаем еще один уровень абстракции, но мы не решаем проблему.
Помните (!) Вынос проблемы на другой уровень абстракции не решает эту проблему.
Generic спецификация не помогает решить DRY диллему, а значит не сильно нам подходит, да и в целом GenericSpecification - это плохая практика тк сами по себе они бесполезны.
Строго-типизированные спецификации
Как мы можем исправить проблему которая у нас возникла выше?
Решением этой проблемы будет создание строго-типизированной спецификации. Эта спецификация будет в себе иметь все нужные доменные знания без возможности изменять их извне.
Перепишем наш код:
public abstract class Specification<T>
{
public abstract Expression<Func<T , bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
{
Func<T , bool> predicate = ToExpression().Compile();
return predicate(entity);
}
}
public class MpaaRatingAtMostSpecification : Specification<Movie>
{
private readonly MpaaRating_rating;
public MpaaRatingAtMostSpecification(MpaaRating rating)
{
_rating = rating;
}
public override Expression<Func<Movie, bool>> ToExpression()
{
return movie => movie.MpaaRating <= _rating;
}
}
// Controller
public void SomeMethod()
{
var gRating = new MpaaRatingAtMostSpecification(MpaaRating.G);
bool isOk = gRating.IsSatisfiedBy(movie); // Exercising a single movie
IReadOnlyList<Movie> movies = repository.Find(gRating); // Getting a list of movies
}
// Repository
public IReadOnlyList<T> Find(Specification<T> specification)
{
using (ISession session = SessionFactory.OpenSession())
{
return session.Query<T>()
.Where(specification.ToExpression())
.ToList();
}
}
Благодаря такому уровню абстракции, нам не нужно теперь следить за тем как мы создаем спецификации и за их экземплярами, так же это решает проблему дублирования. Спецификации легко поддаются таким операциям как And, Or, Not и тд, для этого нам нужно добавить соответствующий код, например для And операции:
public abstract class Specification<T>
{
public Specification<T> And(Specification<T> specification)
{
return new AndSpecification<T>(this, specification);
}
// And also Or and Not methods
}
public class AndSpecification<T> : Specification<T>
{
private readonly Specification<T> _left;
private readonly Specification<T> _right;
public AndSpecification(Specification<T> left, Specification<T> right)
{
_right = right;
_left = left;
}
public override Expression<Func<T , bool>> ToExpression()
{
Expression<Func<T , bool>> leftExpression = _left.ToExpression();
Expression<Func<T , bool>> rightExpression = _right.ToExpression();
BinaryExpression andExpression = Expression.AndAlso(
leftExpression.Body, rightExpression.Body);
return Expression.Lambda<Func<T , bool>>(
andExpression, leftExpression.Parameters.Single());
}
}
var gRating = new MpaaRatingAtMostSpecification(MpaaRating.G);
var goodMovie = new GoodMovieSpecification();
var repository = new MovieRepository();
IReadOnlyList<Movie> movies = repository.Find(gRating.And(goodMovie));
Чего просто не возвращать IQueryable<T>
в репозитории?
Разве не проще позволить клиентам запрашивать те данные которые они хотят реализовав метод Find который возвращает IQueryable<T>
?
// Repository
public IQueryable<T> Find()
{
return session.Query<T>();
}
и потом уже использовать в контроллере (или любом другом месте где вы юзаете этот репозиторий) нужный запрос
public void SomeMethod()
{
List<Movie> movies = _repository.Find()
.Where(movie => movie.MpaaRating == MpaaRating.G)
.ToList();
}
Этот подход, по сути, имеет тот же недостаток, что и наша первоначальная реализация: он побуждает нас нарушать принцип DRY, дублируя знания предметной области. Этот метод не предлагает нам ничего с точки зрения консолидации в одном месте.
Вторым недостатком здесь является то, что мы открываем контроль над DAL напрямую в контроллер. Что является болезненной и плохой практикой в многоуровневой архитектуре. Стоит помнить что, реализация IQueryable <T> в значительной степени зависит от того, какой "поставщик" LINQ используется за кулисами, поэтому клиентский код должен знать, что потенциально существуют запросы, которые не могут быть скомпилированы в SQL.
Так же этот код нарушает еще один принцип The Liskov Substitution Principle (LSP).
Extension method vs specification
Возникает вопрос: а зачем городить спецификацию, если можно просто создать Extension, например:
public class MoviesExtensions
{
public static IQueryable<Movie> IsForChild(this IQueryable<Movie> users, MpaaRating rating)
{
return users.Where(x => x.MpaaRating == rating);
}
}
Конечно такой подход тоже может использоваться для одного или нескольких сгруппированных условий вместо спецификации. Паттерн спецификация позволяет более гибко работать с фильтрами и операциями And, Or, Not и тд.
Спецификация так же отлично работает и в паре с экстеншн методами.
Например, если вы реализуете SoftDelete подход через Extension:
public static IQueryable<T> Undeleted<T>(this IQueryable<T> queryable) where T : Entity
{
return queryable.Where(x => !x.IsDeleted);
}
вы можете спокойно юзать Extension, который будет общим для каждого из DBSet'ов где нужна поддержка SoftDelete и использовать его в паре с конкретными спецификациями в конкретных сервисах (репозиториях).
Итоги:
- Не используйте GenericSpecification в паре с
Expression<Func<T , bool>>
в качестве реализации шаблона спецификаций, они не позволяют фактически собрать знания о предметной области в одно место. - Не возвращайте
IQueryable <T>
из репозиториев, это вызывает проблемы с нарушением DRY и LSP. - Тот или иной паттерн не панацея от всех проблем, используйте паттерны с умом и применяйте их там где они действительно нужны, помните и про такие принципы как YAGNI и KISS.