На переднем крае — CQRS для обычного приложения

Проектирование, управляемое предметной областью (domain-driven design, DDD), появилось примерно десять лет назад, открыв новые возможности для архитекторов и разработчиков ПО. Помимо конкретных преимуществ и недостатков, DDD олицетворяет собой старую мечту тех, кто имел дело с объектно-ориентированной парадигмой на заре ее развития: создание приложений на основе исчерпывающей объектной модели, учитывающей требования и озабоченности всех заинтересованных сторон.

За прошлое десятилетие многие разработчики участвовали в проектах, следующих правилам DDD. Какие-то проекты оказались успешными, а какие-то — нет. Истина в том, что всеобъемлющая объектная модель, способная охватить любой функциональный или нефункциональный аспект программной системы, является утопией. Особенно в наши сумасшедшие дни продвинутых пользовательских сред (UX), часто меняющихся бизнес-моделей и требований стабильная объектная модель — это практически иллюзия.

Недавно начала раскручиваться другая методология — Command and Query Responsibility Segregation (CQRS). CQRS — не самая новая игрушка ученых мужей от программного обеспечения. Она даже не столь сложна, как подразумевает большинство доступных примеров. Проще говоря, CQRS — это шаблон конкретной реализации, который, вероятно, в наибольшей мере подходит почти для всех типов приложений независимо от ожидаемого срока эксплуатации и сложности.

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

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

Команды и запросы

При разработке языка программирования Eiffel в конце 1980-х Бертран Мейер (Bertrand Meyer) пришел к выводу о том, что в программном обеспечении имеются команды, которые изменяют состояние системы, и запросы, которые считывают это состояние. Любое выражение в программе должно быть либо командой, либо запросом, но не их комбинацией. Ту же концепцию можно выразить изящнее: задание вопроса не должно изменять ответ. CQRS является новой формулировкой того же базового принципа: команды и запросы — разные вещи, и их следует реализовать раздельно.

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

Разделение, преследуемое CQRS, достигается группированием операций запроса в одном уровне, а команд — в другом. Каждый уровень имеет свою модель данных, свой набор сервисов и создается с применением своей комбинации шаблонов и технологий. Еще важнее, что эти два уровня могут находиться даже в двух разных звеньях (tiers) и оптимизироваться раздельно, никак не затрагивая друг друга. На рис. 1 показан фундамент архитектуры CQRS.

Каноническая и многоуровневая архитектура CQRS 
Рис. 1. Каноническая и многоуровневая архитектура CQRS

CQRS CQRS
Presentation Layer Презентационный уровень
Application Layer Прикладной уровень
Domain Layer Предметный уровень
Infrastructure Layer Инфраструктурный уровень
Commands Команды
Queries Запросы

Простое понимание того, что команды и запросы являются разными вещами, оказывает глубокое влияние на архитектуру ПО. Например, вдруг становится легче предвидеть и кодировать каждый уровень предметной области. Уровень предметной области (domain layer) в стеке команд нуждается лишь в данных, бизнес-правилах и правилах безопасности для выполнения задач. С другой стороны, уровень предметной области в стеке запросов может быть не сложнее прямого SQL-запроса по ADO.NET-соединению.

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

Когда впервые появилась DDD, она была нацелена на попытку преодолеть сложность в разгар разработки ПО. Но попутно приверженцы этой методологии столкнулись с уймой трудностей. Многие считали, что она была просто частью предметной области бизнеса. Вместо этого большая часть сложности вытекала из прямого (декартова) произведения запросов и команд. Отделение команд от запросов уменьшило уровень сложности на порядок. Если вольно воспользоваться математическими терминами, то CQRS и подход со всеобъемлющей моделью предметной области можно сравнить как N+N против N×N.

Как начать работу с CQRS?

Вы можете преобразовать базовую CRUD-систему (create, read, update, delete) в CQRS-систему. Допустим, у вас есть каноническое веб-приложение ASP.NET MVC, которое собирает пользовательские данные для отображения в различных формах. Это как раз то, что делается большинством приложений и что архитекторы умеют создавать быстро и эффективно. Вы можете переписать это приложение с учетом CQRS. Вы удивитесь, сколь минимальны необходимые изменения, а преимущества потенциально неограниченны.

Ваша каноническая система организована по уровням. У вас есть прикладные сервисы, вызываемые непосредственно из контроллеров, которые управляют сценариями использования. Прикладные сервисы (часто называемые рабочими сервисами) существуют на веб-сервере бок о бок с контроллерами. С учетом рис. 1 прикладные сервисы образуют прикладной уровень. Этот уровень является платформой, с которой вы можете выдавать команды и запросы к остальной части системы. Применение CQRS подразумевает, что вы будете использовать два разных промежуточных звена (tiers). Одно звено имеет дело с командами, которые изменяют состояние системы. Другое звено извлекает данные. На рис. 2 показана схема архитектуры проекта-примера ASP.NET MVC project.

CQRS-архитектура для проекта ASP.NET MVC 
Рис. 2. CQRS-архитектура для проекта ASP.NET MVC

Externals Внешние DLL

Вы создаете пару проектов библиотек классов (стек запросов и стек команд) и ссылаетесь на эти проекты из основного проекта веб-сервера.

Стек запросов

Библиотека классов стека запросов имеет дело только с извлечением данных. Она использует модель данных, максимально близко соответствующую данным, применяемым на презентационном уровне. Вам вряд ли нужны здесь какие-либо бизнес-правила, так как они обычно применяются к командам, которые изменяют состояние.

