Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Command-query separation (CQS) — это разделение методов на read и write.
Command Query Responsibility Segregation (CQRS) — это разделение модели на read и write. Предполагается в одну пишем, с нескольких можем читать. М — масштабирование.
Этот подход часто используют как способ организации кода, даже если хранилище одно. Но как всегда, в реальных более-менее сложных проектах эта штука дает сбой.
Размышления навеяны этой статьей Паттерн CQRS: теория и практика в рамках ASP.Net Core 5 и актуальны для анемичной модели. Для DDD все по-другому.
Начать пожалуй стоит с исторической справки. Сначала было как-то так:
С появлением CQS стало так:
Как видим, два потенциальных god-объекта разделяются на много маленьких и каждый делает одну простую вещь — либо читает данные, либо обновляет. Это у нас CQS. Если еще и разделить на два хранилища (одно для чтения и одно для записи) — это будет уже CQRS. Собственно что из себя представляет например GetEntityQuery и UpdateEntityCommand (здесь и далее условный псевдокод):
Теперь к нам приходит ORM. И вот тут начинаются проблемы. Чаще всего сущность сначала достается из контекста и только затем обновляется. Выглядит это так:
Да, если ORM позволяет обновлять сущности сразу, то все будет хорошо:
Так а что делать, когда надо достать сущность из базы? Куда девать query из command? На ум приходит сделать так:
Хотя я встречал еще такой вариант:
Просто перекладываем проблему из одного места в другое. Эта строчка не перестает от этого быть query.
Ладно, допустим остановились на варианте с GetEntityQuery и UpdateEntityCommand. Там хотя бы query не пытается быть чем-то другим. Но куда это все сложить и откуда вызывать? Пока что есть одно место — это контроллер, выглядеть это будет примерно так:
Да и через некоторое время нам понадобилось, например, отправлять уведомления:
В итоге контроллер у нас начинает толстеть.
Мало того, реальные бизнес-процессы сложные. Если взглянуть на диаграммы IDEF0 или BPMN можно увидеть несколько блоков, за каждым из которых может скрываться код наподобие нашего кода из контроллера или вложенная серия блоков.
И приведу пример одной реальной задачи: по гео-координатам получить погоду в заданной точке. Есть внешний условно-бесплатный сервис. Поэтому требуется оптимизация в виде кэша. Кэш не простой. Хранится в базе данных. Алгоритм выборки: сначала идем в кэш, если там есть точка в радиусе 10 км от заданной и в пределах 1 часа по времени, то возвращаем погоду из кэша. Иначе идем во внешний сервис. Здесь и query, и command, и обращение к внешнему сервису — все-в-одном.
Решение давно витало в облаках, но никак не оформлялось в конкретном виде. Пока я однажды не встретил нечто очень похожее на одном проекте. Я взял его за основу и добавил свой блэкджек.
Как видим искомый CQS изначально создан для абстрагирования на уровне доступа к данным. Там с ним проблем нет. Код, который расположился у нас в контроллере — это бизнес-код, еще один уровень абстракции. И именно для этого уровня выделим еще одно понятие — бизнес-история. Или Story.
Одна бизнес-история — это один из блоков на диаграмме IDEF0. Она может иметь вложенные бизнес-истории, как блок IDEF0 может иметь вложенные блоки. И она может обращаться к искомым понятиям CQS — это к Query и Command.
Таким образом, код из контроллера мы переносим в Story:
И контроллер остается тонким.
Данная UpdateEntityStory инкапсулирует в себе законченный конкретный бизнес-процесс. Ее можно целиком использовать в разных местах (например в вызовах API). Она легко подвергается тестированию и никоим образом не ограничивает использование моков/фейк-объектов.
Диаграмму IDEF0/BPMN можно разбросать по таким Story, что даст более легкий вход в проект. Все изменения можно будет уложить в следующий процесс: сначала меняем документацию (диаграмму IDEF0) — затем дописываем тесты — а уже в конце дописываем бизнес-код. Можно наоборот, по этим Story автоматически построить документацию в виде IDEF0/BPMN диаграмм.
Но чтобы получить более стройный подход, необходимо соблюдать некоторые правила:
1. Story — входная точка бизнес-логики. Именно на нее ссылается контроллер.
2. Но внутрь Story не должны попадать такие вещи как HttpContext и тому подобное. Потому что тогда Story нельзя будет легко вызывать в другом контексте (например в hangfire background job или обработчике сообщения из очереди — там не будет никаких HttpContext).
3. Входящие параметры Story опциональны. Story может возвращать что-либо или не возвращать ничего (хотя для сохранения тестируемости хорошо бы она что-нибудь возвращала).
4. Story может работать как с бизнес-сущностями, так и с моделями и DTO. Может внутри вызывать соответствующие мапперы и валидаторы.
5. Story может вызывать другие Story.
6. Story может вызывать внешние сервисы. Хотя внешний вызов можно тоже оформить как Story. Об этом ниже с нашим сервисом погоды.
7. Story не может напрямую обращаться к контексту базы данных. Это область ответственности Query и Command. Если нарушить это правило, все запросы и команды вытекут наружу и размажутся по всему проекту.
8. На Story можно навешивать декораторы. Об этом тоже ниже.
9. Story может вызывать Query и Command.
10. Разные Story могут переиспользовать одни и те же Query и Command.
11. Query и Command не могут вызывать другие Story, Query и Command.
12. Только Query и Command могут обращаться к контексту базы данных.
13. В простых случаях можно обойтись без Story и из контроллеров вызывать сразу Query или Command.
Теперь тот самый пример с сервисом погоды:
И в заключении о декораторах. Чтобы Story стали более гибкими необходимо cложить их в DI контейнер / mediator. И добавить возможность декорировать их вызов.
Сценарии:
1. Запускать Story внутри транзакции scoped контекста базы данных:
2. Кэшировать вызов
3. Политика повторов
4. Распределенная блокировка
И тому подобное.
Command Query Responsibility Segregation (CQRS) — это разделение модели на read и write. Предполагается в одну пишем, с нескольких можем читать. М — масштабирование.
Этот подход часто используют как способ организации кода, даже если хранилище одно. Но как всегда, в реальных более-менее сложных проектах эта штука дает сбой.
Размышления навеяны этой статьей Паттерн CQRS: теория и практика в рамках ASP.Net Core 5 и актуальны для анемичной модели. Для DDD все по-другому.
Историческая справка
Начать пожалуй стоит с исторической справки. Сначала было как-то так:
public interface IEntityService
{
EntityModel[] GetAll();
EntityModel Get(int id);
int Add(EntityModel model);
void Update(EntityModel model);
void Delete(int id);
}
public interface IEntityRepository
{
Entity[] GetAll();
Entity Get(int id);
int Add(Entity entity);
void Update(Entity entity);
void Delete(int id);
}
С появлением CQS стало так:
public class GetEntitiesQuery
{
public EntityModel[] Execute() { ... }
}
public class GetEntityQuery
{
public EntityModel Execute(int id) { ... }
}
public class AddEntityCommand
{
public int Execute(EntityModel model) { ... }
}
public class UpdateEntityCommand
{
public void Execute(EntityModel model) { ... }
}
public class DeleteEntityCommand
{
public void Execute(int id) { ... }
}
Эволюция
Как видим, два потенциальных god-объекта разделяются на много маленьких и каждый делает одну простую вещь — либо читает данные, либо обновляет. Это у нас CQS. Если еще и разделить на два хранилища (одно для чтения и одно для записи) — это будет уже CQRS. Собственно что из себя представляет например GetEntityQuery и UpdateEntityCommand (здесь и далее условный псевдокод):
public class GetEntityQuery
{
public EntityModel Execute(int id)
{
var sql = "SELECT * FROM Table WHERE Id = :id";
using (var connection = new SqlConnection(...connStr...))
{
var command = connection.CreateCommand(sql, id);
return command.Read();
}
}
}
public class UpdateEntityCommand
{
public void Execute(EntityModel model)
{
var sql = "UPDATE Table SET ... WHERE Id = :id";
using (var connection = new SqlConnection(...connStr...))
{
var command = connection.CreateCommand(sql, model);
return command.Execute();
}
}
}
Теперь к нам приходит ORM. И вот тут начинаются проблемы. Чаще всего сущность сначала достается из контекста и только затем обновляется. Выглядит это так:
public class UpdateEntityCommand
{
public void Execute(EntityModel model)
{
var entity = db.Entities.First(e => e.Id == model.Id); // <-- опа, а что это? query?
entity.Field1 = model.Field1;
db.SaveChanges();
}
}
Да, если ORM позволяет обновлять сущности сразу, то все будет хорошо:
public class UpdateEntityCommand
{
public void Execute(EntityModel model)
{
var entity = new Entity { Id = model.Id, Field1 = model.Field1 };
db.Attach(entity);
db.SaveChanges();
}
}
Так а что делать, когда надо достать сущность из базы? Куда девать query из command? На ум приходит сделать так:
public class GetEntityQuery
{
public Entity Execute(int id)
{
return db.Entities.First(e => e.Id == model.Id);
}
}
public class UpdateEntityCommand
{
public void Execute(Entity entity, EntityModel model)
{
entity.Field1 = model.Field1;
db.SaveChanges();
}
}
Хотя я встречал еще такой вариант:
public class UpdateEntityCommand
{
public void Execute(EntityModel model)
{
var entity = _entityService.Get(model.Id); // )))
entity.Field1 = model.Field1;
db.SaveChanges();
}
}
public class EntityService
{
public Entity Get(int id)
{
return db.Entities.First(e => e.Id == model.Id);
}
}
Просто перекладываем проблему из одного места в другое. Эта строчка не перестает от этого быть query.
Ладно, допустим остановились на варианте с GetEntityQuery и UpdateEntityCommand. Там хотя бы query не пытается быть чем-то другим. Но куда это все сложить и откуда вызывать? Пока что есть одно место — это контроллер, выглядеть это будет примерно так:
public class EntityController
{
[HttpPost]
public EntityModel Update(EntityModel model)
{
var entity = new GetEntityQuery().Execute(model.Id);
new UpdateEntityCommand().Execute(entity, model);
return model;
}
}
Да и через некоторое время нам понадобилось, например, отправлять уведомления:
public class EntityController
{
[HttpPost]
public EntityModel Update(EntityModel model)
{
var entity = new GetEntityQuery().Execute(model.Id);
new UpdateEntityCommand().Execute(entity, model);
_notifyService.Notify(NotifyType.UpdateEntity, entity); // <-- А это query или command?
return model;
}
}
В итоге контроллер у нас начинает толстеть.
Лирическое отступление IDEF0 и BPMN
Мало того, реальные бизнес-процессы сложные. Если взглянуть на диаграммы IDEF0 или BPMN можно увидеть несколько блоков, за каждым из которых может скрываться код наподобие нашего кода из контроллера или вложенная серия блоков.
И приведу пример одной реальной задачи: по гео-координатам получить погоду в заданной точке. Есть внешний условно-бесплатный сервис. Поэтому требуется оптимизация в виде кэша. Кэш не простой. Хранится в базе данных. Алгоритм выборки: сначала идем в кэш, если там есть точка в радиусе 10 км от заданной и в пределах 1 часа по времени, то возвращаем погоду из кэша. Иначе идем во внешний сервис. Здесь и query, и command, и обращение к внешнему сервису — все-в-одном.
Решение
Решение давно витало в облаках, но никак не оформлялось в конкретном виде. Пока я однажды не встретил нечто очень похожее на одном проекте. Я взял его за основу и добавил свой блэкджек.
Как видим искомый CQS изначально создан для абстрагирования на уровне доступа к данным. Там с ним проблем нет. Код, который расположился у нас в контроллере — это бизнес-код, еще один уровень абстракции. И именно для этого уровня выделим еще одно понятие — бизнес-история. Или Story.
Одна бизнес-история — это один из блоков на диаграмме IDEF0. Она может иметь вложенные бизнес-истории, как блок IDEF0 может иметь вложенные блоки. И она может обращаться к искомым понятиям CQS — это к Query и Command.
Таким образом, код из контроллера мы переносим в Story:
public class EntityController
{
[HttpPost]
public EntityModel Update(EntityModel model)
{
return new UpdateEntityStory().Execute(model);
}
}
public class UpdateEntityStory
{
public EntityModel Execute(EntityModel model)
{
var entity = new GetEntityQuery().Execute(model.Id);
new UpdateEntityCommand().Execute(entity, model);
_notifyService.Notify(NotifyType.UpdateEntity, entity);
return model;
}
}
И контроллер остается тонким.
Данная UpdateEntityStory инкапсулирует в себе законченный конкретный бизнес-процесс. Ее можно целиком использовать в разных местах (например в вызовах API). Она легко подвергается тестированию и никоим образом не ограничивает использование моков/фейк-объектов.
Диаграмму IDEF0/BPMN можно разбросать по таким Story, что даст более легкий вход в проект. Все изменения можно будет уложить в следующий процесс: сначала меняем документацию (диаграмму IDEF0) — затем дописываем тесты — а уже в конце дописываем бизнес-код. Можно наоборот, по этим Story автоматически построить документацию в виде IDEF0/BPMN диаграмм.
Но чтобы получить более стройный подход, необходимо соблюдать некоторые правила:
1. Story — входная точка бизнес-логики. Именно на нее ссылается контроллер.
2. Но внутрь Story не должны попадать такие вещи как HttpContext и тому подобное. Потому что тогда Story нельзя будет легко вызывать в другом контексте (например в hangfire background job или обработчике сообщения из очереди — там не будет никаких HttpContext).
3. Входящие параметры Story опциональны. Story может возвращать что-либо или не возвращать ничего (хотя для сохранения тестируемости хорошо бы она что-нибудь возвращала).
4. Story может работать как с бизнес-сущностями, так и с моделями и DTO. Может внутри вызывать соответствующие мапперы и валидаторы.
5. Story может вызывать другие Story.
6. Story может вызывать внешние сервисы. Хотя внешний вызов можно тоже оформить как Story. Об этом ниже с нашим сервисом погоды.
7. Story не может напрямую обращаться к контексту базы данных. Это область ответственности Query и Command. Если нарушить это правило, все запросы и команды вытекут наружу и размажутся по всему проекту.
8. На Story можно навешивать декораторы. Об этом тоже ниже.
9. Story может вызывать Query и Command.
10. Разные Story могут переиспользовать одни и те же Query и Command.
11. Query и Command не могут вызывать другие Story, Query и Command.
12. Только Query и Command могут обращаться к контексту базы данных.
13. В простых случаях можно обойтись без Story и из контроллеров вызывать сразу Query или Command.
Теперь тот самый пример с сервисом погоды:
public class GetWeatherStory
{
public WeatherModel Execute(double lat, double lon)
{
var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);
if (weather == null)
{
weather = _weatherService.GetWeather(lat, lon);
new AddWeatherCommand().Execute(weather);
}
return weather;
}
}
public class GetWeatherQuery
{
public WeatherModel Execute(double lat, double lon, DateTime currentDateTime)
{
// Нативный SQL запрос поиска записи в таблице по условиям:
// * в радиусе 10 км от точки lat/lon
// * в пределах 1 часа от currentDateTime
// С использованием расширений PostGis или аналогичных
return result;
}
}
public class AddWeatherCommand
{
public void Execute(WeatherModel model)
{
var entity = new Weather { ...поля из model... };
db.Weathers.Add(entity);
db.SaveChanges();
}
}
public class WeatherService
{
public WeatherModel GetWeather(double lat, double lon)
{
var client = new Client();
var result = client.GetWeather(lat, lon);
return result.ToWeatherModel(); // маппер из dto в нашу модель
}
}
Декораторы
И в заключении о декораторах. Чтобы Story стали более гибкими необходимо cложить их в DI контейнер / mediator. И добавить возможность декорировать их вызов.
Сценарии:
1. Запускать Story внутри транзакции scoped контекста базы данных:
public class EntityController
{
[HttpPost]
public EntityModel Update(EntityModel model)
{
return _mediator.Resolve<UpdateEntityStory>().WithTransaction().Execute(model);
}
}
// или
[Transaction]
public class UpdateEntityStory
{
...
}
2. Кэшировать вызов
public class EntityController
{
[HttpPost]
public ResultModel GetAccessRights()
{
return _mediator
.Resolve<GetAccessRightsStory>()
.WithCache("key", 60)
.Execute();
}
}
// или
[Cache("key", 60)]
public class GetAccessRightsStory
{
...
}
3. Политика повторов
public class GetWeatherStory
{
public WeatherModel Execute(double lat, double lon)
{
var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);
if (weather == null)
{
weather = _mediator
.Resolve<GetWeatherFromExternalServiceStory>()
.WithRetryAttempt(5)
.Execute(lat, lon);
_mediator.Resolve<AddWeatherCommand>().Execute(weather);
}
return weather;
}
}
// или
[RetryAttempt(5)]
public class GetWeatherFromExternalServiceStory
{
...
}
4. Распределенная блокировка
public class GetWeatherStory
{
public WeatherModel Execute(double lat, double lon)
{
var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);
if (weather == null)
{
weather = _mediator
.Resolve<GetWeatherFromExternalServiceStory>()
.WithRetryAttempt(5).
.Execute(lat, lon);
_mediator.Resolve<AddWeatherStory>()
.WithDistributedLock(LockType.RedLock, "key", 60)
.Execute(weather);
}
return weather;
}
}
// или
[DistributedLock(LockType.RedLock, "key", 60)]
public class AddWeatherStory
{
...
}
И тому подобное.