Хорошо продуманные команды запросов и / или спецификации


92

Я довольно долго искал хорошее решение проблем, представленных типичным шаблоном репозитория (растущий список методов для специализированных запросов и т. Д. См .: http://ayende.com/blog/3955/repository- это новый синглтон ).

Мне очень нравится идея использования командных запросов, особенно с использованием шаблона спецификации. Однако моя проблема со спецификацией заключается в том, что она относится только к критериям простого выбора (в основном, предложение where) и не касается других вопросов запросов, таких как объединение, группировка, выбор подмножества или проекция и т. Д. в основном, все лишние обручи, которые должны пройти многие запросы, чтобы получить правильный набор данных.

(примечание: я использую термин «команда», как в шаблоне «Команда», также известном как объекты запроса. Я не говорю о команде, как в разделении команд / запросов, где существует различие между запросами и командами (обновление, удаление, вставить))

Поэтому я ищу альтернативы, которые инкапсулируют весь запрос, но при этом достаточно гибкие, чтобы вы не просто меняли местами спагетти-репозитории для взрыва класса команд.

Я использовал, например, Linqspecs, и хотя я нахожу некоторую ценность в возможности назначать значимые имена критериям выбора, этого просто недостаточно. Возможно, я ищу смешанное решение, сочетающее в себе несколько подходов.

Я ищу решения, которые могли быть разработаны другими для решения этой проблемы или решения другой проблемы, но все же удовлетворяющие этим требованиям. В связанной статье Айенде предлагает напрямую использовать контекст nHibernate, но я считаю, что это значительно усложняет ваш бизнес-уровень, поскольку теперь он также должен содержать информацию о запросах.

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

ПРИМЕЧАНИЕ: я ищу что-то, основанное на ORM. Не обязательно явно указывать EF или nHibernate, но они являются наиболее распространенными и подходят лучше всего. Если его можно легко адаптировать к другим ORM, это будет бонусом. Совместимость с Linq тоже была бы неплоха.

ОБНОВЛЕНИЕ: я действительно удивлен, что здесь не так много хороших предложений. Кажется, что люди либо полностью относятся к CQRS, либо полностью принадлежат к лагерю репозитория. Большинство моих приложений недостаточно сложны, чтобы гарантировать использование CQRS (большинство сторонников CQRS с готовностью говорят, что вам не следует его использовать).

ОБНОВЛЕНИЕ: здесь есть небольшая путаница. Я ищу не новую технологию доступа к данным, а достаточно хорошо продуманный интерфейс между бизнесом и данными.

В идеале я ищу нечто среднее между объектами запроса, шаблоном спецификации и репозиторием. Как я уже сказал выше, шаблон спецификации имеет дело только с аспектом предложения where, а не с другими аспектами запроса, такими как соединения, подвыборки и т. Д. Репозитории обрабатывают весь запрос, но через некоторое время выходят из-под контроля . Объекты запроса также обрабатывают весь запрос, но я не хочу просто заменять репозитории взрывами объектов запроса.


5
Фантастический вопрос. Я тоже хотел бы увидеть, что люди с большим опытом, чем я предлагаю. В настоящий момент я работаю над базой кода, где общий репозиторий также содержит перегрузки для объектов Command или Query, структура которых похожа на то, что Айенде описывает в своем блоге. PS: Это также может привлечь внимание программистов.
Саймон Уайтхед

Почему бы просто не использовать репозиторий, который предоставляет IQueryable, если вы не возражаете против зависимости от LINQ? Обычный подход - это общий репозиторий, а затем, когда вам нужна повторно используемая логика, описанная выше, вы создаете производный тип репозитория с дополнительными методами.
devdigital

@devdigital - зависимость от Linq - это не то же самое, что зависимость от реализации данных. Я хотел бы использовать Linq для объектов, чтобы можно было сортировать или выполнять другие функции бизнес-уровня. Но это не значит, что мне нужны зависимости от реализации модели данных. На самом деле я имею в виду интерфейс уровня / уровня. В качестве примера я хочу иметь возможность изменять запрос, а не изменять его в 200 местах, что произойдет, если вы вставите IQueryable непосредственно в бизнес-модель.
Эрик Функенбуш

1
@devdigital - который в основном просто перемещает проблемы с репозиторием на ваш бизнес-уровень. Вы просто перемешиваете проблему.
Эрик Функенбуш

Ответы:


95

Отказ от ответственности: поскольку пока нет хороших ответов, я решил опубликовать часть из отличного сообщения в блоге, которое я прочитал некоторое время назад, скопированный почти дословно. Вы можете найти полную запись в блоге здесь . Итак, вот оно:


Мы можем определить следующие два интерфейса:

public interface IQuery<TResult>
{
}

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

В IQuery<TResult>определяет сообщение , которое определяет конкретный запрос с данными возвращаемой с помощью TResultуниверсального типа. С помощью ранее определенного интерфейса мы можем определить сообщение запроса следующим образом:

public class FindUsersBySearchTextQuery : IQuery<User[]>
{
    public string SearchText { get; set; }
    public bool IncludeInactiveUsers { get; set; }
}

Этот класс определяет операцию запроса с двумя параметрами, результатом которой будет массив Userобъектов. Класс, обрабатывающий это сообщение, можно определить следующим образом:

public class FindUsersBySearchTextQueryHandler
    : IQueryHandler<FindUsersBySearchTextQuery, User[]>
{
    private readonly NorthwindUnitOfWork db;

    public FindUsersBySearchTextQueryHandler(NorthwindUnitOfWork db)
    {
        this.db = db;
    }

    public User[] Handle(FindUsersBySearchTextQuery query)
    {
        return db.Users.Where(x => x.Name.Contains(query.SearchText)).ToArray();
    }
}

Теперь мы можем позволить потребителям зависеть от универсального IQueryHandlerинтерфейса:

public class UserController : Controller
{
    IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler;

    public UserController(
        IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler)
    {
        this.findUsersBySearchTextHandler = findUsersBySearchTextHandler;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        User[] users = this.findUsersBySearchTextHandler.Handle(query);    
        return View(users);
    }
}

Эта модель сразу же дает нам большую гибкость, потому что теперь мы можем решить, что вводить в UserController. Мы можем внедрить совершенно другую реализацию или ту, которая обертывает реальную реализацию, без необходимости вносить изменения в UserController(и всех других потребителей этого интерфейса).

IQuery<TResult>Интерфейс дает нам время компиляции поддержки при указании или инъекционных IQueryHandlersв нашем коде. Когда мы изменить , FindUsersBySearchTextQueryчтобы вернуться UserInfo[]вместо этого ( за счет реализации IQuery<UserInfo[]>), то UserControllerне будет компилироваться, так как общий тип ограничения на IQueryHandler<TQuery, TResult>не будет в состоянии отобразить FindUsersBySearchTextQueryв User[].

Однако внедрение IQueryHandlerинтерфейса в потребителя имеет несколько менее очевидных проблем, которые все еще необходимо решить. Количество зависимостей наших потребителей может стать слишком большим и может привести к чрезмерному внедрению конструктора - когда конструктор принимает слишком много аргументов. Количество запросов, выполняемых классом, может часто меняться, что потребует постоянного изменения количества аргументов конструктора.

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

public interface IQueryProcessor
{
    TResult Process<TResult>(IQuery<TResult> query);
}

Это IQueryProcessorнеуниверсальный интерфейс с одним универсальным методом. Как вы можете видеть в определении интерфейса, это IQueryProcessorзависит от IQuery<TResult>интерфейса. Это позволяет нам иметь поддержку времени компиляции в наших потребителях, которые зависят от IQueryProcessor. Давайте перепишем, UserControllerчтобы использовать новый IQueryProcessor:

public class UserController : Controller
{
    private IQueryProcessor queryProcessor;

    public UserController(IQueryProcessor queryProcessor)
    {
        this.queryProcessor = queryProcessor;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        // Note how we omit the generic type argument,
        // but still have type safety.
        User[] users = this.queryProcessor.Process(query);

        return this.View(users);
    }
}

UserControllerТеперь зависит на IQueryProcessorкоторый может обрабатывать все наши запросы. В UserController«S SearchUsersметод вызывает IQueryProcessor.Processметод , проходящий в инициализированном объекте запроса. Поскольку FindUsersBySearchTextQueryреализует IQuery<User[]>интерфейс, мы можем передать его универсальному Execute<TResult>(IQuery<TResult> query)методу. Благодаря выводу типа C # компилятор может определять универсальный тип, и это избавляет нас от необходимости явно указывать тип. ProcessТакже известен возвращаемый тип метода.

Теперь IQueryProcessorпоиск правильных решений является обязанностью реализации IQueryHandler. Это требует некоторой динамической типизации и, возможно, использования инфраструктуры внедрения зависимостей, и все это может быть выполнено с помощью всего нескольких строк кода:

sealed class QueryProcessor : IQueryProcessor
{
    private readonly Container container;

    public QueryProcessor(Container container)
    {
        this.container = container;
    }

    [DebuggerStepThrough]
    public TResult Process<TResult>(IQuery<TResult> query)
    {
        var handlerType = typeof(IQueryHandler<,>)
            .MakeGenericType(query.GetType(), typeof(TResult));

        dynamic handler = container.GetInstance(handlerType);

        return handler.Handle((dynamic)query);
    }
}

QueryProcessorКласс создает определенный IQueryHandler<TQuery, TResult>тип , основанный на типе экземпляра прилагаемого запроса. Этот тип используется, чтобы запросить предоставленный класс контейнера для получения экземпляра этого типа. К сожалению, нам нужно вызвать Handleметод с помощью отражения (в данном случае с использованием ключевого слова dymamic C # 4.0), потому что на этом этапе невозможно привести экземпляр обработчика, поскольку универсальный TQueryаргумент недоступен во время компиляции. Однако, если Handleметод не будет переименован или не получит другие аргументы, этот вызов никогда не завершится ошибкой, и если вы захотите, очень легко написать модульный тест для этого класса. Использование отражения даст небольшое снижение, но не о чем беспокоиться.


Чтобы ответить на одну из ваших проблем:

Поэтому я ищу альтернативы, которые инкапсулируют весь запрос, но при этом достаточно гибкие, чтобы вы не просто меняли местами спагетти-репозитории для взрыва класса команд.

Следствием использования этого дизайна является то, что в системе будет много маленьких классов, но наличие большого количества маленьких / сфокусированных классов (с понятными именами) - это хорошо. Этот подход явно намного лучше, чем наличие множества перегрузок с разными параметрами для одного и того же метода в репозитории, поскольку вы можете сгруппировать их в один класс запроса. Так что классов запросов по-прежнему гораздо меньше, чем методов в репозитории.


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

1
@FuriCuri, действительно ли одному классу нужно 5 запросов? Возможно, вы могли бы рассматривать это как класс со слишком большим количеством обязанностей. В качестве альтернативы, если запросы агрегируются, возможно, они действительно должны быть одним запросом. Конечно, это всего лишь предложения.
Сэм

1
@stakx Вы абсолютно правы, что в моем начальном примере общий TResultпараметр IQueryинтерфейса бесполезен. Однако в моем обновленном ответе TResultпараметр используется Processметодом IQueryProcessorдля разрешения ошибки IQueryHandlerво время выполнения.
david.s

1
У меня также есть блог с очень похожей реализацией, которая заставляет меня думать , что я на правильном пути, это ссылка jupaol.blogspot.mx/2012/11/… и я уже некоторое время использую ее в приложениях PROD, но у меня возникла проблема с этим подходом. Связывание и повторное использование запросов Допустим, у меня есть несколько небольших запросов, которые нужно объединить для создания более сложных запросов. В итоге я просто продублировал код, но я ищу более точный и чистый подход. Любые идеи?
Jupaol

4
@Cemre В итоге я инкапсулировал свои запросы в методы расширения, возвращая IQueryableи убедившись, что не перечислил коллекцию, а затем из того, что QueryHandlerя только что вызвал / связал запросы. Это дало мне гибкость для модульного тестирования моих запросов и их объединения в цепочку. У меня есть служба приложений поверх моей QueryHandler, и мой контроллер отвечает за
общение

4

Мой способ справиться с этим на самом деле упрощен и не зависит от ORM. Я считаю, что репозиторий таков: задача репозитория - предоставить приложению модель, необходимую для контекста, поэтому приложение просто запрашивает репозиторий о том, что ему нужно, но не сообщает, как это получить.

Я предоставляю метод репозитория с критериями (да, стиль DDD), который будет использоваться репозиторием для создания запроса (или чего-то еще, что требуется - это может быть запрос веб-службы). Объединения и группы imho - это детали того, как, а не что и критерии должны быть только основой для создания предложения where.

Модель = конечный объект или структура данных, необходимая приложению.

public class MyCriteria
{
   public Guid Id {get;set;}
   public string Name {get;set;}
    //etc
 }

 public interface Repository
  {
       MyModel GetModel(Expression<Func<MyCriteria,bool>> criteria);
   }

Возможно, вы можете использовать критерии ORM (Nhibernate) напрямую, если хотите. Реализация репозитория должна знать, как использовать критерии с базовым хранилищем или DAO.

Я не знаю ваш домен и требования к модели, но было бы странно, если бы приложение само создавало запрос. Модель настолько меняется, что невозможно определить что-то стабильное?

Это решение явно требует некоторого дополнительного кода, но оно не связывает остальную часть с ORM или чем-то еще, что вы используете для доступа к хранилищу. Репозиторий выполняет свою работу, чтобы действовать как фасад, и ИМО он чист, а код «перевода критериев» можно использовать повторно.


Это не решает проблемы роста репозитория и постоянно расширяющегося списка методов для возврата различных типов данных. Я понимаю, что вы можете не видеть в этом проблемы (многие люди этого не видят), но другие видят это по-другому (я предлагаю прочитать статью, на которую я ссылаюсь, есть много других людей с аналогичным мнением).
Эрик Фанкенбуш

1
Я обращаюсь к нему, потому что критерии делают ненужным множество методов. Конечно, не обо всех из них я не могу много сказать, ничего не зная о том, что вам нужно. Я считаю, что вы хотите напрямую запросить базу данных, поэтому, вероятно, репозиторий просто мешает. Если вам нужно работать напрямую с реляционным хранилищем, сделайте это напрямую, репозиторий не нужен. И, как примечание, досадно, как много людей цитируют Айенде в этом посте. Я не согласен с этим и думаю, что многие разработчики просто неправильно используют этот шаблон.
MikeSW

1
Это может несколько уменьшить проблему, но, учитывая достаточно большое приложение, оно все равно будет создавать репозитории монстров. Я не согласен с решением Айенде об использовании nHibernate непосредственно в основной логике, но я согласен с ним в абсурдности неконтролируемого роста репозитория. Я не хочу напрямую запрашивать базу данных, но я не хочу просто переносить проблему из репозитория на взрыв объектов запроса.
Эрик Фанкенбуш

2

Я сделал это, поддержал это и отменил это.

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

Самое интересное, что вы ответили на свой вопрос в ответ на ответ Оливье: «это по сути дублирует функциональность Linq без всех преимуществ, которые вы получаете от Linq».

Спросите себя: как же этого не могло быть?


Что ж, я определенно столкнулся с проблемами интеграции Linq в ваш бизнес-уровень. Это очень мощный инструмент, но когда мы вносим изменения в модель данных, это просто кошмар. С репозиториями ситуация улучшается, потому что я могу вносить изменения в локализованном месте, не сильно влияя на бизнес-уровень (кроме случаев, когда вам также нужно изменить бизнес-уровень для поддержки изменений). Но репозитории становятся этими раздутыми слоями, которые массово нарушают SRP. Я понимаю вашу точку зрения, но это тоже не решает никаких проблем.
Эрик Функенбуш

Если ваш уровень данных использует LINQ, а изменения модели данных требуют изменений в вашем бизнес-уровне ... вы неправильно распределяете уровни.
Stu

Я думал, вы говорили, что больше не добавляли этот слой. Когда вы говорите, что добавленная абстракция ничего вам не дает, это означает, что вы согласны с Айенде относительно передачи сеанса nHibernate (или контекста EF) непосредственно на ваш бизнес-уровень.
Эрик Функенбуш

1

Вы можете использовать свободный интерфейс. Основная идея состоит в том, что методы класса возвращают текущий экземпляр этого самого класса после выполнения некоторого действия. Это позволяет связывать вызовы методов.

Создав соответствующую иерархию классов, вы можете создать логический поток доступных методов.

public class FinalQuery
{
    protected string _table;
    protected string[] _selectFields;
    protected string _where;
    protected string[] _groupBy;
    protected string _having;
    protected string[] _orderByDescending;
    protected string[] _orderBy;

    protected FinalQuery()
    {
    }

    public override string ToString()
    {
        var sb = new StringBuilder("SELECT ");
        AppendFields(sb, _selectFields);
        sb.AppendLine();

        sb.Append("FROM ");
        sb.Append("[").Append(_table).AppendLine("]");

        if (_where != null) {
            sb.Append("WHERE").AppendLine(_where);
        }

        if (_groupBy != null) {
            sb.Append("GROUP BY ");
            AppendFields(sb, _groupBy);
            sb.AppendLine();
        }

        if (_having != null) {
            sb.Append("HAVING").AppendLine(_having);
        }

        if (_orderBy != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderBy);
            sb.AppendLine();
        } else if (_orderByDescending != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderByDescending);
            sb.Append(" DESC").AppendLine();
        }

        return sb.ToString();
    }

    private static void AppendFields(StringBuilder sb, string[] fields)
    {
        foreach (string field in fields) {
            sb.Append(field).Append(", ");
        }
        sb.Length -= 2;
    }
}

public class GroupedQuery : FinalQuery
{
    protected GroupedQuery()
    {
    }

    public GroupedQuery Having(string condition)
    {
        if (_groupBy == null) {
            throw new InvalidOperationException("HAVING clause without GROUP BY clause");
        }
        if (_having == null) {
            _having = " (" + condition + ")";
        } else {
            _having += " AND (" + condition + ")";
        }
        return this;
    }

    public FinalQuery OrderBy(params string[] fields)
    {
        _orderBy = fields;
        return this;
    }

    public FinalQuery OrderByDescending(params string[] fields)
    {
        _orderByDescending = fields;
        return this;
    }
}

public class Query : GroupedQuery
{
    public Query(string table, params string[] selectFields)
    {
        _table = table;
        _selectFields = selectFields;
    }

    public Query Where(string condition)
    {
        if (_where == null) {
            _where = " (" + condition + ")";
        } else {
            _where += " AND (" + condition + ")";
        }
        return this;
    }

    public GroupedQuery GroupBy(params string[] fields)
    {
        _groupBy = fields;
        return this;
    }
}

Вы бы назвали это так

string query = new Query("myTable", "name", "SUM(amount) AS total")
    .Where("name LIKE 'A%'")
    .GroupBy("name")
    .Having("COUNT(*) > 2")
    .OrderBy("name")
    .ToString();