Шаблон модели предметной области, ставший популярным благодаря DDD, по сути, является способом организации логики предметной области. Выдавая запросы из клиентского интерфейса (front end), вы имеете дело лишь с частью прикладной логики и сценариев применения. Термин «бизнес-логика» обычно является результатом объединения специфичной для приложения логики с инвариантной логикой предметной области. Зная формат сохранения и формат представления данных, вам остается лишь сопоставить данные так, как вы делали бы это в ADO.NET/SQL-запросе.

Полезно помнить, что любой код, который вы можете вызвать из прикладного уровня, отражает предметную область бизнеса в системе. Следовательно, это инвариантный API, выражающий базовую логику системы. В идеале, вы должны быть уверены в том, что никакие несогласованные и несовместимые операции невозможны через этот предоставляемый API. Поэтому, чтобы реализовать естественную доступность стека запросов только для чтения, добавьте класс-оболочку для объекта контекста Entity Framework по умолчанию, который обеспечивает подключение к базе данных:

public class Database : IDisposable
{
  private readonly QueryDbContext _context =
    new QueryDbContext();
  public IQueryable<Customer> Customers
  {
    get { return _context.Customers; }
  }
  public void Dispose()
  {
    _context.Dispose();
  }
}

Класс Matches реализован как набор DbSet<T> в базовом классе DbContext. Как таковой, он предоставляет полный доступ к нижележащей базе данных, и с его помощью можно подготавливать запросы и операции обновления через LINQ to Entities.

Фундаментальный шаг в подготовке конвейера запросов — разрешение доступа к базе данных только для запросов. Это роль класса-оболочки, где Matches предоставляется как IQueryable<T>. Прикладной уровень будет использовать класс-оболочку Database для реализации запросов, нацеленных на передачу данных презентационному уровню:

var model = new RegisterViewModel();
using (var db = new Database())
{
  var list = (from m in db.Customers select m).ToList();
  model.ExistingCustomers = list;
}

Теперь имеется прямое соединение между источником данных и презентационным уровнем. На самом деле вы просто читаете и форматируете данные в целях отображения. Вы ожидаете, что авторизация вводится шлюзом (gate) через логины и ограничения UI. Однако, если это не так, можно добавить дополнительные уровни и разрешить обмен данными через наборы IQueryable. Модель данных является такой же, как база данных, с сохранением «один к одному». Эту модель иногда называют многослойным деревом выражений (layered expression trees, LET).

К этому моменту стоит отметить пару вещей. Прежде всего вы находитесь в конвейере чтения, где бизнес-правила обычно отсутствуют. Максимум, чего у вас может быть здесь, — правила авторизации и фильтры. Они являются общеизвестными на прикладном уровне. Вам не требуется иметь дело с объектами передачи данных (data transfer objects, DTO). Вы располагаете одной моделью сохранения и контейнерами собственно данных для представления. В прикладном сервисе вы придете в конечном счете к следующему шаблону:

var model = SpecificUseCaseViewModel();
model.SomeCollection = new Database()
     .SomeQueryableCollection
     .Where(m => SomeCondition1)
     .Where(m => SomeCondition2)
     .Where(m => SomeCondition3)
     .Select(m => new SpecificUseCaseDto
       {
         // Заполнение
       })
     .ToList();
return model;

Все DTO, которые вы видите в этом фрагменте кода, специфичны для реализуемого вами сценария применения презентационного уровня. Эти объекты — то, что хочет видеть пользователь в создаваемом вами представлении Razor, и применение классов неизбежно. Кроме того, вы можете заменить все блоки Where на специализированные методы расширения IQueryable и превратить весь код в диалог, написанный на языке, специфичном для предметной области.

Второе, что стоит отметить насчет стека запросов, относится к сохранению (persistence). В простейшей форме CQRS стеки команд и запросов совместно используют одну и ту же базу данных. Эта архитектура делает CQRS похожей на классические CRUD-системы. Это облегчает адаптацию разработчиков к новой методологии, сопротивляющихся переменам. Однако вы можете так спроектировать серверную часть, чтобы стеки команд и запросов работали с раздельными базами данных, оптимизированными под их специфические задачи. И здесь возникает дополнительная проблема: синхронизация двух баз данных.

Стек команд

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

В простейшем случае проталкивание команды (pushing a command) заключается в вызове скрипта транзакции. Это инициирует рабочий процесс, который выполняет все этапы, требуемые задачей. Проталкивание команды может быть столь же простым, как в следующем коде:

public void Register(RegisterInputModel input)
{
  // Проталкиваем команду через стек
  using (var db = new CommandDbContext())
  {
    var c = new Customer {
      FirstName = input.FirstName,
      LastName = input.LastName };
    db.Customers.Add(c);
    db.SaveChanges();
  }
}

При необходимости вы можете передать управление настоящему уровню предметной области с сервисами и моделью предметной области, где реализуется полная бизнес-логика. Однако использование CQRS не обязательно привязывает вас к DDD и таким вещам, как агрегаты, фабрики и значимые объекты (value objects). Преимущества отделения команд от запросов можно получать без введения дополнительной сложности модели предметной области.

За рамками обычной CQRS

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

В более изощренных формах могут применяться несколько баз данных, сохранение в различных форматах, денормализация данных для запросов, определение источников событий и, что важнее, более гибкий способ передачи команд серверной части. Гибкость увеличивается за счет использования шины для передачи команд и публикации событий, что позволяет вам определять и модифицировать любую задачу в момент их появления — по аналогии с управлением схемой потоков (flowchart). В то же время масштабируемость достигается за счет расширения компонента шины.

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