Шаблон проектирования «Спецификация» в C#
«Спецификация» в программировании — это шаблон проектирования, посредством которого представление правил бизнес логики может быть преобразовано в виде цепочки объектов, связанных операциями булевой логики.
Я познакомился с этим термином в процессе чтения DDD Эванса. На Хабре есть статьи с описанием практического применения паттерна и проблем, возникающих в процессе реализации.
Если коротко, основное преимущество от использования «спецификаций» в том, чтобы иметь одно понятное место, в котором сосредоточены все правила фильтрации объектов предметной модели, вместо тысячи размазанных ровным слоем по приложению лямбда-выражений.
Классическая реализация шаблона проектирования выглядит так:
public interface ISpecification
{
bool IsSatisfiedBy(object candidate);
}
Что с ним не так применительно к C#?
- Есть
Expression
и> Func
, сигнатура которых совпадает с IsSatisfiedBy> - Есть Extension-методы. alexanderzaytsev с помощью них делает вот так:
public class UserQueryExtensions { public static IQueryable
WhereGroupNameIs(this IQueryable users, string name) { return users.Where(u => u.GroupName == name); } } - А еще можно реализовать вот такую надстройку над LINQ:
public abstract class Specification
{ public bool IsSatisfiedBy(T item) { return SatisfyingElementsFrom(new[] { item }.AsQueryable()).Any(); } public abstract IQueryable SatisfyingElementsFrom(IQueryable candidates); }
В конечном итоге возникает вопрос: стоит ли в C# пользоваться шаблоном десятилетней давности из мира Java и как его реализовать?
Мы решили, что стоит вот таким образом:
public interface IQueryableSpecification
where T: class
{
IQueryable Apply(IQueryable query);
}
public interface IQueryableOrderBy
{
IOrderedQueryable Apply(IQueryable queryable);
}
public static bool Satisfy(this T obj, Func spec) => spec(obj);
public static bool SatisfyExpresion(this T obj, Expression> spec)
=> spec.AsFunc()(obj);
public static bool IsSatisfiedBy(this Func spec, T obj)
=> spec(obj);
public static bool IsSatisfiedBy(this Expression> spec, T obj)
=> spec.AsFunc()(obj);
public static IQueryable Where(this IQueryable source,
IQueryableSpecification spec)
where T : class
=> spec.Apply(source);
Почему не Func
?
От
Func
очень сложно перейти к Expression
. Чаще требуется перенести фильтрацию именно на уровень построения запроса к БД, иначе придется вытаскивать миллионы записей и фильтровать их в памяти, что не оптимально.Почему не Expression>
?
Переход от
Expression
к Func
, напротив, тривиален: var func = expression.Compile()
. Однако, компоновка Expression — отнюдь не тривиальная задача. Еще более не приятно, если требуется условная сборка выражения (например, если спецификация содержит три параметра, два из которых — не обязательные). А совсем плохо Expression>
справляется в случаях, требующих подзапросов вроде query.Where(x => someOtherQuery.Contains(x.Id))
.В конечном итоге, эти рассуждения навели на мысль, что самый простой способ — модифицировать целевой IQueryable
и передавать далее через fluent interface. Дополнительные методы Where позволяют коду выглядеть, словно это обычная цепочка LINQ-преобразований.
Руководствуясь этой логикой, можно выделить абстракцию для сортировки
public static IOrderedQueryable OrderBy(this IQueryable source,
IQueryableOrderBy spec)
where T : class
=> spec.Apply(source);
public interface IQueryableOrderBy
{
IOrderedQueryable Apply(IQueryable queryable);
}
Тогда, добавив Dynamic Linq и немного особой уличной магии
Reflection
, можно написать базовый объект для фильтрации чего-угодно в декларативном стиле. Приведенный ниже код анализирует публичные свойства наследника AutoSpec
и типа, к которому нужно применить фильтрацию. Если совпадение найдено и свойство наследника AutoSpec
заполнено к Queryable
автоматически будет добавлено правило фильтрации по данному полю.public class AutoSpec : IPaging, ILinqSpecification, ILinqOrderBy
where TProjection : class, IHasId
{
public virtual IQueryable Apply(IQueryable 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 ILinqOrderBy.Apply(
IQueryable queryable)
=> !string.IsNullOrEmpty(OrderBy)
? queryable.OrderBy(OrderBy)
: queryable.OrderBy(x => x.Id);
}
AutoSpec
можно реализовать и безDynamic Linq
, с помощью лишьExpression
, но реализация не уместится в десять строчек и код будет гораздо сложнее для понимания.