Русский
Русский
English
Статистика
Реклама

Cqs

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

01.03.2021 00:05:02 | Автор: admin
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{    ...}

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

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru