0 8.4K ru

Паттерн проектирования "Спецификация" в .NET

Categories: 💻 Programming

Сталкиваясь с DDD парадигмой вы точно столкнетесь с спецификацией. Спецификация часто используется вместе с паттерном «Репозиторий». Паттерн "спецификация" предоставляет возможность описывать требования к бизнес-объектам, и затем использовать их (и их композиции) для фильтрации не дублируя запросы.

в Википедии этот паттерн описан так:

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

Изучив несколько статей про спецификацию с других источников: 

learning specification

я решил объединить информацию с нескольких источников и описать полноценно этот паттерн в этой статье.

Начнем с того что рассмотрим 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)
    {
        /* ... */
    }
}

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

Три случая когда стоит использовать шаблон проектирования "спецификация":

  1. Поиск данных в базе данных. Это поиск записей который соответствуют спецификации, которую мы имеем реализовали.
  2. Проверка объектов в памяти. Другими словами, проверка того, что объект, который мы извлекли из БД соответствует спецификации.
  3. Создание нового экземпляра, который соответствует критериям. Это полезно в тех случаях, когда вы не заботитесь о реальном содержании экземпляров, но все же должны иметь определенные атрибуты.

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.

Comments:

Please log in to be able add comments.