Как стать автором
Обновить

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

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

Я познакомился с этим термином в процессе чтения 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, но реализация не уместится в десять строчек и код будет гораздо сложнее для понимания.

UPD


om2804 и xyzuvw справедливо указали, что IQueryableSpec не отвечает требованиям компоновки. Дело в том, что мне крайне редко приходится сталкиваться с необходимостью сделать ||, а && достигается простым query.Where(spec1).Where(spec2). Я решил провести небольшой рефакторинг, чтобы сделать код чище:

     // Переименуем IQueryableSpecification в IQueryableFilter
    public interface IQueryableFilter<T>
        where T: class 
    {
        IQueryable<T> Apply(IQueryable<T> query);
    }

Есть такая библотека: LinqSpecs. Не нравится мне в ней то, что нужно создавать отдельные типы спецификаций на каждый чих. По мне достаточно Expression<Func<T, bool>>

Воспользуемся Predicate Builder от Pete Montgomery.

        /// <summary>
        /// Creates a predicate that evaluates to true.
        /// </summary>
        public static Expression<Func<T, bool>> True<T>() { return param => true; }

        /// <summary>
        /// Creates a predicate that evaluates to false.
        /// </summary>
        public static Expression<Func<T, bool>> False<T>() { return param => false; }

        /// <summary>
        /// Creates a predicate expression from the specified lambda expression.
        /// </summary>
        public static Expression<Func<T, bool>> Create<T>(Expression<Func<T, bool>> predicate) { return predicate; }

        /// <summary>
        /// Combines the first predicate with the second using the logical "and".
        /// </summary>
        public static Expression<Func<T, bool>> And<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
        {
            return first.Compose(second, Expression.AndAlso);
        }

        /// <summary>
        /// Combines the first predicate with the second using the logical "or".
        /// </summary>
        public static Expression<Func<T, bool>> Or<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
        {
            return first.Compose(second, Expression.OrElse);
        }

Детали реализации метода Compose объяснены по ссылке выше. Теперь добавим синтаксический сахар, чтобы можно было использовать && и || и ограничение IHasId на generic, потому что я не заинтересован в создании спецификаций для Value Object. Данное ограничение не является необходимым, просто мне так кажется лучше.

     public static class SpecificationExtenions
    {
        public static Specification<T> AsSpec<T>(this Expression<Func<T, bool>> expr)
            where T : class, IHasId
            => new Specification<T>(expr);
    }

    public sealed class Specification<T> : IQueryableFilter<T>
        where T: class, IHasId
    {
        public Expression<Func<T, bool>> Expression { get; }

        public Specification(Expression<Func<T, bool>> expression)
        {
            Expression = expression;
            if (expression == null) throw new ArgumentNullException(nameof(expression));
        }

        public static implicit operator Expression<Func<T, bool>>(Specification<T> spec)
            => spec.Expression;

        public static bool operator false(Specification<T> spec)
        {
            return false;
        }

        public static bool operator true(Specification<T> spec)
        {
            return false;
        }

        public static Specification<T> operator &(Specification<T> spec1, Specification<T> spec2)
            => new Specification<T>(spec1.Expression.And(spec2.Expression));

        public static Specification<T> operator |(Specification<T> spec1, Specification<T> spec2)
            => new Specification<T>(spec1.Expression.Or(spec2.Expression));

        public static Specification<T> operator !(Specification<T> spec)
            => new Specification<T>(spec.Expression.Not());

        public IQueryable<T> Apply(IQueryable<T> query)
            => query.Where(Expression);

        public bool IsSatisfiedBy(T obj) => Expression.AsFunc()(obj);
    }

Я привык записывать «выражения-спецификации» статическими полями в классе сущности, к которой они относятся:

     public class Category : HasIdBase<int>
    {
        public static readonly Expression<Func<Category, bool>> NiceRating = x => x.Rating > 50;
        
        public static readonly Expression<Func<Category, bool>> BadRating = x => x.Rating < 10;

        public static readonly Expression<Func<Category, bool>> Active= x => x.IsDeleted == false;

       //...
    }

    var niceCategories = db.Query<Category>.Where(Category.NiceRating);

С учетом кода выше можно переписать вот так:

     public class Category : HasIdBase<int>
    {
        public static readonly Specification<Category> NiceRating
             = new Specification(x => x.Rating > 50);
       //...
    }

    var niceCategories = db.Query<Category>
         .Where((Category.NiceRating || Category.BadRating) && Category.IsActive);

Теперь избавимся от DynamicLinq. Придется немного поработать с деревьями выражений.
public enum Compose
{
    And,
    Or
}

public static Spec<T> AsSpec<T>(this object obj, Compose compose = Compose.And)
            where T : class, IHasId
        {
            var filterProps = obj.GetType()
                .GetPublicProperties()
                .ToArray();

            var filterPropNames = filterProps
                .Select(x => x.Name)
                .ToArray();

            var props = typeof(T)
                .GetPublicProperties()
                .Where(x => filterPropNames.Contains(x.Name))
                .Select(x => new
                {
                    Property = x,
                    Value = filterProps.Single(y => y.Name == x.Name).GetValue(obj)
                })
                .Where(x => x.Value != null)
                .Select(x =>
                {
                    // собираем вручную выражения вида e => e.Prop == Val
                    var parameter = Expression.Parameter(typeof (T));
                    var property = Expression.Property(parameter, x.Property);
                    var body = Expression.Equal(property, Expression.Constant(x.Value));
                    var delegateType = typeof(Func<T, bool>);
                    return (Expression<Func<T, bool>>)
                        Expression.Lambda(delegateType, body, parameter);
                })
                .ToArray();

            if (!props.Any()) return new Spec<T>(x => true);
            
            // и собираем через || или &&
            var expr = compose == Compose.And
                ? props.Aggregate((c, n) => c.And(n))
                : props.Aggregate((c, n) => c.Or(n));

            return expr.AsSpec();
        }
Теги:
Хабы:
+6
Комментарии 15
Комментарии Комментарии 15

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн