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

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

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

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

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

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

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

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

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

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

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

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

Ответ 1

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


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

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 не скомпилируется, так как ограничение типа generic на 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 теперь зависит от a IQueryProcessor, который может обрабатывать все наши запросы. Метод UserController SearchUsers вызывает метод IQueryProcessor.Process, проходящий в инициализированном объекте запроса. Поскольку FindUsersBySearchTextQuery реализует интерфейс IQuery<User[]>, мы можем передать его в общий метод Execute<TResult>(IQuery<TResult> query). Благодаря выводу типа С#, компилятор может определить общий тип, и это позволяет нам явно указывать тип. Также известен тип возврата метода Process.

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

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 С# 4.0), поскольку в этот момент невозможно создать экземпляр обработчика, поскольку общий аргумент TQuery недоступен при компиляции время. Однако, если метод Handle не будет переименован или не получит другие аргументы, этот вызов никогда не будет терпеть неудачу, и если вы этого захотите, очень легко написать unit test для этого класса. Использование отражения даст небольшое снижение, но не о чем беспокоиться.


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

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

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

Ответ 2

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

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

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

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 или тем, что вы используете для доступа к хранилищу. Репозиторий выполняет свою работу, чтобы действовать как фасад, а IMO - чистым, а код "критерия перевода" - многоразовым.

Ответ 3

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

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

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

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

Ответ 4

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

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

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). Поэтому после GroupBy нельзя вызвать Where.

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

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

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


Свободные интерфейсы очень гибкие и не требуют создания большого количества перегрузок методов с различными комбинациями параметров. Мой пример работает со строками, однако подход может быть распространен на другие типы. Вы также можете объявить предопределенные методы для особых случаев или методов, принимающих нестандартные типы. Вы также можете добавить такие методы, как 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, но может помочь в ситуациях, когда вы создавали бы команды с помощью простой конкатенации строк в противном случае.