Pull to refresh

CQRS. Факты и заблуждения

Reading time10 min
Views185K

CQRS — это стиль архитектуры, в котором операции чтения отделены от операций записи. Подход сформулировал Грег Янг на основе принципа CQS, предложенного Бертраном Мейером. Чаще всего (но не всегда) CQRS реализуется в ограниченных контекстах (bounded context) приложений, проектируемых на основе DDD. Одна из естественных причин развития CQRS — не симметричное распределение нагрузки и сложности бизнес-логики на read и write — подсистемы Большинство бизнес-правил и сложных проверок находится во write — подсистеме. При этом читают данные зачастую в разы чаще, чем изменяют.

Не смотря на простоту концепции, детали реализации CQRS могут значительно отличаться. И это именно тот случай, когда дьявол кроется в деталях.

От ICommand к ICommandHandler


Многие начинают реализацию CQRS с применения паттерна «команда», совмещая данные и поведение в одном классе.

public class PayOrderCommand
{
    public int OrderId { get; set; }

    public void Execute()
    {
        //...
    }
}

Это усложняет сериализацию / десериализацию команд и внедрение зависимостей.

public class PayOrderCommand
{
    public int OrderId { get; set; }
    
    public PayOrderCommand(IUnitOfWork unitOfWork)
    {
        // WAT?
    }
    
    public void Execute()
    {
        //...
    }
}

Поэтому, оригинальную команду делят на «данные» — DTO и поведение «обработчик команды». Таким образом сама «команда» больше не содержит зависимостей и может быть использована как Parameter Object, в т.ч. в качестве аргумента контроллера.

public interface ICommandHandler<T>
{
    public void Handle(T command)
    {
        //...
    }
}

public class PayOrderCommand
{
    public int OrderId { get; set; }
}

public class PayOrderCommandHandler: ICommandHandler<PayOrderCommand>
{
    public void Handle(PayOrderCommand command)
    {
        //...
    }
}

Если вы хотите использовать сущности, а не их Id в командах, чтобы не заниматься валидацией внутри обработчиков, можно переопределить Model Binding, хотя этот подход сопряжен с недостатками. Чуть позже мы рассмотрим, как вынести валидацию, не меняя стандартный Model Binidng.

ICommandHandler должен всегда возвращать void?


Обработчики не занимаются чтением, для этого есть read — подсистема и часть Query, поэтому всегда должны возвращать void. Но как быть с Id, генерируемыми БД? Например, мы отправили команду «оформить заказ». Номеру заказа соответствует его Id из БД. Id нельзя получить, пока запрос INSERT не выполнен. Чего только не придумают люди, что обойти это выдуманное ограничение:

  1. Последовательный вызов CreateOrderCommandHandler, а затем IdentityQueryHandler<Order&gt
  2. Out — параметры
  3. Добавление в команду специального свойства для возвращаемого значения
  4. События
  5. Отказ от автоинкрементных Id в пользу Guid. Guid приходи в теле команды и записывается в БД

Хорошо, а как быть с валидацией, которую невозможно провести без запроса к БД, например, наличие в БД сущности с заданным Id или состояние счета клиента? Здесь все просто. Чаще всего просто выбрасывают исключение, несмотря на то, что ничего «исключительного» в валидации нет.

Грег Янг четко обозначает свою позицию по этому вопросу (25 минута): «Должен ли обработчик команды всегда возвращать void? Нет, список ошибок или исключение может быть результатом выполнения». Обработчик может возвращать результат выполнения операции. Он не должен заниматься работой Query — поиском данных, что не значит, что он не может возвращать значение. Главным ограничением на этот счет являются ваши требования к системе и необходимость использования асинхронной модели взаимодействия. Если вы точно знаете, что команда не будет выполнена синхронно, а вместо этого попадет в очередь и будет обработана позже, не рассчитывайте получить Id в контексте HTTP-запроса. Вы можете получить Guid операции и опрашивать статус, предоставить callback или получить ответ по web sockets. В любом случае, void или не void в обработчике – меньшая из ваших проблем. Асинхронная модель заставит изменить весь пользовательский опыт, включая интерфейс (посмотрите, как выглядит поиск авиабилетов на Ozon или Aviasales).

Не стоит рассчитывать, что void в качестве возвращаемого значения позволит использовать одну кодовую базу для синхронной и асинхронной моделей. Отсутствие же значимого возвращаемого результата может вводить в заблуждение потребителей вашего API. Кстати, используя исключения для control flow вы все-равно возвращаете значение из обработчика, просто делаете это неявно, нарушая принцип структурного программирования.

На всякий случай, на одном из DotNext я спросил мнение Дино Эспозито по этому поводу. Он согласен с Янгом: обработчик может возвращать ответ. Это может быть не void, но это должен быть результат операции, а не данные из БД. CQRS – это высокоуровневый концепт, дающий выигрыш в некоторых ситуациях (разные требования к read и write подсистемам), а не догма.
Грань между void и не void еще менее заметна в F#. Значению void в F# соответствует тип Unit. Unit в функциональных языках программирования – своеобразный синглтон без значений. Таким образом разница между void и не void обусловлена технической реализацией, а не абстракцией. Подробнее о void и unit можно прочесть в блоге Марка Симана

А что с Query?


Query в CQRS чем-то может напомнить Query Object. Однако, на деле это разные абстракции. Query Object – специализированный паттерн для формирования SQL c помощью объектной модели. В .NET с появлением LINQ и Expression Trees паттерн утратил свою актуальность. Query в CQRS — это запрос на получение данных в удобном для клиента виде.

По аналогии с Command CommandHandler логично разделить Query и QueryHandler. И в данном случае QueryHandler уже действительно не может возвращать void. Если по запросу ничего не найдено, мы можем вернуть null или использовать Special Case.

Но в чем тогда принципиальная разница между CommandHandler<TIn, TOut> и QueryHandler<TIn, TOut>? Их сигнатуры одинаковы. Ответ все тот же. Разница в семантике. QueryHandler возвращает данные и не меняет состояние системы. CommandHandler, наоборот меняет состояние и, возможно, возвращает статус операции.

Если одной семантики вам мало, можно внести такие изменения в интерфейс:

public interface IQuery<TResult>
{
}
 
public interface IQueryHandler<TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

Тип TResult дополнительно подчеркивает, что у запроса есть возвращаемое значение и даже связывает его с ним. Эту реализацию я подсмотрел в блоге разработчика Simple Injector'а и соавтора книги Dependency Injection in .NET Стивена ван Дейрсена. В своей реализации мы ограничились заменой названия метода с Handle на Ask, чтобы сразу видеть на экране IDE, что выполняется запрос без необходимости уточнять тип объекта.

public interface IQueryHandler<TQuery, TResult>
{
    TResult Ask(TQuery query);
}



А нужны ли другие интерфейсы?


В какой-то момент может показаться, что все остальные интерфейсы доступа к данным можно сдать в утиль. Берем несколько QueryHandler'ов, собираем из них хендлер по больше, из них еще больше и так далее. Компоновать QueryHandler'ы имеет смысл только если у вас существуют отдельно use case'ы A и B и вам нужен еще use case, который вернет данные A + B без дополнительных преобразований. По типу возвращаемого значения не всегда очевидно, что вернет QueryHandler. Поэтому легко запутаться в интерфейсах с разными generic-параметрами. Кроме того C# бывает многословным.

public class SomeComplexQueryHandler
{
    IQueryHandler<FindUsersQuery, IQueryable<UserInfo>> findUsers;
    IQueryHandler<GetUsersByRolesQuery, IEnumerable<User>> getUsers;
    IQueryHandler<GetHighUsageUsersQuery, IEnumerable<UserInfo>> getHighUsage;
 
    public SomeComplexQueryHandler(
        IQueryHandler<FindUsersQuery, IQueryable<UserInfo>> findUsers,
        IQueryHandler<GetUsersByRolesQuery, IEnumerable<User>> getUsers,
        IQueryHandler<GetHighUsageUsersQuery, IEnumerable<UserInfo>> getHighUsage)
    {
        this.findUsers = findUsers;
        this.getUsers = getUsers;
        this.getHighUsage = getHighUsage;
    }
}

Удобнее использовать QueryHandler как точку входа для конкретного use case. А для получения данных внутри создавать специализированные интерфейсы. Так код будет более читаемым.
Если идея компоновки маленьких функций в большие не дает вам покоя, то рассмотрите вариант смены языка программирования. В F# эта идея воплощается гораздо лучше.

