EntityFramework: (анти)паттерн Repository +43


Repository Pattern

Репозиторий является посредником между слоем доступа к данным и доменным слоем,
работая как in-memory коллекция доменных обектов. Клиенты создают декларативные
описания запросов и передают их в репозиторий для выполнения.
  — свободный перевод Мартина Фаулера

EntityFraemwork предоставляет нам готовую реализацию паттернов Repository: DbSet<T> и UnitOfWork: DbContext. Но мне часто приходится видеть, как коллеги используют в своих проектах собственную реализацию репозиториев поверх существующих в EntityFraemwork.


Чаще всего используется один из двух подходов:


  1. Generic Repository как попытка абстрагироваться от конкретного ORM.
  2. Repository как набор запросов к выбранной таблице БД (паттерн DAO).

И каждый из этих подходов содержит недостатки.


Generic Repository


При обсуждении архитектуры нового проекта часто звучит вопрос: "А вдруг мы захотим сменить ORM?". И ответом на него обычно бывает предложение: "А давайте сделаем Generic Repository, который будет инкапсулировать в себе взаимодействие с конкретной технологией доступа к данным".


И вот у нас появляется новый слой абстракции, который переводит общеизвестное, хорошо спроектированное и задокументированное API любимого ORM в наше кастомное, спроектированное "на коленке" API без документации.


Типичный интерфейс репозитория выглядит так:


public interface IRepository<T>
{
    T Get(int id);
    void Add(T entity);
    void Remove(T entity);
    IEnumerable<T> GetAll();

    // + какие-то специфичные вещи навроде
    IEnumerable<T> Filter(ICriteria<T> criteria);
    void Load(T entity, Expression<Func<T, TProperty>> property);
}

Зато теперь можно спокойно сменить ORM, если это вдруг понадобится.


На самом деле – нет! А вдруг, при реализации нашего чудо-репозитория мы воспользовались уникальными особенностями конкретного ORM? И при миграции на новый ORM нам придется городить костыли в слое бизнес-логики, чтобы как-то эмулировать то, что предыдущий ORM предоставлял "из коробки".


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


Таким образом, чтобы написать Generic Repository нужно:


  1. Собраться с мыслями.
  2. Спроектировать интерфейс.
  3. Написать реализацию под выбранный в проекте ORM.
  4. Написать реализацию под альтернативные ORM.
  5. Удалить из интерфейса все уникальные фичи каждой библиотеки.
  6. Объяснить товарищам по команде, почему они не могут теперь пользоваться уникальными фичами любимого ORM.
  7. Поддерживать реализации под разные ORM в актуальном состоянии. Ведь фреймворки тоже развиваются!
  8. Объяснить менеджеру, почему Вы тратите на это время вместо выполнения непосредственных задач.

К счастью есть люди, которые уже сделали это за нас. И если Вам действительно необходимо быть независимыми от ORM, Вы можете воспользоваться одной из готовых реализаций. Например из проекта ASP.NET Boilerplate. Помимо Repository в нем есть еще много чего интересного.


Но лучше оставить все как есть. Ведь IDbSet<T> уже содержит весь набор CRUD операций. А так же много чего еще (включая асинхронные операции) за счет наследования от IQueryable<T>.


Repository как набор запросов


Часто люди ошибочно называют репозиторием реализацию другого паттерна — Data Access Object. Либо оба этих паттерна реализуются одним и тем же классом. Тогда в дополнение к CRUD-операциям появляются методы: GetByLogin(), GetByName(), etc.


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


Потом появляются запросы, которые джойнят несколько таблиц, и возвращают Data Transfer Object, а не доменный объект. И возникает вопрос, а в какой же репозиторий такие запросы запихнуть? А все потому, что при группировке запросов по таблицам БД, а не по фичам бизнес-логики, нарушается SRP.


В дополнение к этому, методы DAO обладают и другими недостатками:


  • Их трудно тестировать.

Хотя в EF Core эту проблему попытались решить с помощью in-memory DbContext.


  • Они не поддерживают переиспользование и композицию.

Например, если у нас есть интерфейс DAO:


public interface IPostsRepository
{
    IEnumerable<Post> FilterByDate(DateTime date);
    IEnumerable<Post> FilterByTag(string tag);
    IEnumerable<Post> FilterByDateAndTag(DateTime date, string tag);
}

То для реализации FilterByDateAndTag() мы не можем использовать два предыдущих метода.


Так что же делать?


Использовать паттерны Query Builder и Specification.


.NET предоставляет готовую реализацию паттерна Query Builder: IQueryable<T> и набор методов-расширений LINQ.


Давайте проанализируем запросы в нашем проекте. По закону Паретто, 80% запросов будут


  • либо поиском сущности по id: context.Entities.Find(id),
  • либо фильтрацией по единственному полю:
    context.Entities.Where(e => e.Property == value).

Из оставшихся 20% существенная часть будет уникальна для каждого отдельного бизнес-кейса. Такие запросы можно спокойно оставлять внутри сервисов бизнес-логики.


И только по мере рефакторинга следует выносить повторяющиеся части запросов в extension-методы к IQueryable<T>. А повторяющиеся условия — в спецификации.


Specification


Спецификация представляет правила бизнес-логики в виде булевского предиката, принимающего на вход доменную сущность. Таким образом, спецификации поддерживают композицию с помощью булевских операторов.


Фаулери и Эванс определяют спецификацию как:


public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
}

Но такие спецификации невозможно использовать с IQueryable<T>.


В LINQ to Entities в качестве спецификаций используется Expression<Func<T, bool>>. Но такие выражения нельзя комбинировать с помощью булевских операторов и использовать в LINQ to Objects.


Попробуем совместить оба подхода. Добавим метод ToExpression():


public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
    Expression<Func<T, bool>> ToExpression();
}

И базовый класс Specificaiton<T>:


public class Specification<T> : ISpecification<T>
{
    private Func<T, bool> _function;

    private Func<T, bool> Function => _function
        ?? (_function = Predicate.Compile());

    protected Expression<Func<T, bool>> Predicate;

    protected Specification() { }

    public Specification(Expression<Func<T, bool>> predicate)
    {
        Predicate = predicate;
    }

    public bool IsSatisfiedBy(T entity)
    {
        return Function.Invoke(entity);
    }

    public Expression<Func<T, bool>> ToExpression()
    {
        return Predicate;
    }
}

Теперь нам необходимо переопределить булевские операторы &&, || и !. Для этого придется делать достаточно странные вещи. Согласно C# Language Specification [7.11.2], если переопределить операторы: true, false, & и |, то вместо && будет вызван &, а вместо || будет вызван |.


Specification.cs
public static implicit operator Func<T, bool>(Specification<T> spec)
{
    return spec.Function;
}

public static implicit operator Expression<Func<T, bool>>(Specification<T> spec)
{
    return spec.Predicate;
}

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

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

public static Specification<T> operator !(Specification<T> spec)
{
    return new Specification<T>(
        Expression.Lambda<Func<T, bool>>(
            Expression.Not(spec.Predicate.Body),
            spec.Predicate.Parameters));
}

public static Specification<T> operator &(Specification<T> left, Specification<T> right)
{
    var leftExpr = left.Predicate;
    var rightExpr = right.Predicate;
    var leftParam = leftExpr.Parameters[0];
    var rightParam = rightExpr.Parameters[0];

    return new Specification<T>(
        Expression.Lambda<Func<T, bool>>(
            Expression.AndAlso(
                leftExpr.Body,
                new ParameterReplacer(rightParam, leftParam).Visit(rightExpr.Body)),
            leftParam));
}

public static Specification<T> operator |(Specification<T> left, Specification<T> right)
{
    var leftExpr = left.Predicate;
    var rightExpr = right.Predicate;
    var leftParam = leftExpr.Parameters[0];
    var rightParam = rightExpr.Parameters[0];

    return new Specification<T>(
        Expression.Lambda<Func<T, bool>>(
            Expression.OrElse(
                leftExpr.Body,
                new ParameterReplacer(rightParam, leftParam).Visit(rightExpr.Body)),
            leftParam));
}

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


ParameterReplacer.cs
internal class ParameterReplacer : ExpressionVisitor
{
    readonly ParameterExpression _parameter;
    readonly ParameterExpression _replacement;

    public ParameterReplacer(ParameterExpression parameter, ParameterExpression replacement)
    {
        _parameter = parameter;
        _replacement = replacement;
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        return base.VisitParameter(_parameter == node ? _replacement : node);
    }
}

И преобразовать Specification<T> в Expresison для использования внутри других выражений:


SpecificationExpander.cs
public class SpecificationExpander : ExpressionVisitor
{
    protected override Expression VisitUnary(UnaryExpression node)
    {
        if (node.NodeType == ExpressionType.Convert)
        {
            MethodInfo method = node.Method;

            if (method != null && method.Name == "op_Implicit")
            {
                Type declaringType = method.DeclaringType;

                if (declaringType.GetTypeInfo().IsGenericType
                    && declaringType.GetGenericTypeDefinition() == typeof(Specification<>))
                {
                    const string name = nameof(Specification<object>.ToExpression);

                    MethodInfo toExpression = declaringType.GetMethod(name);

                    return ExpandSpecification(node.Operand, toExpression);
                }
            }
        }

        return base.VisitUnary(node);
    }

    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        MethodInfo method = node.Method;

        if (method.Name == nameof(ISpecification<object>.ToExpression))
        {
            Type declaringType = method.DeclaringType;
            Type[] interfaces = declaringType.GetTypeInfo().GetInterfaces();

            if (interfaces.Any(i => i.GetTypeInfo().IsGenericType
                && i.GetGenericTypeDefinition() == typeof(ISpecification<>)))
            {
                return ExpandSpecification(node.Object, method);
            }
        }

        return base.VisitMethodCall(node);
    }

    private Expression ExpandSpecification(Expression instance, MethodInfo toExpression)
    {
        return Visit((Expression)GetValue(Expression.Call(instance, toExpression)));
    }

    // http://stackoverflow.com/a/2616980/1402923
    private static object GetValue(Expression expression)
    {
        var objectMember = Expression.Convert(expression, typeof(object));
        var getterLambda = Expression.Lambda<Func<object>>(objectMember);
        return getterLambda.Compile().Invoke();
    }
}

Теперь мы сможем


— тестировать наши спецификации:


public class UserIsActiveSpec : Specification<User>
{
    public UserIsActiveSpec()
    {
        Predicate = u => u.IsActive;
    }
}

var spec = new UserIsActiveSpec();
var user = new User { IsActive = true };

Assert.IsTrue(spec.IsSatisfiedBy(user));

— комбинировать наши спецификации:


public class UserByLoginSpec : Specification<User>
{
    public UserByLoginSpec(string login)
    {
        Predicate = u => u.Login == login;
    }
}

public class UserCombinedSpec : Specification<User>
{
    public UserCombinedSpec(string login)
        : base(new UserIsActive() && new UserByLogin(login))
    {
    }
}

— использовать их в LINQ to Entities:


var spec = new UserByLoginSpec("admin");

context.Users.Where(spec.ToExpression());

// или даже так (за счет implicit conversion в Expression)
context.Uses.Where(new UserByLoginSpec("admin") || new UserByLoginSpec("user"));

Если Вам не нравится магия с операторами, Вы можете воспользоваться готовой реализацией Specification из ASP.NET Boilerplate. Или использовать PredicateBuilder из LinqKit.


Методы расширения к IQueryable


Альтернативой спецификациям могут послужить методы-расширения к IQueryable<T>. Например:


public static IQueryable<Post> FilterByAuthor(
    this IQueryable<Post> posts, int authorId)
{
    return posts.Where(p => p.AuthorId = authorId);
}

public static IQueryable<Comment> FilterTodayComments(
    this IQueryable<Comment> comments)
{
    DateTime today = DateTime.Now.Date;

    return comments.Where(c => c.CreationTime > today)
}

Comment[] comments = context.Posts
    .FilterByAuthor(authorId)    // it's OK
    .SelectMany(p => p.Comments
        .AsQueryable()
        .FilterTodayComments())  // will throw Exception
    .ToArray();

Проблема здесь в том, что если первый extension-метод сработает как ожидается, то для второго будет выброшен Exception. Потому что он вызывается внутри Expression Tree переданного в SelectMany(). А LINQ to Entities не может это обработать.


Попытаемся исправить ситуацию. Для этого нам потребуется:


  • ExpressionVisitor, который раскроет наши extension-методы.
  • Декоратор для IQueryable<T>, который вызовет наш ExpressionVisitor.
  • Метод расширения AsExpandable(), который обернет IQueryable<T> в наш декоратор.
  • Аттрибут [Expandable], котоым мы будем помечать extension-методы для раскрытия.
    Ведь Where() или Select() тоже extension-методы, а их раскрывать не надо.

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class ExpandableAttribute : Attribute { }

QueryableExtensions.cs
public static IQueryable<T> AsExpandable<T>(this IQueryable<T> queryable)
{
    return queryable.AsVisitable(new ExtensionExpander());
}

public static IQueryable<T> AsVisitable<T>(
    this IQueryable<T> queryable, params ExpressionVisitor[] visitors)
{
    return queryable as VisitableQuery<T>
        ?? VisitableQueryFactory<T>.Create(queryable, visitors);
}

Теперь нам необходимо реализовать интерфейсы IQueryable<T> и IQueryProvider:


public interface IQueryable<T>
{
    IEnumerator GetEnumerator();     // from IEnumerable
    IEnumerator<T> GetEnumerator();  // from IEnumerable<T>
    Type ElementType { get; }        // from IQueryable
    Expression Expression { get; }   // from IQueryable
    IQueryProvider Provider { get; } // from IQueryable
}

VisitableQuery.cs
internal class VisitableQuery<T> : IQueryable<T>, IOrderedQueryable<T>, IOrderedQueryable
{
    readonly ExpressionVisitor[] _visitors;
    readonly IQueryable<T> _queryable;
    readonly VisitableQueryProvider<T> _provider;

    internal ExpressionVisitor[] Visitors => _visitors;
    internal IQueryable<T> InnerQuery => _queryable;

    public VisitableQuery(IQueryable<T> queryable, params ExpressionVisitor[] visitors)
    {
        _queryable = queryable;
        _visitors = visitors;
        _provider = new VisitableQueryProvider<T>(this);
    }

    Expression IQueryable.Expression => _queryable.Expression;

    Type IQueryable.ElementType => typeof(T);

    IQueryProvider IQueryable.Provider => _provider;

    public IEnumerator<T> GetEnumerator()
    {
        return _queryable.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return _queryable.GetEnumerator();
    }
}

public interface IQueryProvider
{
    IQueryable CreateQuery(Expression expression);
    IQueryable<TElement> CreateQuery<TElement>(Expression expression);
    object Execute(Expression expression);
    TResult Execute<TResult>(Expression expression);
}

VisitableQueryProvider.cs
internal class VisitableQueryProvider<T> : IQueryProvider
{
    readonly VisitableQuery<T> _query;

    public VisitableQueryProvider(VisitableQuery<T> query)
    {
        _query = query;
    }

    IQueryable<TElement> IQueryProvider.CreateQuery<TElement>(Expression expression)
    {
        expression = _query.Visitors.Visit(expression);
        return _query.InnerQuery.Provider
            .CreateQuery<TElement>(expression)
            .AsVisitable(_query.Visitors);
    }

    IQueryable IQueryProvider.CreateQuery(Expression expression)
    {
        expression = _query.Visitors.Visit(expression);
        return _query.InnerQuery.Provider.CreateQuery(expression);
    }

    TResult IQueryProvider.Execute<TResult>(Expression expression)
    {
        expression = _query.Visitors.Visit(expression);
        return _query.InnerQuery.Provider.Execute<TResult>(expression);
    }

    object IQueryProvider.Execute(Expression expression)
    {
        expression = _query.Visitors.Visit(expression);
        return _query.InnerQuery.Provider.Execute(expression);
    }
}

VisitorExtensions.cs
internal static class VisitorExtensions
{
    public static Expression Visit(this ExpressionVisitor[] visitors, Expression node)
    {
        if (visitors != null)
        {
            foreach (var visitor in visitors)
            {
                node = visitor.Visit(node);
            }
        }
        return node;
    }
}

Но тут есть одна маленькая особенность. Для подержки асинхронных операций, таких как ToListAsync(), EntityFramework и EF Core определяют дополнительные интерфейсы: IDbAsyncEnumerable<T> и IAsyncEnumerable<T>. Поэтому лучше воспользоваться готовой реализацией. Она сделана на основе ExpandableQuery из LinqKit, но позволяет использовать любой ExpressionVisitor.


И, наконец, сам ExpressionVisitor:


ExtensionExpander.cs
public class ExtensionExpander : ExpressionVisitor
{
    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        MethodInfo method = node.Method;

        if (method.IsDefined(typeof(ExtensionAttribute), true)
            && method.IsDefined(typeof(ExpandableAttribute), true))
        {
            ParameterInfo[] methodParams = method.GetParameters();
            Type queryableType = methodParams.First().ParameterType;
            Type entityType = queryableType.GetGenericArguments().Single();

            object inputQueryable = MakeEnumerableQuery(entityType);

            object[] arguments = new object[methodParams.Length];

            arguments[0] = inputQueryable;

            var argumentReplacements = new List<KeyValuePair<string, Expression>>();

            for (int i = 1; i < methodParams.Length; i++)
            {
                try
                {
                    arguments[i] = GetValue(node.Arguments[i]);
                }
                catch (InvalidOperationException)
                {
                    ParameterInfo paramInfo = methodParams[i];
                    Type paramType = paramInfo.GetType();

                    arguments[i] = paramType.GetTypeInfo().IsValueType
                        ? Activator.CreateInstance(paramType) : null;

                    argumentReplacements.Add(
                        new KeyValuePair<string, Expression>(paramInfo.Name, node.Arguments[i]));
                }
            }

            object outputQueryable = method.Invoke(null, arguments);

            Expression expression = ((IQueryable)outputQueryable).Expression;

            Expression realQueryable = node.Arguments[0];

            if (!typeof(IQueryable).IsAssignableFrom(realQueryable.Type))
            {
                MethodInfo asQueryable = _asQueryable.MakeGenericMethod(entityType);
                realQueryable = Expression.Call(asQueryable, realQueryable);
            }

            expression = new ExtensionRebinder(
                inputQueryable, realQueryable, argumentReplacements).Visit(expression);

            return Visit(expression);
        }
        return base.VisitMethodCall(node);
    }

    private static object MakeEnumerableQuery(Type entityType)
    {
        return _queryableEmpty.MakeGenericMethod(entityType).Invoke(null, null);
    }

    private static readonly MethodInfo _asQueryable = typeof(Queryable)
        .GetMethods(BindingFlags.Static | BindingFlags.Public)
        .First(m => m.Name == nameof(Queryable.AsQueryable) && m.IsGenericMethod);

    private static readonly MethodInfo _queryableEmpty = (typeof(ExtensionExpander))
        .GetMethod(nameof(QueryableEmpty), BindingFlags.Static | BindingFlags.NonPublic);

    private static IQueryable<T> QueryableEmpty<T>()
    {
        return Enumerable.Empty<T>().AsQueryable();
    }

    // http://stackoverflow.com/a/2616980/1402923
    private static object GetValue(Expression expression)
    {
        var objectMember = Expression.Convert(expression, typeof(object));
        var getterLambda = Expression.Lambda<Func<object>>(objectMember);
        return getterLambda.Compile().Invoke();
    }
}

ExtensionRebinder.cs
internal class ExtensionRebinder : ExpressionVisitor
{
    readonly object _originalQueryable;
    readonly Expression _replacementQueryable;
    readonly List<KeyValuePair<string, Expression>> _argumentReplacements;

    public ExtensionRebinder(
        object originalQueryable, Expression replacementQueryable,
        List<KeyValuePair<string, Expression>> argumentReplacements)
    {
        _originalQueryable = originalQueryable;
        _replacementQueryable = replacementQueryable;
        _argumentReplacements = argumentReplacements;
    }

    protected override Expression VisitConstant(ConstantExpression node)
    {
        return node.Value == _originalQueryable ? _replacementQueryable : node;
    }

    protected override Expression VisitMember(MemberExpression node)
    {
        if (node.NodeType == ExpressionType.MemberAccess
            && node.Expression.NodeType == ExpressionType.Constant
            && node.Expression.Type.GetTypeInfo().IsDefined(typeof(CompilerGeneratedAttribute)))
        {
            string argumentName = node.Member.Name;

            Expression replacement = _argumentReplacements
                .Where(p => p.Key == argumentName)
                .Select(p => p.Value)
                .FirstOrDefault();

            if (replacement != null)
            {
                return replacement;
            }
        }
        return base.VisitMember(node);
    }
}

Теперь мы сможем


— использовать extension-методы внутри Expression Tree:


[Expandable]
public static IQueryable<Post> FilterByAuthor(
    this IEnumerable<Post> posts, int authorId)
{
    return posts.AsQueryable().Where(p => p.AuthorId = authorId);
}

[Expandable]
public static IQueryable<Comment> FilterTodayComments(
    this IEnumerable<Comment> comments)
{
    DateTime today = DateTime.Now.Date;

    return comments.AsQueryable().Where(c => c.CreationTime > today)
}

Comment[] comments = context.Posts
    .AsExpandable()
    .FilterByAuthor(authorId)    // it's OK
    .SelectMany(p => p.Comments
        .FilterTodayComments())  // it's OK too
    .ToArray();

TL; DR


Уважаемые коллеги, не пытайтесь абстрагироваться от выбранного Вами фреймворка! Как правило, фреймворк уже предоставляет достаточный уровень абстракции. А иначе зачем он нужен?




Полный код расширений доступен на GitHub: EntityFramework.CommonTools, и в NuGet:


PM> Install-Package EntityFramework.CommonTools

PM> Install-Package EntityFrameworkCore.CommonTools



Update


Добавил я бенчмарки в свой проект.


DatabaseQueryBenchmark.cs
public class DatabaseQueryBenchmark
{
    readonly DbConnection _connection = Context.CreateConnection();

    [Benchmark(Baseline = true)]
    public object RawQuery()
    {
        using (var context = new Context(_connection))
        {
            DateTime today = DateTime.Now.Date;

            return context.Users
                .Where(u => u.Posts.Any(p => p.Date > today))
                .FirstOrDefault();
        }
    }

    [Benchmark]
    public object ExpandableQuery()
    {
        using (var context = new Context(_connection))
        {
            return context.Users
                .AsExpandable()
                .Where(u => u.Posts.FilterToday().Any())
                .ToList();
        }
    }

    readonly Random _random = new Random();

    [Benchmark]
    public object NotCachedQuery()
    {
        using (var context = new Context(_connection))
        {
            int[] postIds = new[] { _random.Next(), _random.Next() };

            return context.Users
                .Where(u => u.Posts.Any(p => postIds.Contains(p.Id)))
                .ToList();
        }
    }
}

Результаты получаются такие примерно
          Method |        Median |     StdDev | Scaled | Scaled-SD |
---------------- |-------------- |----------- |------- |---------- |
        RawQuery |   555.6202 ?s | 15.1837 ?s |   1.00 |      0.00 |
 ExpandableQuery |   644.6258 ?s |  3.7793 ?s |   1.15 |      0.03 | <<<
  NotCachedQuery | 2,277.7138 ?s | 10.9754 ?s |   4.06 |      0.10 |

Похоже, все кэшируется как надо. Просадка на компиляцию запроса получается в 15-30 %.







К сожалению, не доступен сервер mySQL