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 контейнер / factory / mediator. И добавить возможность декорировать их вызов.

    Сценарии:

    1. Запускать Story внутри транзакции scoped контекста базы данных:

    public class EntityController
    {
        [HttpPost]
        public EntityModel Update(EntityModel model)
        {
            return _factory.Resolve<UpdateEntityStory>().WithTransaction().Execute(model);
        }
    }
    
    // или
    
    [Transaction]
    public class UpdateEntityStory
    {
        ...
    }
    

    2. Кэшировать вызов

    public class EntityController
    {
        [HttpPost]
        public ResultModel GetAccessRights()
        {
            return _factory
                .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 = _factory
                     .Resolve<GetWeatherFromExternalServiceStory>()
                     .WithRetryAttempt(5)
                     .Execute(lat, lon);
    
                 _factory.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 = _factory
                     .Resolve<GetWeatherFromExternalServiceStory>()
                     .WithRetryAttempt(5).
                     .Execute(lat, lon);
    
                 _factory.Resolve<AddWeatherStory>()
                     .WithDistributedLock(LockType.RedLock, "key", 60)
                     .Execute(weather);
            }
    
            return weather;
        }
    }
    
    // или
    
    [DistributedLock(LockType.RedLock, "key", 60)]
    public class AddWeatherStory
    {
        ...
    }
    

    И тому подобное.

    Комментарии 12

      +2

      Спасибо, получился подробный материал с примерами. Часто статьям про чистоту кода и архитектуру этого не хватает.


      На мой взгляд, то, к чему Вы пришли, пытаясь отобразить бизнес-процессы с применением CQRS-принципа в коде конкретного приложения, получилось идейно близко к чистой архитектуре (Clean Architecture https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html).

        +1

        Вы бы почитали что-нибудь типа "CQRS Documents by Greg Young" и "Exploring CQRS and Event Sourcing", потому что у вас видение CQRS, какое-то, мягко говоря, очень своеобразное.

          +1

          Я читал CQRS Documents by Greg Young. И там есть некоторые вещи, которые меня смущают:


          1. Изначально Бертран Мейер вводил понятие CQS для объектно-ориентированного дизайна. И определяет read-only и write-only методы, которые меняют объект (состояние, данные). Это ближе к понятию чистой функции, без побочных эффектов.


          2. Грег Янг адаптировал CQS под DDD. Я же адаптирую под анемичную модель. И у него точно такая же сборная солянка:


            public void Handle(DeactivateInventoryItem message) // <-- обработчик команды и dto
            {
            var item = _repository.GetById(message.InventoryItemId); // <-- запрос внутри команды
            item.Deactivate(); // <-- собственно бизнес-код по изменению состояния
            _repository.Save(item, message.OriginalVersion);  // <-- старый добрый save-changes
            }

          3. Грег Янг вводит понятие task-based команд. Т.е. это такие штуки, которые запускают сложный бизнес-процесс, а не просто обновляют часть данных. Моя Story похожа на task-based команду. Там тоже все намешано, но я не называю ее командой, которая изначально призвана только менять состояние объекта.


          4. Далее, в случае с DDD команда будет дергать входную точку доменной модели. Но сначала надо загрузить ее состояние из хранилища в оперативную память. Формально при вызове команды внутри не будет query, так как команда обращается к уже загруженной доменной модели. А по факту чем ее будут грузить? Методами-запросами, которые не изменяют состояние. Почему об этом ни слова?


          5. CQRS — это отдельная тема про разделение модели (и следовательно хранилищ). Например, одно для записи с 3НФ, и несколько оптимизированных для чтения с 1НФ (кстати аналог view в реляционных БД). В моем случае — внутри Story можно отправить dto в очередь сообщений и на той стороне несколько 1НФ хранилищ его подхватят и изменят свое состояние. Там это будет контекст обработчика сообщения из очереди с вызовом команды. Причем это будет чистая идеальная команда — она только поменяет состояние и ничего не возвратит. Story там излишняя.


          6. Event Sourcing — тоже отдельная тема. Не обязательно этим пользоватся в составе CQS/CQRS/DDD. Любой компонент, который отслеживает баланс (денежный или складских остатков) по сути и есть event sourcing.


            +1

            У вас просто называется все как-то непривычно совсем, не так как обычно. "Командой" назвали обработчик, а "моделью" собственно саму команду. И MediatR принято использовать совсем не так. Какой смысл его использовать просто как Sеrvice Locator — он совсем не для этого.

              0
              Можно переименовать, не проблема. С медиатором — да, косяк, это фабрика, поправил.
                +1

                Для простой реализации CQRS медиатор как раз замечательно подходит. Просто его нужно использовать действительно как медитор (шину команд), а не как фабрику или локатор их обработчиков.

          +1
          Спасибо за интересные примеры. В отличии от недавней статьи про CQRS, тут более жизненные примеры и видна логика.
            0
            То что Вы называете Story в некоторых публикациях называют use case. В книге Фаулера «Архитектура корпоративных программных приложений» это слой application logic.
            Если посмотреть статью www.codeproject.com/Articles/5283291/Examples-of-Layered-Application-Architecture-Based, то там Story это фасад слоя логики (facade sublayer logic layer) и рассматривается использование этого фасада в разных типах приложений.
            Единственное в чём не могу с Вами согласиться так это то, что одна Story может использовать другие Story. Story это точка входа для каждого юнита логики приложения и на мой взгляд точка входа не должна быть напрямую переиспользована в других точках входа. Хотя безусловно одну и ту же Story можно вызывать из разных контроллеров.
              0
              Благодарю за ссылку, довольно интересно, почитаю.

              По существу — допустим, но вопрос так и остается — куда складывать переиспользуемый код? В query/command нельзя, если это ни то ни другое. В бизнес-сервис? Есть вероятность, что он начнет пухнуть. Можно конечно принудительно на ревью кода ограничить один класс-один метод. Но тогда это и будет моя Story, просто другое наименование.

              Собственно в этом и суть — один класс-один метод. Как их назвать — да как душе угодно. Но метод, по возможности, не должен иметь побочных эффектов, делать только одну вещь (только брать из хранилища, только сохранять в хранилище, только вычислять, только обращаться к внешнему ресурсу) и быть законченным логическим блоком (т.е. делать полную операцию на текущем уровне абстракции/инкапсуляции, а не ее часть). Не везде получится это сделать, но надо стремиться увеличить количество таких методов. Например GetWeatherStory из статьи делает кучу вещей. Но каждая ее составляющая часть делает одну маленькую вещь. А целиком она законченный логический блок — выдает погоду, оставляя хранилище в согласованном состоянии, без дублей и так далее.

              Можно даже пойти дальше и прокидывать Action/Func. Я об этом думал в свое время, но там начинаются проблемы с подтягиванием зависимостей. Надо тогда все прокидывать как параметры функции и это всплывает на уровень фреймворка/платформы. Недавно кстати была статья по этому поводу, которая в более полной мере описывает этот подход habr.com/ru/company/jugru/blog/545482.

              Насчет UseCase — да, соглашусь, есть условно верхнеуровневые story (которые вызываются из контроллеров) и вспомогательные (рассчитать что-то по алгоритму, обратиться к внешнему сервису, отправить уведомление и так далее — у них как раз вероятность переиспользования выше). Не проблема разделить их с помощью имен. Например GetWeatherUseCase, но внутри нее GetWeatherFromExternalServiceStory (-> и теперь можно сократить имя до GetWeatherStory или RequestWeatherStory). Но тут палка о двух концах. Если работа ведется с бизнес-аналитиком и соответствующей документацией — имена story должны совпадать с блоками в документации, чтобы потом новому программисту было легко их найти и сопоставить. В таком случае стиль именования должен быть согласован на более высоком уровне. Так же возможен случай, когда код в UseCase вдруг станет переиспользоваться. Тогда его придется раскидать заново по новым UseCase/Story.
                0
                Основные тезисы Вашего коментария
                >>GetWeatherStory из статьи делает кучу вещей
                и
                >>вспомогательные Story(рассчитать что-то по алгоритму, обратиться к внешнему сервису, отправить уведомление и так далее — у них как раз вероятность переиспользования выше)

                могут быть решены достаточно стандартно для многослойной архитектуры.

                1. Часть многократно используемого функционала может быть реализована в базовых классах, наследником которых является рассматриваемый Story.
                2. Сам функционал слоя логики приложения, который используется объектами Story, реализуется в объектах-сервисах:
                — domain services для логики предметной области приложения; если требуется ddd подход вместо сервисов используется функционал, реализованный в объектах-сущностях предметной области;
                — infrastructure services для взаимодействия с внешними источниками данных типа очередей сообщений и веб-сервисов;
                — persistence services для взаимодействия с внешними хранилищами персистентных данных типа баз данных; в этих сервисах как раз и используются query/command операции.

                При таком подходе вспомогательные Story не потребуются. Каждый Story использует нужный ему набор функционала объектов-сервисов.
                  0
                  Не совсем, основной тезис — куда складывать переиспользуемый код, который может быть как инфраструктурным, так и бизнес-кодом.

                  Базовые классы не очень удобны порой, поэтому лучше их не использовать. Кейс — когда в одной стори надо поведение из двух базовых, а множественного наследования в C# нет. Композиция в данном случае будет лучше.

                  С сервисами — я уже говорил, что они начнут пухнуть. А потом внутри сервисов захочется обратиться к хранилищу за дополнительными данными (см выше в статье пример с EntityService). Но обращение к хранилищу — это ведь тоже есть запрос или команда. Да и с domain сервисами — вы кажется начинаете затрагивать DDD. В случае с DDD команда будет дергать модель предметной области, а та в свою очередь начнет молотить бизнес-логику внутри себя.

                  С infrastructure и persistence — да, все так, но вот там такой бизнес кейс, а не инфраструктурный:
                  1. Покупатель делает заказ в интернет магазине на несколько товаров.
                  2. Возвращает один из товаров — под этим скрывается некий бизнес-процесс для единицы товара. Это возврат товара на склад и возврат части денег.
                  3. Но покупатель может так же вернуть все товары полностью. Но ведь внутри этого бизнес-процесса будет повторяется бизнес-процесс для единицы товара в цикле + некоторые оптимизации (иначе например будет N+1 запрос к БД).
                    0
                    Каждая Story (use case) это отдельный сценарий логики приложения. На каждый сценарий выделяется отдельный класс в фасаде слоя логики.
                    Класс фасада имеет один публичный метод которым пользуется слой представления.
                    Фасад взаимодействует с сервисами напрямую: фасад -> domain services, фасад -> infrastructure services, фасад -> persistence service.
                    Множественное использование функционала сервисов в классах фасадов и будет переиспользованием кода в слое логики.
                    Можно использовать наследование и строить иерархию классов фасадов. Но композиция фасадов, когда один фасад является полем внутри другого, выглядит достаточно странно и такой конструкцией не пользуюсь.

                    Рассмотрим задачу «Покупатель делает заказ в интернет магазине на несколько товаров».
                    1. Добавление каждого товара или выбранного набора товаров (например при помощи чекбоксов на визуальной форме) это одно отдельное обращение к классу фасада бизнес-логики.
                    2. Перевод списка выбранных товаров в статус заказа — одно отдельное обращение к классу фасада бизнес-логики.
                    3. Возврат товара из заказа — одно отдельное обращение к классу фасада бизнес-логики.
                    4. Возврат всех товаров из заказа — одно отдельное обращение к классу фасада бизнес-логики.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое