CQS (CQRS) со своим блэкджеком

Command-query separation (CQS) — это разделение методов на read и write.

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 можно увидеть несколько блоков, за каждым из которых может скрываться код наподобие нашего кода из контроллера или вложенная серия блоков.

image

И приведу пример одной реальной задачи: по гео-координатам получить погоду в заданной точке. Есть внешний условно-бесплатный сервис. Поэтому требуется оптимизация в виде кэша. Кэш не простой. Хранится в базе данных. Алгоритм выборки: сначала идем в кэш, если там есть точка в радиусе 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/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *