Шаблон проектирования «Спецификация» в C#

в 11:51, , рубрики: .net, C#, DDD, IsSatisfiedBy, Specification, Проектирование и рефакторинг, Разработка веб-сайтов

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

Я познакомился с этим термином в процессе чтения DDD Эванса. На Хабре есть статьи с описанием практического применения паттерна и проблем, возникающих в процессе реализации.

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

Классическая реализация шаблона проектирования выглядит так:

public interface ISpecification
{
    bool IsSatisfiedBy(object candidate);
}

Что с ним не так применительно к C#?

  1. Есть Expression<Func<T, bool>> и Func<T, bool>>, сигнатура которых совпадает с IsSatisfiedBy
  2. Есть Extension-методы. alexanderzaytsev с помощью них делает вот так:
    public class UserQueryExtensions 
    {
      public static IQueryable<User> WhereGroupNameIs(this IQueryable<User> users,
    string name)
      {
          return users.Where(u => u.GroupName == name);
      }
    }
    

  3. А еще можно реализовать вот такую надстройку над LINQ:
    public abstract class Specification<T>
    {
      public bool IsSatisfiedBy(T item)
      {
        return SatisfyingElementsFrom(new[] { item }.AsQueryable()).Any();
      }
    
       public abstract IQueryable<T> SatisfyingElementsFrom(IQueryable<T> candidates);
    }
    

В конечном итоге возникает вопрос: стоит ли в C# пользоваться шаблоном десятилетней давности из мира Java и как его реализовать?


Мы решили, что стоит вот таким образом:

public interface IQueryableSpecification<T>
    where T: class 
{
    IQueryable<T> Apply(IQueryable<T> query);
}

public interface IQueryableOrderBy<T>
{
    IOrderedQueryable<T> Apply(IQueryable<T> queryable);
}

public static bool Satisfy<T>(this T obj, Func<T, bool> spec) => spec(obj);

public static bool SatisfyExpresion<T>(this T obj, Expression<Func<T, bool>> spec)
=> spec.AsFunc()(obj);

public static bool IsSatisfiedBy<T>(this Func<T, bool> spec, T obj)
=> spec(obj);

public static bool IsSatisfiedBy<T>(this Expression<Func<T, bool>> spec, T obj) 
=> spec.AsFunc()(obj);

public static IQueryable<T> Where<T>(this IQueryable<T> source, 
IQueryableSpecification<T> spec)
    where T : class
    => spec.Apply(source);

Почему не Func<T, bool>?

От Func очень сложно перейти к Expression. Чаще требуется перенести фильтрацию именно на уровень построения запроса к БД, иначе придется вытаскивать миллионы записей и фильтровать их в памяти, что не оптимально.

Почему не Expression<Func<T, bool>>?

Переход от Expression к Func, напротив, тривиален: var func = expression.Compile(). Однако, компоновка Expression — отнюдь не тривиальная задача. Еще более не приятно, если требуется условная сборка выражения (например, если спецификация содержит три параметра, два из которых – не обязательные). А совсем плохо Expression<Func<T, bool>> справляется в случаях, требующих подзапросов вроде query.Where(x => someOtherQuery.Contains(x.Id)).

В конечном итоге, эти рассуждения навели на мысль, что самый простой способ – модифицировать целевой IQueryable и передавать далее через fluent interface. Дополнительные методы Where позволяют коду выглядеть, словно это обычная цепочка LINQ-преобразований.

Руководствуясь этой логикой, можно выделить абстракцию для сортировки

public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, 
IQueryableOrderBy<T> spec)
    where T : class
    => spec.Apply(source);

public interface IQueryableOrderBy<T>
{
    IOrderedQueryable<T> Apply(IQueryable<T> queryable);
}

Тогда, добавив Dynamic Linq и немного особой уличной магии Reflection, можно написать базовый объект для фильтрации чего-угодно в декларативном стиле. Приведенный ниже код анализирует публичные свойства наследника AutoSpec и типа, к которому нужно применить фильтрацию. Если совпадение найдено и свойство наследника AutoSpec заполнено к Queryable автоматически будет добавлено правило фильтрации по данному полю.

public class AutoSpec<TProjection> : IPaging, ILinqSpecification<TProjection>, ILinqOrderBy<TProjection>
    where TProjection : class, IHasId
{
    public virtual IQueryable<TProjection> Apply(IQueryable<TProjection> query)
         => GetType()
        .GetPublicProperties()
        .Where(x => typeof(TProjection).GetPublicProperties()
            .Any(y => x.Name == y.Name))
        .Aggregate(query, (current, next) =>
        {
            var val = next.GetValue(this);
            if (val == null) return current;
            return current.Where(next.PropertyType == typeof(string)
                   ? $"{next.Name}.StartsWith(@0)"
                   : $"{next.Name}=@0", val);
        });

    IOrderedQueryable<TProjection> ILinqOrderBy<TProjection>.Apply(
        IQueryable<TProjection> queryable)
        => !string.IsNullOrEmpty(OrderBy)
        ? queryable.OrderBy(OrderBy)
        : queryable.OrderBy(x => x.Id);
}

AutoSpec можно реализовать и без Dynamic Linq, с помощью лишь Expression, но реализация не уместится в десять строчек и код будет гораздо сложнее для понимания.

Автор: Максим Аршинов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js