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 { ... }
И тому подобное.
ссылка на оригинал статьи https://habr.com/ru/post/544034/
Добавить комментарий