Недооцененный паттерн «Спецификация» в связке с паттерном «Репозиторий»

a900715decfcbc0be4c57108e4257e3b.png

Использование спецификации открыло для меня новый мир в создании приложений

Мотивация

Репозитории предоставляют удобное решение для доступа к данным. Однако за многолетний опыт разработки, побывав в нескольких компаниях, сменив кучу проектов я НЕ ВСТРЕЧАЛ паттерн «Спецификация» совместно с паттерном «Репозиторий».

Плюсы:

  • использование абстракций для доступа к данным — правильное решение;

  • спецификация предлагает стандартизованный подход к созданию репозиториев, что облегчает разработку, сопровождение и масштабирование приложений;

  • добавление разных вариаций запросов данных сводится к созданию одной  строки кода;

  • изменение запросов производится на уровне пользовательского кода — нет необходимости менять код репозитория;

  • паттерн спецификация позволяет более гибко работать с фильтрами и операциями And, Or, Not и т. д.

Минусы

Уверен, ребята в комментариях найдут минусы у этого подхода.
Хотя от себя добавлю, что для оптимизированных запросов к БД, код нужно дорабатывать (хотя бы в том плане, чтобы можно было управлять порядком включения зависимых сущностей .Include(_ => _.AnotherEntity))

Реализация спецификации

Схема моей реализации шаблона представлена на Рисунке 1 ниже. Для моих потребностей она оказалась вполне достаточной и покрывающий все кейсы использования. Хочу обратить внимание, что у интерфейса нет методов And() Or() Not(). Благодаря этому нет нарушения Interface Segregation Principle.

Рис 1. UML схема шаблона

Рис 1. UML схема шаблона «Спецификация»

Всего 4 класса и пара вспомогательных позволяют достичь ОГРОМНОЙ гибкости в формировании запросов.

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

Код интерфейса ISpecification

/// 
///     Базовый интерфейс спецификации
/// 
public interface ISpecification : ICloneable
    where TEntity : class
{
    /// 
    ///     Сколько объектов пропустить
    /// 
    int Skip { get; }

    /// 
    ///     Сколько объектов взять
    /// 
    int Take { get; }

    /// 
    ///     Получить необходимые поля для включения
    /// 
    Expression>[] GetIncludes();

    /// 
    ///     Удовлетворяет ли объект условиям
    /// 
    Expression>? SatisfiedBy();

    /// 
    ///     Получить модели для сортировки результатов
    /// 
    OrderModel[] GetOrderModels();
}

Метод Expression>[] GetIncludes()позволяет возвращать функции включения объектов в запрос.

Метод Expression>? SatisfiedBy() занимается проверкой объекта на соответствие условиям перечисленным в Func.

Метод OrderModel[] GetOrderModels() возвращает DTO, хранящие сортирующие выражения, для сортировки результатов запроса.

Код класса OrderModel

/// 
///     Модель для хранения сортирующего выражения
/// 
public class OrderModel
    where TEntity : class
{
    #region .ctor

    /// 
    public OrderModel(Expression> orderExpression, bool needOrderByDescending)
    {
        OrderExpression = orderExpression;
        NeedOrderByDescending = needOrderByDescending;
    }

    #endregion

    #region Properties

    /// 
    ///     Сортирующее выражение
    /// 
    public Expression> OrderExpression { get; }

    /// 
    ///     Нужна ли сортировка по убыванию
    /// 
    public bool NeedOrderByDescending { get; }

    #endregion
}

Абстрактный класс BaseSpecification содержит реализацию свойств Skip и Take, а также перегрузки операторов И (&) и ИЛИ (|). Благодаря чему нет необходимости внедрять методы And() и Or() в базовый интерфейс.

Код класса BaseSpecification

/// 
///     Базовая спецификация для коллекций объектов
/// 
public abstract class SpecificationBase : ISpecification
    where TEntity : class
{
    #region Implementation of ISpecification

    /// 
    public int Skip { get; set; } = 0;

    /// 
    public int Take { get; set; } = int.MaxValue;

    /// 
    public abstract Expression>? SatisfiedBy();

    /// 
    public abstract Expression>[] GetIncludes();

    /// 
    public abstract OrderModel[] GetOrderModels();

    /// 
    public abstract object Clone();

    #endregion

    /// 
    ///     Перегрузка оператора И
    /// 
    public static SpecificationBase operator &(
        SpecificationBase left,
        SpecificationBase right)
    {
        return new AndSpecification(left, right);
    }

    /// 
    ///     Перегрузка оператора ИЛИ
    /// 
    public static SpecificationBase operator |(
        SpecificationBase left,
        SpecificationBase right)
    {
        return new OrSpecification(left, right);
    }
}

