Добре розроблені команди запитів та / або специфікації


90

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

Мені дуже подобається ідея використання командних запитів, особливо завдяки використанню шаблону Специфікація. Однак моя проблема зі специфікацією полягає в тому, що вона стосується лише критеріїв простих виділень (в основному, речення where), а не вирішує інших питань запитів, таких як об'єднання, групування, вибір підмножини чи прогнозування тощо. в основному, всі зайві обручі, через які повинні пройти багато запитів, щоб отримати правильний набір даних.

(примітка: я використовую термін "команда", як у шаблоні Command, також відомий як об'єкти запиту. Я не кажу про команду, як при розділенні команд / запитів, де існує різниця між запитами та командами (оновлення, видалення, вставити))

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

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

Я шукаю рішення, розроблені іншими для вирішення цієї проблеми, або для вирішення іншої проблеми, але все ще задовольняє цим вимогам. У пов'язаній статті Аєнде пропонує використовувати контекст nHibernate безпосередньо, але я вважаю, що це в значній мірі ускладнює ваш бізнес-рівень, оскільки він тепер також повинен містити інформацію про запити.

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

ПРИМІТКА: Я шукаю щось на основі ORM. Не обов’язково бути явно EF або nHibernate, але це найпоширеніші та найкращі варіанти. Якщо його можна легко адаптувати до інших ORM, це було б бонусом. Також було б непогано сумісно з Linq.

ОНОВЛЕННЯ: Я справді здивований, що тут не так багато хороших пропозицій. Здається, що люди або повністю CQRS, або вони повністю перебувають у таборі сховищ. Більшість моїх програм недостатньо складні, щоб гарантувати CQRS (щось, що стосується більшості прихильників CQRS, легко заявляє, що ви не повинні використовувати його для).

ОНОВЛЕННЯ: Здається, тут є невелика плутанина. Я шукаю не нову технологію доступу до даних, а досить розумно спроектований інтерфейс між бізнесом та даними.

В ідеалі те, що я шукаю, це щось на межі між об’єктами запиту, шаблоном специфікації та сховищем. Як я вже говорив вище, шаблон специфікації має справу лише з аспектом пропозиції where, а не з іншими аспектами запиту, такими як об'єднання, підвиділення тощо. . Об'єкти запиту також мають справу з цілим запитом, але я не хочу просто замінювати сховища вибухами об'єктів запиту.


5
Фантастичне запитання. Я теж хотів би побачити, що люди з більшим досвідом, ніж я пропоную. Я працюю над базою коду на даний момент, коли загальне сховище також містить перевантаження для об'єктів Command або Query, структура яких схожа на те, що описує Ayende у своєму блозі. PS: Це також може привернути певну увагу програмістів.
Саймон Уайтхед

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

@devdigital - Залежність від Linq - це не те саме, що залежність від реалізації даних. Я хотів би використовувати Linq для об’єктів, щоб я міг сортувати або виконувати інші функції бізнес-рівня. Але це не означає, що я хочу залежності від реалізації моделі даних. Те, про що я справді кажу, - це інтерфейс рівня / рівня. Як приклад, я хочу мати можливість змінити запит, і не потрібно змінювати його в 200 місцях, що відбувається, якщо ви натиснете IQueryable безпосередньо в бізнес-модель.
Ерік Функенбуш

1
@devdigital - який в основному просто переносить проблеми зі сховищем на ваш бізнес-рівень. Ви просто перетасовуєте проблему навколо.
Ерік Функенбуш

Відповіді:


94

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


Ми можемо визначити наступні два інтерфейси:

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'' 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метод, використовуючи відображення (за допомогою ключового слова C # 4.0 dymamic у цьому випадку), оскільки на даний момент неможливо привести екземпляр обробника, оскільки загальний TQueryаргумент недоступний під час компіляції. Однак, якщо Handleметод не перейменований або не отримає інших аргументів, цей виклик ніколи не провалиться, і якщо ви цього захочете, дуже легко написати модульний тест для цього класу. Використання роздумів дасть невелике падіння, але насправді не про що турбуватися.


Щоб відповісти на одне із ваших проблем:

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

Наслідком використання цієї конструкції є те, що в системі буде багато малих класів, але мати багато малих / цілеспрямованих класів (з чіткими назвами) - це добре. Цей підхід, очевидно, набагато кращий, ніж багато перевантажень з різними параметрами для одного і того ж методу в сховищі, оскільки ви можете згрупувати їх в одному класі запитів. Отже, ви все одно отримуєте набагато менше класів запитів, ніж методи у сховищі.


2
Схоже, ви отримали нагороду. Мені подобаються концепції, я просто сподівався, що хтось представить щось справді інше. Вітаємо.
Ерік Функенбуш

1
@FuriCuri, чи справді одному класу потрібно 5 запитів? Можливо, ви могли б розглядати це як клас із забагато обов’язків. Як варіант, якщо запити об’єднуються, то, можливо, вони насправді повинні бути одним запитом. Звичайно, це лише пропозиції.
Sam

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

1
У мене також є блог із дуже подібною реалізацією, що змушує мене зрозуміти, що я на правильному шляху, це посилання jupaol.blogspot.mx/2012/11/…, і я вже деякий час використовую його в програмах PROD, але у мене була проблема з цим підходом. Прив’язка та повторне використання запитів Скажімо, у мене є кілька невеликих запитів, які потрібно об’єднати, щоб створити більш складні запити. Я в кінцевому підсумку просто дублював код, але я шукаю набагато кращий і чистіший підхід. Будь-які ідеї?
Jupaol

4
@Cemre Я в підсумку завершив інкапсуляцію своїх запитів у методах Extension, повернувшись, IQueryableі переконавшись, що не перерахував колекцію, а потім із QueryHandlerя щойно викликав / ланцюжок запитів. Це дало мені гнучкість для модульного тестування моїх запитів та їх ланцюгового зв’язку. У мене є додаткова служба QueryHandler, а мій контролер відповідає за безпосередню розмову зі службою замість обробника
Jupaol

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 або тим, що ви використовуєте для доступу до сховища. Сховище виконує свою роботу, виконуючи функції фасаду, а IMO - це чисто, а код "перекладу критеріїв" можна використовувати багаторазово


Це не стосується проблем зростання сховища та наявності постійно розширюваного списку методів повернення різних видів даних. Я розумію, ви можете не бачити проблеми з цим (багато людей цього не бачать), але інші бачать це інакше (я пропоную прочитати статтю, на яку я посилався, є багато інших людей з подібними думками).
Ерік Функенбуш

1
Я до цього звертаюся, оскільки критерії роблять безліч методів непотрібними. Звичайно, не з усіх з них я не можу сказати багато, не знаючи нічого про потрібний відтінок. Хоча я розумію, що ви хочете запитати безпосередньо db, тому, ймовірно, сховище просто заважає. Якщо вам потрібно працювати безпосередньо з реляційним сотражем, перейдіть до цього безпосередньо, не потрібно сховища. І як примітка, дратує те, як багато людей цитують Айенде з цим постом. Я не погоджуюся з цим, і я думаю, що багато розробників просто використовують шаблон неправильно.
MikeSW

1
Це може дещо зменшити проблему, але, враховуючи досить великий додаток, він все одно створить сховища монстрів. Я не погоджуюся з рішенням Айенде щодо використання nHibernate безпосередньо в основній логіці, але я погоджуюся з ним щодо абсурду зростання поза контролем сховища. Я не хочу безпосередньо запитувати базу даних, але я не просто хочу перенести проблему зі сховища на вибух об'єктів запиту.
Ерік Функенбуш

2

Я це зробив, підтримав і скасував.

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

Найцікавішим є те, що ви відповіли на власне запитання у відповідь на відповідь Олів'є: "це по суті дублює функціональність Linq без усіх переваг, які ви отримуєте від Linq".

Запитайте себе: як могло не бути?


Ну, я точно випробував інтеграцію Linq у ваш бізнес-рівень. Це дуже потужно, але коли ми вносимо зміни в модель даних, це кошмар. З репозиторіями все покращується, оскільки я можу вносити зміни в локалізованому місці, не сильно впливаючи на бізнес-рівень (крім випадків, коли вам також потрібно змінити бізнес-рівень для підтримки змін). Але сховища стають цими роздутими шарами, які масово порушують SRP. Я розумію вашу думку, але це насправді теж не вирішує жодних проблем.
Ерік Функенбуш

Якщо ваш рівень даних використовує LINQ, а зміни моделі даних вимагають змін на вашому бізнес-рівні ... ви неправильно шаруєте.
Стю

Я думав, ви кажете, що більше не додавали цей шар. Коли ви говорите, що додана абстракція нічого не приносить вам, це означає, що ви погоджуєтесь з Ayende щодо передачі сесії 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метод повертає a, GroupedQueryякий є базовим класом Queryі не має Whereметоду (метод where декларується в Query). Тому не можна телефонувати Whereпісля GroupBy.

Однак це не ідеально. За допомогою цієї ієрархії класів ви можете послідовно приховувати членів, але не показувати нових. Тому 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, але може допомогти в ситуаціях, коли ви створювали б команди, використовуючи просту конкатенацію рядків.


3
Хм .. Цікаво, але, схоже, у вашому рішенні є проблеми з можливостями введення SQL, і насправді не створює підготовлених операторів для попередньо скомпільованого виконання (таким чином, виконуючи повільніше). Можливо, його можна було б адаптувати для вирішення цих проблем, але тоді ми застрягли в результатах нетипового набору даних, а що ні. Я віддав би перевагу рішенню на основі ORM, і, можливо, я мав би це чітко вказати. Це по суті дублює функціональність Linq без усіх переваг, які ви отримуєте від Linq.
Ерік Функенбуш,

Мені відомо про ці проблеми. Це лише швидке і брудне рішення, яке демонструє, як може бути побудований вільний інтерфейс. У реальному рішенні ви, мабуть, “перепечете” свій існуючий підхід у вільний інтерфейс, адаптований до ваших потреб.
Олів'є Якот-Декомб
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.