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{ ...}
И тому подобное.