Самой простой в реализации является DirectSpecification. Она позволяет создавать одно условное выражение для выбора данных.

Код класса DirectSpecification

/// 
///     Прямая спецификация
/// 
public class DirectSpecification : SpecificationBase
    where TEntity : class
{
    #region Fields

    private readonly List>> _includes = new();
    private readonly Expression>? _matchingCriteria;
    private OrderModel? _orderModel;

    #endregion

    #region .ctor

    /// 
    public DirectSpecification(Expression> matchingCriteria)
    {
        _matchingCriteria = matchingCriteria;
    }

    /// 
    public DirectSpecification()
    { }

    /// 
    protected DirectSpecification(
        List>> includes,
        Expression>? matchingCriteria,
        OrderModel? orderModel)
    {
        _includes = includes;
        _matchingCriteria = matchingCriteria;
        _orderModel = orderModel;
    }

    #endregion

    #region Implementation of SpecificationBase

    /// 
    public override object Clone()
    {
        // NOTE: поскольку список не смотрит из объекта явно,
        // то нет необходимости перекопировать его полностью включая внутренние элементы
        // аналогично и с моделью сортировки, считается, что она неизменяемая
        return new DirectSpecification(_includes, _matchingCriteria, _orderModel);
    }

    /// 
    public override Expression>? SatisfiedBy()
        => _matchingCriteria;

    /// 
    public override Expression>[] GetIncludes()
        => _includes.ToArray();

    /// 
    public override OrderModel[] GetOrderModels()
    {
        return _orderModel is null ? Array.Empty>() : new[] { _orderModel };
    }

    #endregion

    #region Public methods

    /// 
    ///     Добавить включение
    /// 
    public DirectSpecification AddInclude(Expression> includeExpression)
    {
        _includes.Add(includeExpression);

        return this;
    }

    /// 
    ///     Установить модель сортировки
    /// 
    public DirectSpecification SetOrder(OrderModel orderModel)
    {
        _orderModel = orderModel;

        return this;
    }

    #endregion
}

«И» и «ИЛИ» спецификации между собой очень похожи, их код приведен ниже. Их конструкторы принимают в аргументах две другие спецификации ISpecification, которые могут быть как составными (тоже «И» или «ИЛИ»), так и простые спецификации (например две реализации через DirectSpecification), так и комбинации простой и составной спецификации.

Код класса AndSpecification

/// 
///     Спецификация И
/// 
public sealed class AndSpecification : SpecificationBase
   where TEntity : class
{
    #region Fields

    private readonly ISpecification _rightSideSpecification;
    private readonly ISpecification _leftSideSpecification;

    #endregion

    #region .ctor

    /// 
    public override object Clone()
    {
        var left = (ISpecification)_leftSideSpecification.Clone();
        var right = (ISpecification)_leftSideSpecification.Clone();

        return new AndSpecification(left, right);
    }

    /// 
    public AndSpecification(
        ISpecification leftSide,
        ISpecification rightSide)
    {
        Assert.NotNull(leftSide, "Left specification cannot be null");
        Assert.NotNull(rightSide, "Right specification cannot be null");

        _leftSideSpecification = leftSide;
        _rightSideSpecification = rightSide;
    }

    #endregion

    #region Implementation Of SpecificationBase

    /// 
    public override Expression>? SatisfiedBy()
    {
        var left = _leftSideSpecification.SatisfiedBy();
        var right = _rightSideSpecification.SatisfiedBy();
        if (left is null && right is null)
        {
            return null;
        }

        if (left is not null && right is not null)
        {
            return left.And(right);
        }

#pragma warning disable IDE0046 // Convert to conditional expression
        if (left is not null)
        {
            return left;
        }
#pragma warning restore IDE0046 // Convert to conditional expression

        return right;
    }

    /// 
    public override Expression>[] GetIncludes()
    {
        var leftIncludes = _leftSideSpecification.GetIncludes();
        var rightIncludes = _rightSideSpecification.GetIncludes();

        leftIncludes.AddRange(rightIncludes);

        return leftIncludes;
    }

    /// 
    public override OrderModel[] GetOrderModels()
    {
        var leftOrderModels = _leftSideSpecification.GetOrderModels();
        leftOrderModels.AddRange(_rightSideSpecification.GetOrderModels());

        return leftOrderModels;
    }

    #endregion
}

