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

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

    Я познакомился с этим термином в процессе чтения 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();
            }
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 14
    • 0
      Сигнатура метода в AutoSpec
      IQueryable<TProjection> Apply(IQueryable<TProjection> query)
      
      выглядит странно. Не понятно что оно должно делать. Можете привести пример как этим пользоваться? Спасибо!

      PS: Не знаю как другим, для меня использование =>, когда строчек больше, чем одна, не прибавляет коду читаемости. Скорее наоборот.
      • 0
        public class SomeFilter: AutoSpec<MyNiceEntity>
        {
            public string Name {get;set;}
            
            public int? Rating {get;set;}
        }
        


        Если Name заполнен, то будет query = query.Where(x => x.StartsWith(Name)). Если Rating будет не null, то query = query.Where(x => x.Rating == Rating)
        • 0
          А можете объяснить чего Вы добились, используя такой подход? Т.е. какие плюсы подхода перевесили минусы в вашем случае.
          Потому что
          можно написать базовый объект для фильтрации чего-угодно
          я здесь что-то не увидел. Получаем базовый класс, который подходит только для entities, у которых есть свойства Name и Rating. Очень большой риск использовать этот базовый класс не правильно, что приведёт к ошибке в run time
          • 0
            Вот представьте, что вам нужно выпускать по 30 CRUD-интефрейсов в месяц. Все с фильтрацией, пагинацией, сортировкой и формами для создания/редактирования. Унаследовали вы AutoSpec, добавили названия полей, который вам нужно фильтровать. Готов фильтр. При этом логику фильтрации вы не пишете, вы только указываете названия полей. Сам тип нужен для:
            • Того чтобы явно указать какие поля можно фильтровать/сортировать, а какие — нет
            • Передать мета-информацию на фронт, чтобы тот самый грид с фильтрацией, пагинацией сгенерировать, а не писать руками

            Естественно, что это решение, которое работает на большом объеме простых задач и для запуска космических кораблей в туманность Андромеды не подходит.
      • +1
        Насколько я понимаю, подход AutoSpec применим только к простым ситуациям, когда нужно сравнить значения полей на равенство. Для более сложных условий (например, выборка в диапазоне времени) все равно придется писать свой Expression.

        Но главный недостаток вашего подхода, как мне кажется, это отсутствие поддержки логических операций над ними.На практике очень полезно и удобно комбинировать несколько спецификаций:
        var spec = spec1 && (spec2 || spec3);
        

        При желании можно реализовывать это самому, но проще взять готовую библиотеку.
        • 0
          Абсолютно верно. AutoSpec — для того, чтобы молотить CRUD в огромных количествах. К сожалению && и || по-человечески не переопределишь, потому что это потребует базового класса Specification, чего я делать не хочу по причинам, описанным в начале статьи.
          • 0
            К сожалению && и || по-человечески не переопределишь

            И если опять же смотреть на определение паттерна Спецификация из начала статьи, AutoSpec это точно реализация спецификации? Где же та цепочка объектов, связанных операциями булевой логики?
            • 0
              Я не понял, что вы имеете в виду. Я пользуюсь LinqSpecs и там это сделано, на мой взгляд, достаточно хорошо.
              • 0
                LinqSpecs есть возможности комбинирования. Я узнаю тут паттерн Спецификация
                (spec1 && spec2).Equals(spec1 && spec2);
                

                В случае с AutoSpec такой возможности, как я понял, нет и не предвидится. И вообще спецификация ли это тогда?
                • +1
                  Возможность комбинирования — полезная штука, но, насколько я понимаю, она не является определяющей в паттерне «спецификация». Тот же Эванс приводит в книге примеры спецификаций, которые содержат SQL-запросы и не могут быть скомбинированы с другими спецификациями.
                  • 0
                    Верно, но в начале статьи определение Спецификации построено именно на этом. Аккуратнее с копированием определений паттернов проектирования из вики.
                    • 0
                      om2804, предостерегать от копирования нужно меня, а неxyzuvw :) Я действительно не очень точно сформулировал свои мысли. Посыл был такой: Expression<Func<T, bool>> — это уже компонуемая спека. Но чтобы сделать красивые And и Or нужно попотеть с ParameterRebinder. Поэтому, я решил провести небольшой рефакторинг и теперь код мне нравится даже больше.
                      • 0
                        Предостерегаю всех. Так уж получилось, что диалог состоялся с xyzuvw
          • +1
            Есть такая библотека: LinqSpecs. Не нравится мне в ней то, что нужно создавать отдельные типы спецификаций на каждый чих.

            В LinqSpecs создавать новый класс не обязательно. Можно использовать AdHocSpecification:
            public static Specification<Category> NiceRatingSpec { get; } =
                new AdHocSpecification<Category>(x => x.Rating > 50);
            
            public static Specification<Category> BadRatingSpec { get; } =
                new AdHocSpecification<Category>(x => x.Rating < 10);
            

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.