Вы можете создать только новый экземпляр Query. Остальные классы имеют защищенный конструктор. Смысл иерархии в том, чтобы «отключить» методы. Например, GroupByметод возвращает, GroupedQueryкоторый является базовым классом Queryи не имеет Whereметода (метод where объявлен в Query). Поэтому позвонить Whereпозже невозможно GroupBy.

Однако это не идеально. С помощью этой иерархии классов вы можете последовательно скрывать элементы, но не показывать новые. Поэтому Havingвыдает исключение при его предыдущем вызове GroupBy.

Учтите, что звонить можно Whereнесколько раз. Это добавляет новые условия ANDк существующим условиям. Это упрощает создание фильтров программным способом из отдельных условий. То же самое возможно и с Having.

У методов, принимающих списки полей, есть параметр params string[] fields. Он позволяет передавать имена отдельных полей или массив строк.


Fluent-интерфейсы очень гибкие и не требуют создания множества перегрузок методов с различными комбинациями параметров. Мой пример работает со строками, однако этот подход можно распространить на другие типы. Вы также можете объявить предопределенные методы для особых случаев или методы, принимающие настраиваемые типы. Вы также можете добавить такие методы, как ExecuteReaderили ExceuteScalar<T>. Это позволит вам определять такие запросы

var reader = new Query<Employee>(new MonthlyReportFields{ IncludeSalary = true })
    .Where(new CurrentMonthCondition())
    .Where(new DivisionCondition{ DivisionType = DivisionType.Production})
    .OrderBy(new StandardMonthlyReportSorting())
    .ExecuteReader();

Даже команды SQL, построенные таким образом, могут иметь параметры команд, что позволяет избежать проблем с внедрением SQL-кода и в то же время позволяет кэшировать команды на сервере базы данных. Это не замена O / R-mapper, но может помочь в ситуациях, когда в противном случае вы создавали бы команды, используя простую конкатенацию строк.


3
Хм .. Интересно, но ваше решение, похоже, имеет проблемы с возможностями SQL Injection, и на самом деле не создает подготовленные операторы для предварительно скомпилированного выполнения (таким образом, выполняется медленнее). Вероятно, его можно было бы адаптировать для решения этих проблем, но тогда мы застряли с результатами небезопасного набора данных, а также с другими. Я бы предпочел решение на основе ORM и, возможно, мне следует указать это явно. По сути, это дублирует функциональность Linq без всех преимуществ, которые вы получаете от Linq.
Эрик Функенбуш

Я знаю об этих проблемах. Это просто быстрое и грязное решение, показывающее, как можно создать плавный интерфейс. В реальном решении вы, вероятно, «запекли бы» свой существующий подход в гибкий интерфейс, адаптированный к вашим потребностям.
Olivier Jacot-Descombes
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.