Можно ли write-подсистеме использовать read-подсистему и наоборот?


Еще один догмат – никогда нельзя перемешивать write и read – подсистемы. Строго говоря, здесь все верно. Если вам захотелось получить данные из QueryHandler внутри обработчика команды, скорее всего это значит, что CQRS в данной подсистеме не нужен. CQRS решает конкретную проблему: read — подсистема не справляется с нагрузками.

Одним из самых популярных вопросов в DDD-группе до недавнего времени был: «Мы используем DDD и у нас тут есть годовой отчет. Когда мы пытаемся его построить наш слой бизнес-логике поднимает в оперативную память агрегаты и оперативная память заканчивается. Как нам быть?». Ясно как: написать оптимизированный SQL-запрос вручную. Это же касается посещаемых веб-ресурсов. Нет нужды поднимать все ООП-великолепие, чтобы получить данные, закешировать и отобразить. CQRS – предлагает отличный водораздел: в обработчиках команд мы используем доменную логику, потому что команд не так много и потому что мы хотим, чтобы были выполнены все проверки бизнес-правил. В read — подсистеме, наоборот, желательно обойти слой бизнес-логики, потому что он тормозит.

Смешивая read и write подсистемы, мы теряем водораздел. Смысл семантической абстракции теряется даже на уровне одного хранилища. В случае, когда read — подсистема использует другое хранилище данных, вообще нет гарантии, что система находится в согласованном состоянии. Раз актуальность данных не гарантирована, теряется смысл проверок бизнес-слоя. Использование write — подсистемы в read — подсистеме вообще противоречит смыслу операции: команды по определению меняют состояние системы, а query – нет.

У каждого правила, впрочем, есть исключения. В том же видео минутой раньше Грег приводит пример: «вам требуется загрузить миллионы сущностей, чтобы сделать расчет. Вы будете грузить все эти данные в оперативную память или выполните оптимальный запрос?». Если в read — подсистеме уже есть подходящий query handler и вы используете один источник данных никто не посадит вас в тюрьму за вызов query из обработчика команды. Просто держите в голове аргументы против этого.

Возвращать из QueryHandler сущности или DTO?


DTO. Если клиенту требуется весь агрегат из БД что-то не так с клиентом. Более того, обычно требуются максимально плоские данные. Вы можете начать используя LINQ и Queryable Extensions или Mapster на этапе прототипирования. И по необходимости заменять реализации QueryHandler на Dapper и / или другое хранилище данных. В Simple Injector есть удобный механизм: можно зарегистрировать все объекты, реализующие интерфейсы открытых дженериков из сборки, а для остальных оставить fallback с LINQ. Один раз написав такую конфигурацию не придется ее редактировать. Достаточно добавить в сборку новую реализацию и контейнер автоматом подхватит. Для других дженериков будет продолжать работать фолбек на LINQ-реализацию. Mapster, кстати не требует создавать профайлы для маппинга. Если вы соблюдаете соглашения в названиях свойств между Entity и Dto проекции будут строиться автоматом.
С «автомаппером» у нас сложилось следующее правило: если нужно писать ручной мапиинг и встроенных соглашений не достаточно, лучше обойтись без автомапера. Таким образом, переезд на «мапстер» оказался довольно простым.

CommandHandler и QueryHandler — холистические абстракции


Т.е. действующие от начала до конца транзакции. Т.е. типовое использование — один хендлер на запрос. Для доступа к данным лучше использовать другие механизмы, например уже упомянутый QueryObject или UnitOfWork. Кстати, это решает проблему с использованием Query из Command и наоборот. Просто используйте QueryObject и там и там. Нарушение этого правила усложняет управление транзакциями и подключением к БД.

Cross Cutting Concerns и декораторы


У CQRS есть одно большое преимущество над стандартной сервисной архитектурой: у нас всего 2 generic-интерфейса. Это позволяет многократно повысить полезность шаблона «декоратор». Есть ряд функций, необходимых любому приложению, но не являющихся бизнес-логикой в прямом смысле: логирование, обработка ошибок, транзакционность и т.п. Традиционно варианта два:

  1. смириться и замусоривать бизнес-логику такими зависимостями и сопутствующим кодом
  2. посмотреть в сторону АОП: с помощью интерцепторов в runtime, например Castle.Dynamic Proxy или переписывая IL на этапе компиляции, например PostSharp

