Системы управления предприятиями, проектами, сотрудниками давно вошли в нашу жизнь. И пользователи таких enterprise приложений все более требовательны: возрастают требования к масштабируемости, сложность бизнес-логики, требования к системам меняются быстро, да и отчетность требуется в реальном времени.
Поэтому при разработке зачастую можно наблюдать одни и те же проблемы в организации кода и архитектуры, а также в их усложнении. При неправильном подходе к проектированию рано или поздно может наступить момент, когда код становится настолько сложным и запутанным, что каждое внесение изменений требует все больше времени и ресурсов.
Типовой подход к проектированию приложения
Многослойная архитектура – один из самых популярных способов организации структуры веб-приложений. В простой её вариации, как на приведенной выше схеме, приложение делится на 3 части: слой данных, слой бизнес-логики и слой пользовательского интерфейса.
В слое данных имеется некий Repository, который абстрагирует нас от хранилища данных.
В слое бизнес-логики есть объекты, которые инкапсулируют бизнес-правила (обычно их названия варьируются в пределах Services/BusinessRules/Managers/Helpers). Запросы пользователя проходят от UI сквозь бизнес-правила, и дальше через Repository производится работа с хранилищем данных.
С такой архитектурой запросы на получение и изменение данных, как правило, производятся в одном и том же месте – в слое бизнес-логики. Это довольно простой и привычный способ организации кода, и такая модель может подойти для большинства приложений, если в этих приложениях количество пользователей со временем значительно не меняется, приложение не испытывает больших нагрузок и не требует значительного расширения функционала
Но если веб-ресурс становится достаточно популярным, может стать вопрос о том, что одного сервера для него недостаточно. И тогда встает вопрос о распределении нагрузки между несколькими серверами. Простейший вариант быстро распределить нагрузку – использовать несколько копий ресурса и репликацию базы данных. А учитывая, что все действия такой системы никак не разделены, это порождает новые проблемы.
Классическая многослойная архитектура не обеспечивает лёгкого решения подобных проблем. Поэтому неплохо было бы использовать подходы, в которых эти проблемы решены с самого начала. Одним из таких подходов является CQRS.
Command and Query Responsibility Segregation (CQRS)
CQRS – подход проектирования программного обеспечения, при котором код, изменяющий состояние, отделяется от кода, просто читающего это состояние. Подобное разделение может быть логическим и основываться на разных уровнях. Кроме того, оно может быть физическим и включать разные звенья (tiers), или уровни.
В основе этого подхода лежит принцип Command-query separation (CQS).
Основная идея CQS в том, что в объекте методы могут быть двух типов:
- Queries: Методы возвращают результат, не изменяя состояние объекта. Другими словами, у Query не никаких побочных эффектов.
- Commands: Методы изменяют состояние объекта, не возвращая значение.
Для примера такого разделения рассмотрим класс User с одним методом IsEmailValid:
- public class User
- {
- public string Email { get; private set; }
-
- public bool IsEmailValid(string email)
- {
- bool isMatch = Regex.IsMatch("email pattern", email);
-
- if (isMatch)
- {
- Email = email; // Command
- }
-
- return isMatch; // Query
- }
- }
В данном методе мы спрашиваем (делаем Query), является ли валидным переданный email. Если да, то получаем в ответ True, иначе False. Кроме возврата значения, здесь также определено, что в случае валидного email сразу присваивать его значение (делаем Command) полю Email.
Несмотря на то что пример довольно простой, вероятна и менее тривиальная ситуация, если представить себе метод Query, который при вызове в нескольких уровнях вложенности меняет состояние разных объектов. В лучшем случае повезет, если не придется столкнуться с подобными методами и их долгой отладкой. Подобные побочные эффекты от вызова Query часто обескураживают, так как сложно разобраться в работе системы.
Если воспользоваться принципом CQS и разделить методы на Command и Query, получим следующий код:
- public class User
- {
- public string Email { get; private set; }
-
- public bool IsEmailValid(string email) // Query
- {
- return Regex.IsMatch("email pattern", email);
- }
-
- public void ChangeEmail(string email) // Command
- {
- if (IsEmailValid(email) == false)
- throw new ArgumentOutOfRangeException(email);
-
- Email = email;
- }
- }
Теперь пользователь нашего класса не увидит никаких изменений состояния при вызове IsEmailValid, он лишь получит результат – валиден ли email или нет. А в случае вызова метода ChangeEmail пользователь явно поменяет состояние объекта.
В CQS у Query есть одна особенность. Раз Query никак не меняет состояние объекта, то методы типа Query можно хорошо распараллелить, разделяя приходящуюся на операции чтения нагрузку.
Если CQS оперирует методами, то CQRS поднимается на уровень объектов. Для изменения состояния системы создается класс Command, а для выборки данных – класс Query. Таким образом, мы получаем набор объектов, которые меняют состояние системы, и набор объектов, которые возвращают данные.
Типовой дизайн системы, где есть UI, бизнес-логика и база данных:
CQRS говорит, что не надо смешивать объекты Command и Query, нужно их явно выделить. Система, разделенная таким образом, будет выглядеть уже так:
Разделение, преследуемое CQRS, достигается группированием операций запроса в одном уровне, а команд – в другом. Каждый уровень имеет свою модель данных, свой набор сервисов и создается с применением своей комбинации шаблонов и технологий. Еще важнее, что эти два уровня могут находиться даже в двух разных звеньях (tiers) и оптимизироваться раздельно, никак не затрагивая друг друга.
Простое понимание того, что команды и запросы являются разными вещами, оказывает глубокое влияние на архитектуру ПО. Например, вдруг становится легче предвидеть и кодировать каждый уровень предметной области. Уровень предметной области (domain layer) в стеке команд нуждается лишь в данных, бизнес-правилах и правилах безопасности для выполнения задач. С другой стороны, уровень предметной области в стеке запросов может быть не сложнее прямого SQL-запроса.
С чего начать при работе с CQRS?
1. Стек команд
В CQRS на стек команд возлагается только выполнение задач, которые модифицируют состояние приложения. Команде присущи следующие свойства:
- Изменяет состояние системы;
- Ничего не возвращает;
- Контекст команды хранит нужные для ее выполнения данные.
Объявление и использование команды условно можно поделить на 3 части:
- Класс команды, представляющий собой данные;
- Класс обработчика команд;
- Класс с методом или методами, которые принимают команду на вход и вызывают именно тот обработчик, который реализует команду с данным типом.
Суть команд и запросов заключается в том, что они имеют общий признак, по которому они могут быть объединены. Иначе говоря, у них имеется общий тип. В случае команд это будет выглядеть следующим образом:
- public interface ICommand
- {
- }
Первым шагом объявляется интерфейс, который, как правило, ничего не содержит. Он будет использоваться как параметр, который может быть получен на стороне сервера непосредственно из пользовательского интерфейса (UI), или же быть сформирован иным образом, для передачи обработчику команды.
Далее объявляется интерфейс обработчика команды.
- public interface ICommandHandler<in TCommand> where TCommand : ICommand
- {
- void Execute(TCommand command);
- }
Он содержит лишь 1 метод, принимающий данные с типом интерфейса, объявленным ранее.
После этого остается определить способ централизованного вызова обработчиков команд в зависимости от конкретного типа переданной команды (ICommand). Эту роль могут выполнять сервисная шина или диспетчер.
- public interface ICommandDispatcher
- {
- void Execute<TCommand>(TCommand command) where TCommand : ICommand;
- }
В зависимости от потребностей может иметь как один, так и более методов. В простых случаях может быть достаточно и одного метода, задача которого заключается в том, чтобы по типу переданного параметра определить, какую реализацию обработчика команды вызывать. Тем самым пользователю не придется делать это вручную.
Пример команды. Допустим, есть интернет-магазин, для него нужно создать команду, которая создаст товар в хранилище. В начале создадим класс, где в его имени указываем то, какое действие производит данная команда.
- public class CreateInventoryItem : ICommand
- {
- public Guid InventoryItemid { get; }
- public string Name { get; }
-
- public CreateInventoryItem(Guid inventoryItemld, string name)
- {
- InventoryItemId = inventoryItemId;
- Name = name;
- }
- }
Все классы, реализующие ICommand, содержит данные – свойства и конструктор с установкой их значений при инициализации – и больше ничего.
Реализация обработчика, то есть уже непосредственно самой команды, сводится к довольно простым действиям: создается класс, который реализует интерфейс ICommandHandler. Аргументом типа указывается команда, объявленная ранее.
- public class InventoryCommandHandler : ICommandHandler<CreateInventoryItem>
- {
- private readonly IRepository<InventoryItem> _repository;
-
- public InventoryCommandHandlers(IRepository<InventoryItem> repository)
- {
- _repository = repository;
- }
-
- public void Execute(CreateInventoryItem message)
- {
- var item = new InventoryItem(message.InventoryItemld, message.Name);
- _repository.Save(item);
- }
-
- // ...
- }
Тем самым мы реализуем метод, принимающий на вход эту команду, и указываем, какие действия на основе переданных данных хотим произвести. Обработчики команд можно объединять логически и реализовывать в одном таком классе несколько интерфейсов ICommandHandler с разным типом команд. Получится перегрузка методов, и при вызове метода Execute будет выбран подходящий по типу команды.
Теперь, чтобы вызывать подходящий обработчик команды, нужно создать класс, реализующий интерфейс ICommandDispatcher. В отличие от прошлых двух, данный класс создается единожды и может иметь разные реализации в зависимости от стратегии регистрации и вызова обработчиков команд.
- public class CommandDispatcher : ICommandDispatcher
- {
- private readonly IDependencyResolver _resolver;
-
- public CommandDispatcher(IDependencyResolver resolver)
- {
- _resolver = resolver;
- }
-
- public void Execute<TCommand>(TCommand command) where TCommand : ICommand
- {
- if (command == null) throw new ArgumentNullException("command");
-
- var handler = _resolver.Resolve<ICommandHandler<TCommand>>();
-
- if (handler == null) throw new CommandHandlerNotFoundException(typeof(TCommand));
-
- handler.Execute(command);
- }
- }
Одним из способов вызова нужного обработчика команды является использование DI-контейнера, в котором регистрируются все реализации обработчиков. В зависимости от переданной команды будет создаваться тот экземпляр, который обрабатывает данный тип команды. Затем диспетчер просто вызывает его метод Execute.
2. Стек запросов
Стек запросов имеет дело только с извлечением данных. Запросы используют модель данных, максимально соответствующую данным, применяемым на презентационном уровне. Вам вряд ли нужны какие-либо бизнес-правила – обычно они применяются к командам, которые изменяют состояние.
Запросу присущи следующие свойства:
- Не изменяет состояние системы;
- Контекст запроса хранит нужные для ее выполнения данные (пейджинг, фильтры и т.п.);
- Возвращает результат.
Объявление и использование запросов также можно условно поделить на 3 части:
- Класс запроса, представляющий собой данные с типом возвращаемого результата;
- Класс обработчика запросов;
- Класс с методом или методами, которые принимают запрос на вход и вызывают именно тот обработчик, который реализует запрос с данным типом.
Как и для команд, для запросов объявляются похожие интерфейсы. Единственное отличие – в них указывается то, что должно быть возвращено.
- public interface IQuery<TResult>
- {
- }
Здесь в качестве аргумента типа указывается тип возвращаемых данных. Это может быть произвольный тип, например, string или int[].
После объявляется обработчик запросов, где также указывается тип возвращаемого значения.
- public interface IQueryHandler<in TQuery, out TResult> where TQuery : IQuery<TResult>
- {
- TResult Execute(TQuery query);
- }
По аналогии с командами объявляется диспетчер для вызова обработчиков запросов.
- public interface IQueryDispatcher
- {
- TResult Execute<TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult>;
- }
Пример запроса. Допустим, нужно создать запрос, возвращающий пользователей по поисковому критерию. Здесь также с помощью осмысленного имени класса указываем, что за запрос будет производится.
- public class FindUsersBySearchTextQuery : IQuery<User[]>
- {
- public string SearchText { get; }
- public bool InactiveUsers { get; }
-
- public FindUsersBySearchTextQuery(string searchText, bool inactiveUsers)
- {
- SearchText = searchText;
- InactiveUsers = inactiveUsers;
- }
- }
Далее создаём обработчик, реализующий IQueryHandler с аргументами типа запроса и типа его возвращаемого значения.
- public class UserQueryHandler : IQueryHandler<FindUsersBySearchTextQuery, User[]>
- {
- private readonly IRepository<User> _repository;
-
- public UserQueryHandler(IRepository<User> repository)
- {
- _repository = repository;
- }
-
- public User[] Execute(FindUsersBySearchTextQuery query)
- {
- var users = _repository.GetAll();
- return users.Where(user => user.Name.Contains(query.SearchText)).ToArray();
- }
- }
После чего остается создать класс для вызова обработчиков запросов.
- public class QueryDispatcher : IQueryDispatcher
- {
- private readonly IDependencyResolver _resolver;
-
- public QueryDispatcher(IDependencyResolver resolver)
- {
- _resolver = resolver;
- }
-
- public TResult Execute<TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult>
- {
- if (query == null) throw new ArgumentNullException("query");
-
- var handler = _resolver.Resolve<IQueryHandler<TQuery, TResult>>();
-
- if (handler == null) throw new QueryHandlerNotFoundException(typeof(TQuery));
-
- return handler.Execute(query);
- }
- }
Вызов команд и запросов
Чтобы можно было вызывать команды и запросы, достаточно использовать соответствующие диспетчеры и передать конкретный объект с необходимыми данными. На примере это выглядит следующим образом:
- public class UserController : Controller
- {
- private IQueryDispatcher _queryDispatcher;
-
- public UserController(IQueryDispatcher queryDispatcher)
- {
- _queryDispatcher = queryDispatcher;
- }
-
- public ActionResult SearchUsers(string searchString)
- {
- var query = new FindUsersBySearchTextQuery(searchString);
-
- User[] users =_queryDispatcher.Execute(query);
-
- return View(users);
- }
- }
Имея контроллер для обработки запросов пользователя, достаточно передать в качестве зависимости объект нужного диспетчера, после чего сформировать объект запроса или команды и передать методу диспетчера Execute.
Так мы избавляемся от необходимости постоянного увеличения зависимостей при увеличении количества функций системы и уменьшаем количество потенциальных ошибок.
Регистрация обработчиков
Регистрировать обработчики можно разными способами. С помощью DI-контейнера можно регистрировать по-отдельности или автоматически просматривая сборку с нужными типами. Второй вариант может выглядеть следующим образом:
using SimpleInjector;
var container = new Container();
container.Register(typeof(ICommandHandler<>), AppDomain.CurrentDomain.GetAssemblies());
container.Register(typeof(IQueryHandler<,>), AppDomain.CurrentDomain.GetAssemblies());
Здесь используется контейнер SimpleInjector. Регистрируя обработчики методом Register, первым аргументом указывается тип интерфейсов обработчиков команд и запросов, а вторым – сборка, в которой производится поиск классов, реализующих данные интерфейсы. Тем самым не нужно указывать конкретные обработчики, а лишь только общий интерфейс, что крайне удобно.
Что если при вызове Command/Query надо проверять права доступа, записать информацию в лог и тому подобное?
Их централизованный вызов позволяет добавить действия до или после выполнения обработчика, не изменяя ни один из них. Достаточно внести изменения в сам диспетчер, или создать декоратор, который через DI-контейнер подменит исходную реализацию (в документации SimpleInjector довольно хорошо расписаны примеры подобных декораторов).
Достоинства CQRS
- Меньше зависимостей в каждом классе;
- Соблюдается принцип единственной ответственности (SRP);
- Подходит практически везде;
- Проще заменить и тестировать;
- Легче расширяется функциональность.
Ограничения CQRS
- При использовании CQRS появляется много мелких классов;
- При использовании простой реализации CQRS могут возникнуть сложности с использованием группы команд в одной транзакции;
- Если в Command и Query появляется общая логика, нужно использовать наследование или композицию. Это усложняет дизайн системы, но для опытных разработчиков не является препятствием;
- Сложно целиком придерживаться CQS и CQRS. Самый простой пример – метод выборки данных из стека. Выборка данных – это Query, но нам надо обязательно поменять состояние и сделать размер стека -1. На практике вы будете искать баланс между жестким следованием принципами и производственной необходимостью;
- Плохо ложится на CRUD-приложения.
Где не подходит
- В небольших приложениях/системах;
- В преимущественно CRUD-приложениях.
Заключение
Чтобы приложения были по-настоящему эффективными, они должны подстраиваться под требования бизнеса. Архитектура, основанная на CQRS, значительно упрощает расширение и модификацию рабочих бизнес-процессов и поддерживает новые сценарии. Вы можете управлять расширениями в полной изоляции. Для этого достаточно добавить новый обработчик, зарегистрировать и сообщить ему, как обрабатывать сообщения только требуемого типа. Новый компонент будет автоматически вызываться только при появлении соответствующего сообщения и работать бок о бок с остальной частью системы. Легко, просто и эффективно.
CQRS позволяет оптимизировать конвейеры команд и запросов любым способом. При этом оптимизация одного конвейера не нарушит работу другого. В самой базовой форме CQRS используется одна общая база данных и вызываются разные модули для операций чтения и записи из прикладного уровня.
Источники
> Блог Александра Бындю — CQRS на практике
> На переднем крае — CQRS для обычного приложения
> Как мы попробовали DDD, CQRS и Event Sourcing и какие выводы сделали
> CQRS Documents by Greg Young
> Simple CQRS example
> DDDD, CQRS and Other Enterprise Development Buzz-words
Комментарии (76)
Diaskhan
18.07.2017 12:31-1Вообще CQRS имеет смысл быть для высоко нагруженных систем.
Одна БД для записи информации, 4 БД для чтения информации. Иначе зачем делить логику, как мне кажется это методология или новомодное словечко вечно вырывается из контекста !
Хотелось бы узнать у Бертрана Мейера какое у него было железо !
https://ru.wikipedia.org/wiki/CQRS
На практике, CQRS дает возможность пропустить все проверки утверждений в действующей системе, чтобы повысить её производительность, не боясь того, что это изменит её поведение. CQRS также предотвращает возникновение некоторых гейзенбагов.
Как я и говорил для повышения производительности !vintage
18.07.2017 13:26-3Чтение из одной базы, а запись в другую реализуется одним if-ом. Для этого не нужны никакие CQ®S.
kaljan
18.07.2017 13:58+2вы тролль?
vintage
18.07.2017 15:14-2Ага. Буквально вчера троллил это для файлов:
struct Stream { File output; File input; this( string output , string input = output ) { this.output = File( path , "ab" ); this.input = File( path , "rb" ); } auto put( Data )( Data data ) { auto offset = this.output.tell; this.output.lockingBinaryWriter.put( data ); return offset; } auto read( Data )( ulong offset ) { Unqual!Data[1] buffer; this.input.seek( offset ); this.input.rawRead( buffer ); return cast( Data ) buffer[0]; } }
void main() { auto stream = Stream( "target.bin" , "source.bin" ); stream.put( 777 ); stream.put( "Hello" ); writeln( stream.read!ulong( 0 ) ); }
indestructable
18.07.2017 17:00Только это будет не CQRS, и не будет его преимуществ. В CQRS данные для чтения имеют структуру, оптимизированную для чтения, поэтому чтение будет быстрое за счет более медленной записи. Например, вместо расчета прибыли при создании отчета (путем суммирования всех проводок) мы рассчитываем прибыль при создании каждой проводки.
vintage
18.07.2017 17:21Все эти оптимизации вовсе не обязательно выпячивать наружу. Запросили у "модели отчёта" прибыль — она взяла предагрегированное значение из быстрой базы. Передали ей флаг "хочу актуальные данные" — она пересчитала прибыль по медленной базе. Тут же она может и закешировать полученное значение в быструю базу. Уповая на CQRS вы не получаете никаких преимуществ, зато получаете кучу ограничений.
indestructable
18.07.2017 17:47+1АПИ CQRS систем — это отражение их eventual consistency. То есть факта, что отправив команду системе, результат нужно получать другим способом.
vintage
18.07.2017 20:41-1Представьте себе популярную нынче multi-master репликацию. eventual consistency есть, а cqrs — нет.
indestructable
18.07.2017 21:25БД внутри — это как раз cqrs + event sourcing, они журнал операций ведут.
vintage
18.07.2017 22:07Что там у БД внутри — совершенно не важно. Впрочем, нет там никакого cqrs и Event Sourcing. Там есть лишь временный WAL, который периодически чистится, а все запросы что-то возвращают (банально, чтобы не делать лишних лукапов).
DrFdooch
18.07.2017 14:37+1CQRS — это про архитектуру системы. И в архитектуру в том числе входит необходимость её поддерживать. Даже без требований к, например, производительности, в некоторых проектах имеет смысл разнести логику на изолированные команды и изолированные запросы только из соображений понятности структуры кода или просто более удобной навигации.
indestructable
18.07.2017 17:12+2redux очень похож на CQRS+event sourcing. Данные для чтения — это state, данные для записи — это бекэнд. В серверную архитектуру его, конечно, один в один не перенести, но подходы и проблемы очень похожи.
В реальных приложениях практически невозможно использовать "чистые" команды, которые не возвращают результата. Нужно будет обрабатывать ошибки, а также получать какие-то минимальные результаты, например, идентификаторы созданных сущностей и т.д.
- Не стоит использовать CQRS без необходимости, его нужно применять только в самых нагруженных местах (или в системах, которые хорошо на него ложатся, например, основанных на событиях, типа приложений для такси). Реализовывать на нем CRUD — это удовольствие ниже среднего.
raveclassic
18.07.2017 17:22redux очень похож на CQRS+event sourcing. Данные для чтения — это state, данные для записи — это бекэнд. В серверную архитектуру его, конечно, один в один не перенести, но подходы и проблемы очень похожи.
А
redux-saga
— process-менеджеры, все верно.
zelenin
18.07.2017 19:15+1В реальных приложениях практически невозможно использовать "чистые" команды, которые не возвращают результата. Нужно будет обрабатывать ошибки, а также получать какие-то минимальные результаты, например, идентификаторы созданных сущностей и т.д.
не возвращать данные — общепринятое, но не обязательное решение. Об этом в т.ч. упомянул и Грег Янг, назвав это правило самым большим недопониманием парадигмы.
superroma
20.07.2017 14:55+2redux очень похож на CQRS+event sourcing. Данные для чтения — это state, данные для записи — это бекэнд. В серверную архитектуру его, конечно, один в один не перенести, но подходы и проблемы очень похожи.
Подходы похожи, но проблемы заметно другие. Редьюсеры как в redux не написать если все в памяти не хранить, многопользовательский доступ к командам. Плюс проблемы инфраструктуры (евенты тупо могут не в том порядке прийти или вообще не прийти). Но в общем-то все у Грега Янга подробно описано как что решать. С eventsourcing на сервере и redux на клиенте вообще все гладко получается
dimack
19.07.2017 13:12+4Всё хорошо, только вот пример с методом «IsEmailValid» крайне неудачный. В принципе так нельзя называть метод, который изменяет состояние, какой подход вы бы не использовали.
igoriok
23.07.2017 02:07-1Добавьте в систему события (не путать с event-sourcing) и вы получите отличную архитектуру для микросервисов.
igoriok
23.07.2017 12:04-1Приведу пример:
// contract.dll public class RegisterUser : ICommand { ... } public class UserRegistered : IEvent { ... } public class SendEmail : ICommand { ... } public class UserData { ... } public class GetUserById : IQuery<UserData> { ... } // api.exe public class UserController { private IDispatcher _dispatcher; public void Register(RegisterUserModel model) { _dispatcher.Execute(new RegisterUser { ... }); } } // domain.exe public class User { ... } public interface IUserRepository { ... } public class UserDomainHandler : ICommandHandler<RegisterUser>, IQueryHandler<GetUserById, UserData> { private IDispatcher _dispatcher; private IUserRepository _repository; public void Handle(RegisterUser command) { var user = new User(...); _repository.Insert(user); _dispatcher.Publish(new UserRegistered { ... }); } public UserData Handle(GetUserById query) { var user = _repository.GetById(...); return new UserData { ... }; } // email.exe public interface IEmailService { ... } public class UserEmailHandler : IEventHandler<UserRegistered> { private IDispatcher _dispatcher; public void Handle(UserRegistered event) { var userData = _dispatcher.Query(new GetUserById { ... }); _dispatcher.Execute(new SendEmail { ... }); } } public class CommonEmailHandler : ICommandHandler<SendEmail> { private IEmailService _emailService; public void Handle(SendEmail command) { _emailService.SendEmail(...); } }
Все три процесса независимы и легко маштабируются. Соединяется всё через AMQP (например RabbitMQ).
igoriok
23.07.2017 12:28-1или ещё один пример:
// contract.dll public class ProductData { ... } public class CreateProduct : ICommand { ... } public class ProductCreated : IEvent { ... } public class GetProductById : IQuery<ProductData> { ... } public class SearchProducts : IQuery<List<ProductData>> { ... } // api.exe public class ProductController { private IDispatcher _dispatcher; public void Create(CreateProductModel model) { _dispatcher.Execute(new CreateProduct { ... }); } public ProductModel Get(int id) { var productData = _dispatcher.Query(new GetProductById { ... }); return new ProductModel { ... }; } public List<ProductModel> Search(string query) { var productsData = _dispatcher.Query(new SearchProducts { ... }); return new List<ProductModel>(...) } } // domain.exe public class ProductDomainHandler : ICommandHandler<CreateProduct>, IQueryHandler<GetProductById, ProductData> { private IDispatcher _dispatcher; private IProductRepository _repository; public void Handle(CreateProduct command) { var product = new Product(...); _repository.Insert(product); _dispatcher.Publish(new ProductCreated { ... }); } public ProductData Handle(GetProductById query) { var product = _repository.GetById(...); return new ProductData { ... }; } } // index.exe public class ProductIndexHandler : IEventHandler<ProductCreated>, IQueryHandler<SearchProducts, List<ProductData>> { private IDispatcher _dispatcher; private IElasticClient _client; public void Handle(ProductCreated event) { var productData = _dispatcher.Query(new GetProductById { ... }); _client.Index(productData); } public List<ProductData> Search(SearchProducts query) { var data = _client.Search(...); return data; } }
И что самое интересное, вам даже не обязательно разносить по разным процессам, просто переименуйте в domain.dll и index.dll и запускайте внутри одного процесса, минуя AMQP.
Profyev
23.07.2017 20:01а как быть с асинхронными запросами?
Это и не команда, которая ничего не возвращает, и не запрос, который вернет «здесь и сейчас»?SSul
23.07.2017 20:54Например?
Profyev
24.07.2017 03:20например, запросить примерно это
Task<TResult> GetSomething(какие-то параметры запроса)
результат вернется, но не сразу
franzose
24.07.2017 09:14+1Так в сущности-то запрос остаётся запросом, асинхронен он или нет. Для асинхронных нужно будет использовать что-то вроде
async/await
.
SSul
24.07.2017 09:30+2Тогда может использоваться async/await, в этом случае метод интерфейса IQueryHandler должен будет возвращать Task<>. Для браузера же особой разницы не будет, т.к. он всё так же будет ожидать ответа.
public interface IQueryHandler<in TQuery, out TResult> where TQuery : IQuery<TResult> { Task<TResult> Execute(TQuery query); }
public async Task<TResult> Execute(...) { var result = await GetSomething(...); return result; }
Ezergil
28.07.2017 10:57-1public ActionResult SearchUsers(string searchString)
{
var query = new FindUsersBySearchTextQuery(searchString);
User[] users =_queryDispatcher.Execute(query); return View(users); }
Достоинства CQRS
Меньше зависимостей в каждом классе;
честно говоря, при такой реализации способ уменьшения зависимостей кажется спорным. Во-первых мы прибегаем к антипаттерну диктатор (по Марку Симену), когда зависимости мы создаем напрямую через new. На мой взгляд было бы уместнее использовать что-то вроде абстрактной фибрики запросов, тогда в том числе по сигнатуре контроллера было понятно, что он создает запросы. Ну и в плане класса диспетчера, который по факту тоже является антипаттерном сервислокатор, он создает кажующуюся простоту контроллера. На самом деле он не уменьшает количество зависимостей ( и как следствие сложность) класса, он просто скрывает их реальное количество. В итоге мы жертвуем простотой класса с точки зрения понимания его обязанностей и, как следствие, эффективностью рефакторинга. Было бы рациональнее внедрять конкретные обработчики через конструктор, при этом класс стал бы гораздо понятнее
mayorovp
28.07.2017 13:45+1FindUsersBySearchTextQuery
— это DTO, объект с данными без поведения; у него никогда не появится своих зависимостей. Такие объекты создавать черезnew
можно и нужно.
SSul
28.07.2017 13:52+1Во-первых мы прибегаем к антипаттерну диктатор (по Марку Симену), когда зависимости мы создаем напрямую через new. На мой взгляд было бы уместнее использовать что-то вроде абстрактной фабрики запросов
Теперь мы вынуждены зависеть от фабрики, да и как она будет создавать объекты запросов/команд? Неужелиnew
так плох?
Ну и в плане класса диспетчера, который по факту тоже является антипаттерном сервислокатор, он создает кажующуюся простоту контроллера. На самом деле он не уменьшает количество зависимостей ( и как следствие сложность) класса, он просто скрывает их реальное количество
Диспетчер делегирует вызов обработчиков по переданному сообщению, используется ли в нём Service Locator или нет — это не важно, он оперирует только определенным списком обработчиков. По этому поводу есть довольно интересная статья (англ.).
В итоге мы жертвуем простотой класса с точки зрения понимания его обязанностей и, как следствие, эффективностью рефакторинга. Было бы рациональнее внедрять конкретные обработчики через конструктор, при этом класс стал бы гораздо понятнее
Это хоть и допустимо, но не эффективно, т.к. стоит учитывать, что количество обработчиков может быть неопределенным, тем более если речь идет о рефакторинге.Ezergil
28.07.2017 16:35new не так плох, просто если класс зависит от фабрики, то из сигнатуры конструктора очевидно, какую ответственность он на себя берет. То есть вот этот контроллер создает внутри запросы и по обработчикам видно — какие именно запросы он создает (и обрабатывает), более того, контроллеры это медиаторы, которые должны выполнять как можно меньше работы, но при этом они могут содержать большое количество зависимостей для делегирования обязанностей между ними.
vintage
То есть достаточно назвать метод "TryChangeEmail", который пытается изменить email и возвращает статус изменения, и вашей проблемы с недопониманием что делает метод не будет. Разделение на 2 метода тут не требуется.
Командам как правило необходимо делать запросы в процессе работы. И какой толк в этом случае от разделения команд и запросов, если они всё равно получаются сильно связанными?
raveclassic
Запросы лучше делать не из команд, а из process-менеджеров. Для таких транзакций тут не хватает event sourcing.
vintage
Больше абстракций богу абстракций :-)
mayorovp
Командам нужно делать в процессе работы запросы к базе, но эти запросы не будут буквой Q в CQRS.
vintage
А смысл дублировать код запросов?
mayorovp
А дублирования обычно и не происходит.
vintage
Q: найти всех программистов
C: найти всех программистов и уволить найденных
mayorovp
Это — плохая команда, поскольку подвержена состоянию гонки. Между Q и C наверняка же кто-то просмотрел список программистов и утвердил — и команда должна исполнить именно утвержденное действие, а не абы какое.
Поэтому команды обычно работают с явными списками первичных ключей, в то время как запросы обычно выполняют фильтрацию по неключевым полям.
C: взять сотрудников с id из некоторого списка и уволить найденных
vintage
Вы не фантазируйте про обычно/необычно. Есть бизнес-задача: "Реализовать кнопку быстрого увольнения всех программистов, безо всяких утверждений/подтверждений".
Ну или более реалистичный вариант: "Реализовать кнопку быстрой выплаты всем менеджерам зарплаты" :-)
mayorovp
В такой задаче запрос (
Q
) вообще не нужен, достаточно одной команды (C
).vintage
Реализовать отчёт "список всех менеджеров". Опа, и Q появился. И грех не воспользоваться им из C.
franzose
Отчёт — это отчёт, к чему там команды?
vintage
Q: дай список всех менеджеров
C: возьми (Q) и выдай им зарплату.
mayorovp
Если это две стадии одного сценария использования — то команда должна использовать явный список менеджеров, найденных на первом шаге.
Если же это два различных сценария использования — то лучше им не иметь общего кода. Потому что отчеты имеют свойство меняться по желанию левой пятки начальника, и нежелательно чтобы этот хаос мог затронуть команду. Иными словами, совпадение запросов в Q и C — это случайность, а не закономерность.
vintage
Это уже бизнесу решать должны ли они совпадать. Если левая пятка начальника решила, что маркетологи — тоже программисты, только нейролингвистические, то они должны попасть в "список всех программистов" во всех запросах и во всех командах.
franzose
Немного странно) Отчёт в моём понимании — это, грубо говоря, табличка с выводом сгруппированных данных. Тот же вывод на экран счёта с конкретными позициями, например.
Если я вас правильно понял, то как в таком случае табличка (т.е. сводка) с менеджерами относится к выплате им зарплаты?
vintage
Объём выгружаемых данных зависит от fetch plan, который обычно уникален для каждого запроса и команды. От "список идентификаторов", до "выборка подграфа по заданным полям на заданную глубину". И это далеко не только таблички.
franzose
Всё равно не ясно, как одно с другим соотносится. Отдельно получаем идентификаторы менеджеров, отдельно обновляем менеджеров с этими идентификаторами. При этом команда не знает, откуда пришли эти идентификаторы, их ей просто передали.
zelenin
"найти всех программистов" не обязательно выполняются в одном контексте. Разделение Q и C происходит как раз для того, чтобы было удобнее оптимизировать чтение и запись, например читать из быстрого для чтения хранилища (денормализованный nosql), а записывать в стандартную реляционку.
raveclassic
Не воспринимайте query как какой-то промежуточный запрос в базу и серии "найти -> уволить найденных".
Query — "Найти всех программистов" для, например отображения в списке в UI.
Command — "Уволить всех имеющихся программистов", которая попадает в процесс-менеджер, который идет в сервис/аггрегат и с ним уже может общаться через несколько методов и/или через ES или просто через один готовый метод.
vintage
Как ни накручивай абстракции, а код получения списка всех программистов будет одинаковый.
raveclassic
Зависит от того, где эти программисты лежат — во Write DB или в Read DB.
CQRS не решает вопрос переиспользования какого-либо кода, а лишь вопрос масштабируемости.
Никто вам не мешает все сваливать в один сервис с одной базой и с методом tryFireEverybody. Когда придется масштабироваться — будете накручивать абстракции.
AndreyRubankov
т.е. останется всего лишь решить «тривиальный» вопрос репликации WriteDB -> ReadDB, чтобы чтение всегда было консистентным?
raveclassic
Да, но мы помним про eventual consistency, как тут уже несколько раз упоминали.
AndreyRubankov
Соответственно, в случаях, когда нужно прочитать, то, что только что записали — этот подход не очень хорош.
В этом случае нужно будет бороться с CQRS:
1. ждем, пока база синхронизируется (долгий/очень долгий запрос команды)
2. делаем «синхронизацию» руками (сложный и скользкий путь, с множеством проблем)
В целом, подход очень хороший, но он подходит больше для «энтерпрайзов» с большим количеством сложной логики и сложными workflow, где нету необходимости быстро отдать ответ.
Правильно?
raveclassic
Именно, все верно, причем для высоконагруженных энтерпрайзов.
Если вам интересно, могу посоветовать вот эту книжку.
indestructable
Насколько я понимаю, в CQRS зависимость почти всегда односторонняя: обработчики команд могут обновлять данные для чтения, но никогда не читают их.
В случае, когда нужно перед записью данных их прочитать (а нужно практически всегда), то читают из данных для записи (если нет event sourcing, а данные реляционные), или обработчик команд имеет внутреннее состояние, которое и используется для принятия решений (и обновляется при вызовах команд) — это в случае, если есть ивент сорсинг и данные для записи так просто не прочесть.
Ну и опять же, даже в случае с ивент сорсингом, можно хранить дополнительные реляционные данные (хотя это и влечет дополнительные проблемы).
zelenin
все так
indestructable
В CQRS данные для чтения (для запросов) могут отличаться от данных для обработки (для команд). Поэтому дублирования (и гонки) тут не будет, скорее всего.
vintage
Да они везде могут отличаться. А могут и не отличаться. Смысл дублировать логику, если нужны ровно те же данные?
indestructable
Если они не отличаются, CQRS не нужен.
Смысл его в том, чтобы оптимизировать производительность приложения, выполняя расчеты (трансформации данных) во время записи (а не во время чтения).
Поэтому же CQRS подходит только приложениям, у которых интенсивность чтения намного больше интенсивности записи.
vintage
Никто не мешает "оптимизировать производительность приложения, выполняя расчеты во время записи" и без разделения кода на кучки. От того, что вы разделили код на C и Q предагрегация у вас в коде волшебным образом не появится.
indestructable
Ну, в общем-то, да. Можно.
CQRS, если он с командами и event sourcing-ом, имеет много больше точек оптимизации, плюс некоторые фичи, типа получения состояния на любой момент времени.
И разделение кода на кучки — это так само получается, если все это реализовывать.
ПС Разделение кода на C и Q — это, вообще-то, здравая идея, которая называется CQS (command-query separation). Этот принцип говорит, что каждый метод в приложении должен либо возвращать данные, либо их модифицировать, но модификация при чтении — это плохая идея.
vintage
Модификация при чтении — это либо говнокод, либо такая бизнес задача (считать число запросов, например). CQ®S тут ни при чём. А вот чтение при модификации — необходимая штука. А корень всех проблем — неидемпотентность. Например, запрос getTime, хотя и ничего не изменяет, но не идемпотентен, поэтому его ни закешировать, ни дёрнуть лишний раз нельзя. А вот команда setUserName(name), хотя и изменяет данные, но идемпотентен, а значит её можно спокойно вызывать сколько угодно раз, получая один и тот же результат, или наоборот, не вызывать, если имя пользователя и так уже равно передаваемому.
SSul
Конечно, в случае такого простого примера это может и не понадобиться.
Основная суть заключается в том, чтобы в подобных ситуациях было контролируемое разделение. В качестве другого примера можно взять более сложную и довольно типичную задачу: имеется метод, в котором идет создание нового пользователя, а после этого метод возвращает связанный с ним объект или его Id. Тем самым смешиваются команда и запрос.
На что это влияет? Это сразу отбрасывает возможность асинхронной операции, а это означает, что пользователь будет вынужден ждать, пока завершится операция, и вернется результат. Т.к. мы вынуждены возвращать результат сразу после команды, мы не сможем, например, добавить дополнительную БД, которая бы хранила и возвращала данные для запросов, а команды бы выполнялись на другой БД, тем самым распределяя нагрузку.
Иными словами, чем сложнее логика, тем больше эти и какие-либо другие факторы будут сказываться как на производительности, так и на сложности самого приложения.
В CQRS предлагается стремиться к тому, чтобы команда (или запрос) выполняла только строго определенную задачу, а вот сама команда уже может являться частью какой-либо бо?льшей задачи.
То есть все необходимые запросы и валидация должны быть сделаны до того, как команда начнет выполняться, и ей должны быть переданы необходимые данные. Тем самым будут иметься отдельные Query, вместо того, чтобы выполнять их в команде.
buriy
И как cqrs предлагает справляться с проблемой консистентности изменений?
Как теперь делать создание нового пользователя и получение его id?
Как поиск его по имени пользователя? А если кто-то другой это имя пользователя в это же время зарегистрировал, как мы сможем это понять и не начать использовать чужую запись?
Как я понимаю, ваша описанная схема вообще не дружит с транзакциями.
Можно придумать обходные пути (двухфазный коммит, версионирование), но это уже усложнение, а не упрощение. Подобные усложнения приходится использовать для big data, но это вынужденная мера, а не преимущество подхода.
А вот если бы вы разрешили командам одновременно писать и получать данные, а запросам бы запретили писать — то уже стало бы удобнее этим пользоваться.
DrFdooch
проблему консистентности данных я бы оставил слою хранения данных. код должен будет обработать ошибку выполнения команды в соответствии с требованиями.
мне кажется, что CQRS не запрещает обращения к базе для чтения в командах (и точно не запрещает этого во всей подсистеме выполнения команд). тем не менее, лично я выношу код чтения в подготовку контекста команды, в коде исполнения оставляя исключительно операции записи.
mayorovp
Чтение в командах нужно хотя бы для того чтобы избежать конфликтов изменений.
indestructable
В данных для записи консистентность будет строгая (как в обычном приложении). А вот между базами для чтения и для записи будет eventual consistency, то есть они будут согласованы когда-нибудь, но не прямо сейчас. Именно поэтому в обработчиках команд обычно не используется Read DB.
vintage
И как потом найти свежесозданного пользователя, если команда не будет возвращать нам его id?
Не отбрасывает. Возвращается либо id создаваемого пользователя, либо id асинхронной операции. Иначе о результате команды можно узнать лишь как-нибудь косвенно.
Можем. Создаём 2 соединения: из одного читаем, в другое пишем. Оба соединения могут быть легко инкапсулированы в одном субд адаптере, позволяя программисту вообще не думать о том, что у него есть 2 базы.
SSul
Генерировать Id перед созданием пользователя, если только это не автоинкремент в БД (возможные проблемы с которым уже не относятся к CQRS).
Если используется обычный async/await, то да. Если же команда обрабатывается в очереди, то здесь не получится что-либо вернуть.
Если только эти БД обновляются одновременно, но обычно БД для чтения обновляется позже, через какое-то время после изменений в БД для записи, иначе в ней пропадет смысл при её частом обновлении. То есть не получится сразу после создания пользователя получить по нему данные, его просто еще не будет существовать.
vintage
Вот абстракции и потекли. Не, я, конечно, за предгенерацию id, но это далеко не всегда возможно.
Идентификатор элемента очереди. Или вы исповедуете принцип "с моей стороны вылетело, а дальше судьба команды меня не волнует"?
Ну так сами себе же создали проблемы :-) Если бы команда сразу же и возвращала данные свежесозданного пользователя или запросы могли сходить в основную базу и заполнить кеш, то таких проблем бы не было.
raveclassic
Задаете correlation id команде и ждете себе спокойно по каналу чтения событие "пользователь создан" (или что там у вас в бизнес логике прописано) с нужным cid. Получили ивент, достали все нужные данные. Выигрыш тут в прекрасной масштабируемости канала чтения.
Более того, в конечном приложении вы это разделение можете спрятать обратно в модель и отдать один метод "создать пользователя", возвращающий ID. Только внутрянка у вас CQRS+ES.
SSul
Как и строгое следование принципам CQS. Все сводится к балансу между жестким следованием принципами и производственной необходимостью, и какие последствия это может за собой повлечь.
Зависит от реализации.
В простом случае команда вполне может вернуть статус выполнения успешно/неуспешно.
В другом случае команда может послать событие, что создание пользователя завершилось успешно, или не успешно, если по какой-то причине выполнить команду не удалось, и затем как-либо оповестить пользователя.
Или вообще без оповещения пользователя, вызывающему коду может лишь понадобиться знать, смогла ли команда выполниться или нет, чтобы, например, попытаться её выполнить снова в случае неудачного выполнения (типа недоступности сервера для отправки email).
CQRS в этом плане выступает как основа для подобных действий.
Потому что БД для чтения используется для повышения производительность запросов на чтение, а не записи, в первой ссылке в источниках об этом рассказывается подробнее.
UX меняется в случае использования CQRS с разными БД или Event Sourcing.
superroma
Если это использовать в связке с DDD — Domain Aggregate, то у агрегата должна быть вся информация для выполнения команды и он не должен делать запросы по ходу этого дела. Единственные запросы у агрегата — сохранение и восстановление своего состояния. При применении eventsourcing там все вообще очень хорошо и красиво абстрагируется.