Код класса OrSpecification

/// 
///     Спецификация ИЛИ
/// 
public class OrSpecification : SpecificationBase
    where TEntity : class
{
    #region Fields

    private readonly ISpecification _leftSideSpecification;
    private readonly ISpecification _rightSideSpecification;

    #endregion

    #region .ctor

    /// 
    public OrSpecification(
        ISpecification left,
        ISpecification right)
    {
        Assert.NotNull(left, "Left specification cannot be null");
        Assert.NotNull(right, "Right specification cannot be null");

        _leftSideSpecification = left;
        _rightSideSpecification = right;
    }

    #endregion

    #region Implemtation of SpecificationBase

    /// 
    public override object Clone()
    {
        var left = (ISpecification)_leftSideSpecification.Clone();
        var  right = (ISpecification)_leftSideSpecification.Clone();

        return new OrSpecification(left, right);
    }

    /// 
    public override Expression>? SatisfiedBy()
    {
        var left = _leftSideSpecification.SatisfiedBy();
        var right = _rightSideSpecification.SatisfiedBy();
        if (left is null && right is null)
        {
            return null;
        }

        if (left is not null && right is not null)
        {
            return left.Or(right);
        }

#pragma warning disable IDE0046 // Convert to conditional expression
        if (left is not null)
        {
            return left;
        }

        return right;
    }

    /// 
    public override Expression>[] GetIncludes()
    {
        var leftIncludes = _leftSideSpecification.GetIncludes();
        var rightIncludes = _rightSideSpecification.GetIncludes();

        leftIncludes.AddRange(rightIncludes);

        return leftIncludes;
    }

    /// 
    public override OrderModel[] GetOrderModels()
    {
        var leftOrderModels = _leftSideSpecification.GetOrderModels();
        leftOrderModels.AddRange(_rightSideSpecification.GetOrderModels());

        return leftOrderModels;
    }

    #endregion
}

Обе они реализуют метод SatisfiedBy() базового класса SpecificationBase, объединяя два Expression, полученных от вызовов методов двух спецификаций, которые были переданы в конструктор.

Реализация репозиториев

Схема моей реализации репозиториев совместно с использованием паттерна «Спецификация» представлена на Рисунке 2 ниже.

Рис 2. UML схема паттерна

Рис 2. UML схема паттерна «Репозиторий» совместно со «Спецификацией»

В целом первые 4 метода (Get, GetStrict, List, Any), представленные в IRepository, реализуются единожды в базовом абстрактном классе StorageBase и больше никогда не изменятся.

Код интерфейса IStorage

/// 
///     Общий интерфейс хранилищ
/// 
public interface IStorage
    where T : class
{
    /// 
    ///     Добавляет новую модель в хранилище
    /// 
    void Add(T model);
    
    /// 
    ///     Удалить
    /// 
    void Remove(T model);

    /// 
    ///     Находит модель по идентификатору
    /// 
    ///  Спецификация получения данных 
    ///  Модель 
    T? Get(ISpecification specification);

    /// 
    ///     Находит модель по идентификатору, бросает ошибку, если не найдено
    /// 
    ///  Спецификация получения данных 
    ///  Код ошибки, если модель не найдена 
    ///  Модель 
    /// 
    ///     Ошибка с кодом , если модель не найдена
    /// 
    T GetStrict(ISpecification specification, string errorCode);

    /// 
    ///     Определяет соответствуют ли выбранные объекты условиям спецификации
    /// 
    ///  Спецификация 
    bool Any(ISpecification specification);

    /// 
    ///     Получить сущности
    /// 
    ///  Спецификация 
    IEnumerable GetMany(ISpecification specification);
}