Первый вариант плох своей многословностью и копипастой. Второй – проблемами с производительностью и отладкой, зависимостью от внешних инструментов и «магией». Вариант с декораторами – находится где-то посередине. С одной стороны, мы можем вынести сопутствующую логику в декораторы. С другой, в этом нет никакой магии. Весь код написан человеком и его можно отладить.

Помните, я обещал решить проблему валидацией входных параметров без изменения ModelBinder’а? Вот и ответ, реализуйте декоратор для валидации. Если вас устраивает использование исключений, то выбросите ValidationExcepton.

public class ValidationQueryHandlerDecorator<TQuery, TResult>
    : IQueryHandler<TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    private readonly IQueryHandler<TQuery, TResult> decorated;
 
    public ValidationQueryHandlerDecorator(IQueryHandler<TQuery, TResult> decorated)
    {
        this.decorated = decorated;
    }
 
    public TResult Handle(TQuery query)
    {
        var validationContext = new ValidationContext(query, null, null);
        Validator.ValidateObject(query, validationContext,
          validateAllProperties: true);

        return this.decorated.Handle(query);
    }
}

Если нет, — можно сделать небольшую оберточку и использовать Result в качестве возвращаемого значения.

    public class ResultQueryHandler<TSource, TDestination>
        : IQueryHandler<TSource, Result<TDestination>>
    {
        private readonly IQueryHandler<TSource, TDestination> _queryHandler;

        public ResultQueryHandler(IQueryHandler<TSource, TDestination> queryHandler)
        {
            _queryHandler = queryHandler;
        }

        public Result<TDestination> Ask(TSource param)
            => Result.Succeed(_queryHandler.Ask(param));
    }

SimpleInjector предлагает удобный способ для регистрации открытых generic’ов и декораторов. Всего одной строчкой кода можно вставить логирование перед выполнением, после выполнения, навесить глобальную транзакционность, обработку ошибок, автоматическую подписку на доменные события. Главное не слишком переусердствовать.

Есть определенное неудобство с двумя интерфейсами IQueryHandler и ICommandHandler. Если мы хотим включить логирование или валидацию в обеих подсистемах, то придется написать два декоратора, с одинаковым кодом. Что-ж, это не типичная ситуация. В read-подсистеме, вряд ли потребуется транзакционность. Тем не менее, примеры с валидацией и логированием вполне себе жизненные. Можно решить эту проблему перейдя от интерфейсов к делегатам.

public abstract class ResultCommandQueryHandlerDecorator<TSource, TDestination>
        : IQueryHandler<TSource, Result<TDestination>>  
        , ICommandHandler<TSource, Result<TDestination>>  
    
    {
        private readonly Func<TSource, Result<TDestination>> _func;

        // Хендлеры превращаются в элегантные делегаты
        protected ResultCommandQueryCommandHandlerDecorator(
            Func<TSource, Result<TDestination>> func)
        {
            _func = func;
        }
         
        // Для Query
        protected ResultCommandQueryCommandHandlerDecorator(
            IQueryHandler<TSource, Result<TDestination>> query)
            : this(query.Ask)
        {
        }
 
        // Для Command
        protected ResultCommandQueryCommandHandlerDecorator(
            ICommandHandler<TSource, Result<TDestination>> query)
            : this(query.Handle)
        {
        }
        
        protected abstract Result<TDestination> Decorate(
            Func<TSource, Result<TDestination>> func, TSource value);

        public Result<TDestination> Ask(TSource param)
            => Decorate(_func, param);

        public Result<TDestination> Handle(TSource command)
            => Decorate(_func, command);
    }

Да, в этом случае тоже есть небольшой оверхед: придется объявить два класса только для кастинга передаваемого в конструктор параметра. Это тоже можно решить путем усложнения конфигурации IOC-контейнера, но мне проще объявить два класса.

Альтернативный вариант — использовать интерфейс IRequestHandler для Command и Query, а чтобы не путаться использовать naming convention. Такой подход реализован в библиотеке MediatR.
Tags:
Hubs:
+29
Comments108

Articles

Change theme settings