Ниже приведены реализации методов (Get, GetStrict, List, Any), они максимально просты и понятны, но при этом максимально «гибкие», благодаря спецификациям.

    /// 
    public bool Any(ISpecification specification)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification)
              .Any();

    /// 
    public T? Get(ISpecification specification)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification)
              .FirstOrDefault();

    /// 
    public T GetStrict(ISpecification specification, string errorCode)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification)
              .FirstOrDefault() ?? throw new ErrorException(errorCode);

    /// 
    public IEnumerable GetMany(ISpecification specification)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification);

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

Внимательные читатели задались вопросом про странный класс, появившийся непонятно откуда

Да) это класс SpecificationEvaluator

Этот класс позволяет формировать запросы на основе переданной спецификации к базе данных, хранилищам в RAM или другим источникам данных. Реализация для IEnumerableполностью аналогична, ее не буду приводить, для облегчения восприятия.

/// 
///     Создает запрос к базе данных на основе спецификации
/// 
public class SpecificationEvaluator
{
    #region IQueryable

    /// 
    ///     Получить сформированный запрос
    /// 
    public static IQueryable GetQuery(
        IQueryable inputQuery,
        ISpecification specification)
        where TEntity : class
    {
        var query = inputQuery;

        // включаю в запрос необходимые дополнительные сущности
        query = specification
            .GetIncludes()
            .Aggregate(query, static (current, include) => current.Include(include));

        // отбираю только необходимые объекты
        var whereExp = specification.SatisfiedBy();
        if (whereExp is not null)
        {
            query = query.Where(whereExp)!;
        }

        // получаю модели для сортировки
        var orderModels = specification.GetOrderModels();
        if (!orderModels.Any())
        {
            return query
                .Skip(specification.Skip)
                .Take(specification.Take);
        }

        // сортирую
        var orderedQuery = AddFirstOrderExpression(query, orderModels.First());
        foreach (var orderModel in orderModels.Skip(1))
        {
            orderedQuery = AddAnotherOrderExpression(orderedQuery, orderModel);
        }

        return orderedQuery
            .Skip(specification.Skip)
            .Take(specification.Take);
    }

    /// 
    ///     Добавить сортировку в самый первый раз
    /// 
    private static IOrderedQueryable AddFirstOrderExpression(
        IQueryable query,
        OrderModel orderModel)
        where TEntity : class
    {
        return orderModel.NeedOrderByDescending
            ? query.OrderByDescending(orderModel.OrderExpression)
            : query.OrderBy(orderModel.OrderExpression);
    }

    /// 
    ///     Продолжить добавление сортировок
    /// 
    private static IOrderedQueryable AddAnotherOrderExpression(
        IOrderedQueryable query,
        OrderModel orderModel)
        where TEntity : class
    {
        return orderModel.NeedOrderByDescending
            ? query.ThenByDescending(orderModel.OrderExpression)
            : query.ThenBy(orderModel.OrderExpression);
    }

    #endregion
}

Применение

Для упрощения понимания использования, приведу конкретный пример. Представим, что у нас есть класс Man — человек со свойствами имя, возраст и пол:

/// 
///     Человек
/// 
internal sealed class Man
{
    /// 
    ///     Возраст
    /// 
    public int Age { get; set; }

    /// 
    ///     Имя
    /// 
    public string Name { get; set; }

    /// 
    ///     Пол
    /// 
    public GenderType Gender { get; set; }
}

/// 
///     Определяет пол человека
/// 
internal enum GenderType
{
    /// 
    ///     Мужчина
    /// 
    Male,

    /// 
    ///     Женщина
    /// 
    Female
}

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

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

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

Буду показывать на примере RAM хранилища данных для более легкого воспроизведения.

/// 
///     Самое неоптимальное хранилище моделей людей
/// 
internal sealed class BadManRepository1 : StorageBase
{
    private ImmutableArray _storage = ImmutableArray.Empty;

    /// 
    public override void Add(Man model)
    {
        _storage = _storage.Add(model);
    }

    /// 
    public override void Remove(Man model)
    {
        _storage = _storage.Remove(model);
    }

    /// 
    public Man Get(string name)
        => CreateQuery().FirstOrDefault(_ => _.Name == name);

    /// 
    public Man Get(int age)
        => CreateQuery().FirstOrDefault(_ => _.Age == age);

    /// 
    public Man Get(GenderType gender)
        => CreateQuery().FirstOrDefault(_ => _.Gender == gender);

    /// 
    public Man Get(string name, int age)
        => CreateQuery().FirstOrDefault(_ => _.Name == name && _.Age == age);

    /// 
    public Man Get(string name, GenderType gender)
        => CreateQuery().FirstOrDefault(_ => _.Name == name && _.Gender == gender);

    /// 
    public Man Get(string name, int age, GenderType gender)
        => CreateQuery().FirstOrDefault(_ => _.Name == name && _.Age == age && _.Gender == gender);

    /// 
    public Man Get(string name, int age)
        => CreateQuery().FirstOrDefault(_ => _.Name == name || _.Age == age);
}

Вариант выше содержит много метода Get, но даже они не покрывают всех возможных вариантов фильтрации для трех полей, которых с учетом операторов И и ИЛИ уже я насчитал 11, а что будет для большего количества полей, а если еще нужны методы Any(), List() с такими же условиями фильтрации?

Другой подход уменьшает количество методов в репозитории, но увеличивает количество строк кода в каждом из них. Он тоже не является оптимальным. Привел реализацию только метода Get с И оператором. Нужно также реализовать Get с ИЛИ и Get с вариациями И и ИЛИ. Все это займет кучу кода и при добавлении нового свойства в класс Man, придется изменять каждый из этих методов или добавлять новые.

/// 
///     Неоптимальное хранилище моделей людей
/// 
internal sealed class BadManRepository2 : StorageBase
{
    private ImmutableArray _storage = ImmutableArray.Empty;

    /// 
    public Man Get(GetManRequest request)
    {
        var query = CreateQuery();
        if (request.Name is not null)
        {
            query = query.Where(x => x.Name == request.Name);
        }
        if (request.Age is not null)
        {
            query = query.Where(x => x.Age == request.Age);
        }
        if (request.Gender is not null)
        {
            query = query.Where(x => x.Gender == request.Gender);
        }

        return query.FirstOrDefault();
    }
}

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

/// 
///     Хранилище моделей людей
/// 
internal sealed class ManRepository : StorageBase
{
    private ImmutableArray _storage = ImmutableArray.Empty;

    /// 
    public override void Add(Man model)
    {
        _storage = _storage.Add(model);
    }

    /// 
    public override void Remove(Man model)
    {
        _storage = _storage.Remove(model);
    }

    /// 
    protected override IEnumerable CreateQuery() => _storage;
}

Вот так мы добавили в нашу систему новую сущность и репозиторий для работы с ней. Теперь покажу как можно использовать этот репозиторий. Для удобства создам статический класс, создающий спецификации:

/// 
///     Статический класс для создания спецификации для получения 
/// 
internal static class ManSpecification
{
    /// 
    ///     С именем
    /// 
    public static ISpecification WithName(string name)
        => new DirectSpecification(_ => _.Name == name);

    /// 
    ///     С возрастом
    /// 
    public static ISpecification WithAge(int age)
        => new DirectSpecification(_ => _.Age == age);

    /// 
    ///     С гендером
    /// 
    public static ISpecification WithGender(GenderType gender)
        => new DirectSpecification(_ => _.Gender == gender);

    /// 
    ///     Сортировать по возрасту
    /// 
    public static ISpecification OrderByAge(bool orderByDescending = false)
        => new DirectSpecification().SetOrder(new(static _ => _.Age, orderByDescending));

    /// 
    ///     Сортировать по имени
    /// 
    public static ISpecification OrderByName(bool orderByDescending = false)
        => new DirectSpecification().SetOrder(new(static _ => _.Name, orderByDescending));
}

Тогда применение будет выглядеть следующим образом:

    public static int Main()
    {
        var repository = new ManRepository();

        var spec1 = ManSpecification.WithName("Коля")
            & ManSpecification.WithAge(26);

        var man1 = repository.Get(spec1);

        var spec2 = ManSpecification.WithName("Коля") | ManSpecification.WithAge(26);
        var men2 = repository.Get(spec2);

        var spec3 = (ManSpecification.WithName("Женя") | ManSpecification.WithAge(26))
            & ManSpecification.WithGender(GenderType.Male);
        var men3 = repository.Get(spec2);

        var spec4 = (ManSpecification.WithName("Женя") | ManSpecification.WithAge(26))
            & ManSpecification.WithGender(GenderType.Male)
            & ManSpecification.OrderByAge();
        var orderedMen4 = repository.Get(spec2);
    }

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

Заключение

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

© Habrahabr.ru