Создаем IT-продукты на заказ

Мы делаем анализ IT-проекта или IT-продукта. За 5 лет мы создали продукты с топовыми e-commerce компаниями, ритейлом, банками и другими бизнесами.

Мы специализируемся на SaaS-решениях на архитектуре микросервисов.

Посмотрите, что говорят о нас клиенты и как комфортно мы стартуем проекты.

Для обсуждения проекта пишите на ceo@byndyusoft.com или звоните +7 (904) 305 5263

пятница, 18 июля 2014 г.

Command and Query Responsibility Segregation (CQRS) на практике

Эту статью можно считать продолжением темы про переход от монолитной архитектуры к распределенной. Одной из целей применения CQRS тоже является переход к горизонтальному масштабированию. Однако, кроме этого CQRS дает рад преимуществ на уровне дизайна кода и простоты поддержки. Всё, что написано в статье, применяется в проектах моей компании уже несколько лет.

Возможно, вы уже используете CQRS, сами того не зная. Изучение данной темы поможет структурировать приемы, характерные для CQRS, использовать общепринятые названия в коде и разговаривать с коллегами на одном языке, оперируя понятиями команд и запросов.

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

Для тех, кто любит смотреть, а не читать, предлагаю видео и слайды с очень близким содержанием. Также на видео есть много вопросов-ответов, которые у вас могут возникнуть:

Если вам больше нравится читать, то ниже статья с более подробными комментариями и ссылками.

Основная теория CQRS

Рассмотрение темы мы начнем с принципа Command–query separation (CQS). Первым упоминанием CQS считается книга B. Meyer Object Oriented Software Construction. Основная идея CQS заключается в том, что в объекте методы могут быть двух типов:

  • Queries: Методы возвращают результат, не изменяя состояние объекта. Другими словами у Query не никаких side effects.
  • Commands: Методы изменяют состояние объекта, не возвращая значение. На самом деле более корректно называть эти методы modifiers или mutators, но так исторически сложилось, что они называются командами.

Для примера рассмотрим класс User с одним методом IsEmailValid:

public class User
{
    public string Email { get; private set; }

    public bool IsEmailValid(string email)
    {
        bool isMatch = Regex.IsMatch("email pattern", email);

        if (isMatch)
        {
            Email = email; // Command
        }

        return isMatch; // Query
    }
}

Мы спрашиваем у пользователя (делаем Query) является ли валидным email. Если да, то ждем в ответ true, иначе false. Кроме возврата значения, программист, который писал метод IsEmailValid, решил в случае валидного email сразу присваивать его значение (делаем Command) полю Email.

Пример довольно простой, но представьте себе метод Query, который при вызове в нескольких уровнях вложенности меняет состояние разных объектов. Вы сталкивались с долгим дебагов таких методов? Подобные side effects от вызова Query часто обескураживают, т.к. сложно разобраться в работе системы.

Давайте воспользуемся принципом CQS и разделим методы на Command и Query:

public class User
{
    public string Email { get; private set; }

    public bool IsEmailValid(string email) // Query
    {
        return Regex.IsMatch("email pattern", email);
    }

    public void ChangeEmail(string email) // Command
    {
        if (IsEmailValid(email) == false)
            throw new ArgumentOutOfRangeException(email);
        Email = email;
    }
}

Теперь пользователь нашего класса не увидит никаких изменений состояния при вызове IsEmailValid. Кроме того, пользователь явно поменяет состояние объекта, вызвав метод ChangeEmail.

В CQS у Query есть одна особенность. Раз Query никак не меняет состояние объекта, то методы типа Query можно очень хорошо параллелить. Это мы обсудить чуть позже на уровне дизайна всего приложения.

CQRS на уровне дизайна приложения

Точно такая же идея лежит в основе принципа Command Query Responsibility Segregation (CQRS). Но в этом случае поднимается на уровень объектов, а не методов в объекте. Для изменения состояния системы делается класс Command, а для выборки данных класс Query. Таким образом, мы получаем набор объектов, которые меняют состояние системы, и набор объектов, которые возвращают данные. Давайте рассмотрим на примере.

Типовой дизайн системы, где есть UI, бизнес-логика и БД:

CQRS говорит нам, что не надо смешивать объекты Command и Query, а нужно их явно выделить и получим:

Дальше мы будем использовать эти базовые понятия и структуры, чтобы придать системе возможность горизонтального масштабирования. Но уже сейчас, явно выделив Command и Query в вашем коде, вы получите более стройный дизайн системы.

Если класс является Command, то:

  • Изменяет состояние системы
  • Ничего не возвращает
  • Хорошо описывает предметную область, как действия пользователей над системой
  • Контекст команды хранит нужные для её выполнения данные

Пример класса, который является командой:

public class DeleteUserCommand : ICommand<DeleteUserContext>
{
    private readonly ISession session;

    public DeleteUserCommand(ISession session)
    {
        this.session = session;
    }

    public void Execute(DeleteUserContext context)
    {
        session.Delete<User>(context.UserId);
    }
}

Обратите внимание, что у класса типа ICommand есть только один метод Execute, который возвращает void. Так будут выглядеть все команды в вашем проекте. Класс DeleteUserContext - тот самый контекст, который несет в себе данные, необходимые для команды.

Если класс является Query, то:

  • Не изменяет состояние системы. Никаких side effects!
  • Контекст запроса хранит нужные для её выполнения данные (пейджинг, фильтры и т.п.)
  • Возвращает результат

Пример класса, который является запросом:

public class FindUserByIdQuery : IQuery<FindByIdContext, User>
{
    private readonly ISession session;

    public FindUserByIdQuery(ISession session)
    {
        this.session = session;
    }

    public User Ask(FindByIdContext context)
    {
        return session.Query<User>()
            .SingleOrDefault(x => x.Id == context.Id);
    }
}

Обратите внимание, что у класса типа IQuery есть только один метод Ask, который возвращает данные. Так будут выглядеть все запросы в вашем проекте. Класс FindByIdContext - это контекст, который несёт в себе данные, необходимые для запроса.

Эволюция кода

Принцип CQRS и его применение, как и другие принципы, не родился из вакуума, а появился в ходе постоянного решения одних и тех же проблем. Мы тоже взяли CQRS не просто потому что захотелось взять что-то новенькое и опробовать. Были проблемы с шаблоном Repository и организацией бизнес-логики.

Я опишу, как эволюционным путем, мы дошли до CQRS. Возможно на каком-то этапе вы увидите свою систему и поймете куда дальше стоит двигать ее дизайн.

Типовой подход к проектированию приложения:

Пожалуй, каждый разработчик хотя бы раз в жизни применял такой подход. Глядя на эту схему, мы можем сделать выводы, что у нас есть некий Repository, который абстрагирует нас от хранилища данных. В слое бизнес-логики у нас есть объекты, которые инкапсулируют бизнес-правила. Обычно их названия варьируются в пределах Services-BusinessRules-Managers-Helpers и т.п. buzzwords. Запрос пользователя проходит сквозь бизнес-правила и дальше через Repository работает с хранилищем данных.

Repository v1.0

Когда я узнал про шаблон Repository и начал его использовать, это существенно улучшило код. Всю работу с БД мы собрали в одном месте и скрыли за репозиторием. Первая версия шаблона Repository выглядела примерно так:

public interface IRepository<TEntity>
{
    void Create(TEntity entity);

    TEntity Get(int id);

    void Update(TEntity entity);

    void Delete(TEntity entity);
}

Канонический Repository созданный как Fowler завещал. Проблемы начались дальше.

Repository v2.0

Для работы в проекте нам не достаточно просто CRUD операций. Нужны различные хитрые выборки данных. Раз Repository - это наш объект для работы с хранилищем, то в него и добавим нужные методы:

public class AccountRepository : IRepository<Account>
{
    public IEnumerable<Account> GetActiveAccounts()
    {
        // ...
    }

    public void ChangeAccountAddress(int id, string newAddress)
    {
        // ...
    }

    public IEnumerable<Account> GetPremiumAccountsByManager()
    {
        // ...
    }

В большом проекте количество методов на один Repository может достигать десятков. Методы начинают переиспользовать друг друга и методы соседних репозиториев. В итоге, мы получаем объекты с большим количеством ответственности, что нарушает SRP.

Если у вас были такие большие репозитории и вы использовали DIP с IoC-контейнером, то вы видели заваленный зависимостями конструктор из инжектируемых интерфейсов:

public class AccountRepository : IRepository<Account>
{
    public AccountRepository(
        IPriceCalculator priceCalculator,
        IMessageDispatcher messageDispatcher,
        IEmailSender emailSender,
        IDataContext dataContext,
        IAwsProvider awsProvider,
        ISphinxProvider sphinxProvider,
        IMongoDbProvider mongoDbProvider
        /* ... */)
    {

Чем больше Repository, тем больше у него различных зависимостей, тем больше у него причин для изменения, тем больше мы инжектируем, тем сложнее его мОчить. В целом, ничего хорошего от такого God-object'а мы не получим.

Repository v3.0

Давайте сделаем следующий шаг эволюции. Зачем писать столько методов в одном классе? Ведь можно вернуть наружу IQueryable и пусть клиент, вызывающий метод Repository, делает с ним что хочет. Описание такого подхода вы можете увидеть в статье Беспроблемный шаблон Repository, которая была ответом на мою статью про проблемы с этим шаблоном.

Кроме того, если хорошая статья Jimmy Bogard Limiting your abstractions, где он развивает тему отказа от Repository. Вместо этого можно взять сессию RavedDB (статья на примере этого хранилища) и работать с ним из контроллера напрямую.

В обеих статьях авторы абсолютно правы, но в их подходах есть существенное ограничение. Проект не должен быть большим с длинной историей и постоянными изменениями. Дело в том, что мы отдаем не просто IQueryable в контроллер, мы отдаем написание бизнес-правил в методы контроллера. Очевидно, что со временем эти бизнес-правила начнут меняться и дублироваться в разных контроллерах и придется делать какой-то промежуточный объект, чтобы эти бизнес-правила собрать воедино. Может даже назвать этот объект Repository?

Вторая проблема с таким подходом в том, что он оперирует понятием одной ORM и СУБД. В проектах, которые мы делаем последние несколько лет обычно есть несколько СУБД, кэши и поисковые движки, причем по мере жизни проекта один хранилища убираются, на их смену приходят более подходящие под текущую ситуацию на проекте. В таком разнообразии опасно отдавать все "сессии" напрямую в контроллеры, иначе система станет неповоротливой и монолитной.

Repository v4.0

Давайте посмотрим на суть проблемы. В нашем Repository много методов и кода, потому что много ответственности. Что это за ответственности? Обычно это логические условия выборки:

session.Query<User>()
 .Where(x => x.Activated);

session.Query<User>()
 .Where(x => x.Activated &&
    x.Balance > 0);

session.Query<User>()
 .Where(x => x.Activated &&
    x.Balance > 0 &&
    ...);

И стратегии подгрузки вложенных объектов (fetch):

session.Query<User>()
    .Fetch(x => x.Bills);
            
session.Query<User>()
    .Fetch(x => x.Bills)
    .Fetch(x => x.Roles);

session.Query<User>()
    .Fetch(x => x.Bills)
    .Fetch(x => x.Roles)
    .Fetch(...);

Раз так, то давайте вынесем эту логику в отдельные классы. Предикаты условий выборки вынесем в Specification:

public class ActiveAccountSpecification : ISpecification<Account>
{
    public Func<Account, bool> IsSatisfiedBy()
    {
        return x => x.IsActive && x.Credit > 0;
    }
}

Стратегии подгрузки в FetchStrategy:

public class AccountCommentFetchStrategy : IFetchStrategy<Account>
{
    public Action<Account> Apply()
    {
        return x => x.Posts.Select(p => p.Comments);
    }
}

Тогда наш Repository может превратиться в набор почти пустых методов:

public class AccountRepository : IRepository<Account>
{
    public IEnumerable<Account> GetAccounts(
   IFetchStrategy<Account>[] fetchStrategies,
          ISpecification<Account>[] specifications)
    {
        // ...
    }

Repository v5.0

Мы уже вынесли из репозитория много логики, но в нем еще остались методы и зависимости. Теперь вспоминаем про CQRS и думаем, что часть методов Repository меняет состояние системы, а часть возвращает данные. Давайте каждый метод Repository сделаем отдельным классом:

Каждый метод будет тянуть только свои зависимости и решать только одну бизнес-задачу:

public class FindPremiumAccountsByManagerQuery : IQuery<FindPremiumAccountsByManagerContext, User>
{
    private readonly ISession session;

    public FindPremiumAccountsByManagerQuery(ISession session)
    {
        this.session = session;
    }

    public User Ask(FindPremiumAccountsByManagerContext context)
    {
        return session.Query<User>()…;
    }
}

Применив CQRS к списку обязанностей Repository мы получаем много маленьких Command и Query. С точки зрения системы:

  • Меньше зависимостей в каждом классе
  • Соблюдается SRP
  • Маленький класс проще заменить
  • Маленький класс проще тестировать
  • В целом дизайн кода однотипный и понятный
  • При расширении функциональности системы сложность дизайна растет почти линейно

Что с Services/Managers/BusinessRules?

История слоя с бизнес-правилами точно такая же:

  • Растет количество классов такого типа
  • Растет количество методов в каждом классе
  • Растет количество зависимостей каждого класса
  • Разбиваем сервисы на Command и Query

Я думаю тенденция рефакторинга понятна - вместо больших классов с множеством зависимостей мы движемся в сторону большого количества маленьких однотипных классов, каждый из которых отвечает за единственное бизнес-правило, что соответствует SRP.

Эволюция архитектуры

На уровне кода мы дошли до множества маленьких Command и Query. Есть ли от этого какие-то преимущества на уровне архитектуры проекта?

Типовая архитектура с Shared DB и первые попытки ускорить работу системы подробно рассмотрены в статье Переход от монолитной архитектуры к распределенной, сейчас я не буду повторно останавливаться на этом моменте.

Давайте сразу рассмотрим конечную схему с CQRS и двумя различными хранилищами данных:

Command будут работать с доменом и менять состояние системы. Query будут является представлениями для состояния системы, которые "заточены" под быстрые выборки данных. Стоит заметить, что для хранилищ, откуда Query делает выборки, обычно выбирают NoSQL решения, основанные на архитектуре с характеристиками BASE, для возможности горизонтального масштабирования.

Если у вас получился дизайн с явным выделением Command и Query с одной или несколькими хранилищами, то считайте, что вы используете CQRS.

Event Sourcing

Все статьи и выступления на конференциях, где говорят про CQRS, обязательно рассказывают про Event Sourcing.

На самом деле использование CQRS с Event Sourcing не является обязательным условием. Давайте разберемся, что же такое Event Sourcing, почему его рекомендуют использовать вместе с CQRS и какие есть ограничения.

Предпосылки к Event Sourcing

Возьмем для примера реляционную БД MSSQL. В ней есть понятие лога транзакции. При изменении значения ячейки, в лог транзакций записывается что и когда мы поменяли. Если взять лог транзакций и "проиграть" его от начала до конца, то получится текущее состояние БД.

В Event Sourcing мы берем концепцию лога транзакций и реализуем её в коде в явном виде. Теперь каждое изменение состояние системы не записывается в БД напрямую, а сохраняется в виде Event'а. Хранилище содержит не сами данные, а набор Event'ов со всеми изменениями, которые были в системе. Это хранилище обычно называют Event Store.

Если мы не храним данные, а только лог изменений, то как делать запросы для выборки данных? Чтобы сделать запрос к БД, мы создаем специальные проекции, основанные на логе Event'ов. Аналог проекций в MSSQL - это View, но разница в том, что View основаны на данных в БД (состоянии), а проекции создаются и обновляются на основе списка Event'ов.

Ниже примеры бизнес-задач, которые могут обратить ваше внимание на использование Event Sourcing:

  • Каким было состояние системы 2 недели назад на момент события Х?
  • Пользователям надо отменять любые действия в системе
  • Имеете ли вы право затереть данные в ячейке новыми? На сколько важны старые данные? Можем ли мы позволить себе потерять старые значения?
  • Сами события переходов между состояниями являются важной частью аналитики

Для примера рассмотрим работу электронной Scrum-доски. User Story со временем можем менять свой размер, т.е. переоцениваться.

На сколько важно команде и менеджеру проекта знать динамику изменения оценок? Можем ли мы просто в поле оценка поменять 8SP на 1SP, навсегда потеряв изначальную цифру? От ответа на этот вопрос зависит будем ли мы использовать Event Sourcing.

Или другой пример. Пользователь интернет-магазина добавил в корзину продукт, потом удалил его оттуда, потом добавил пару других и т.п. Нужна ли бизнесу история манипуляций с корзиной?

Основы Event Sourcing

Я не буду давать подробное описание Event Sourcing, его можно найти в конце в списке рекомендованных статей. Сейчас я бы хотел обозначить основные моменты для понимания:

  • Все изменения, которые попадают в систему, мы записываем в виде дельты - Event. Событие изменения состояния системы должно знать к какому агрегату оно относится, версию и данные об изменении.
  • Текущее состояние домена – это "проигрывание" журнала Event'ов
  • Выборки делаются на проекциях, сами проекции это "проигранные" Event'ы (ниже мы посмотрим их на схеме)
  • Для экономии ресурсов состояние домена не "проигрывается" каждый раз с нуля. Мы можем зафиксировать состояние домена на определенную дату, например, с начала года. Это называется создать snapshot.

Дизайн проекта с Event Sourcing

Мы уже рассмотрели, как поменяется дизайн проекта, когда мы применим CQRS. При накладывании на эту схему Event Sourcing нужно будет внести незначительные модификации:

Теперь Command порождает Event, который записывается в Event Store. В качестве Event Store может выступать любое хранилище, даже файловая система. Загрузка корня агрегата будет делаться накатываем всех событий агрегата, которые есть в Event Store.

Обратите внимание на Event Publisher - это тот самый сервис, который раньше отвечал за переброску данных из основной БД через очередь в БД для чтения. Теперь он принимает поток Event'ов, скорее всего через очередь, и создает из них проекции для Query. Когда пользователь запрашивает данные, то Query обращается как раз к проекции и выбирает данные. По сути поменялось не много.

Надо ли мне Event Sourcing?

В Event Sourcing всё не так гладко, как может показаться на первый взгляд. Есть проблемы, которые не просто решить:

  • Как проектировать агрегаты?
  • Как рефакторить агрегаты? Что делать, если корень агрегата был выбран неверно, а события для него уже есть в Event Store?
  • Как изменять уже произошедшие события?
  • Как накатывать события, которые зависели от данных стороннего сервиса?

Хочу поделиться опытом в нашей компании. Обычно для проекта мы сначала делаем прототипы, на которых обкатываем будущий дизайн системы. Несколько разных подходов показываем заказчикам, из которых потом выбираем наиболее подходящий под требования. Как показала практика, заказчики из разных прототипов не выбирают Event Sourcing. Дело в том, что на данный момент есть не много полноценных инструментов, которые бы обеспечивали инфраструктуру для Event Sourcing.

Когда вы решите использовать у себя ES, то задумайтесь о сложностях, которые несет с собой этот подход. Не перевешивают ли они плюсы? Действительно ли бизнесу надо хранить всю историю изменений? Я считаю, что выбор в сторону ES довольно накладный с точки зрения реализации и инфраструктуры, поэтому должен быть полностью оправдан требованиями к системе.

Ограничения перехода на CQRS

Думаю мы получили понимание работы с Event Sourcing, теперь вернемся к CQRS. Рассмотрим ограничения, с которыми вы столкнетесь, когда возьмете за основу дизайна своей системы CQRS:

  • Подход не очень широко распространен. На данный момент я знаю только про одну книгу, где написано про CQRS и Event Sourcing, это книга Implementing Domain-Driven Design, Vaughn Vernon. Соответственно, если вы используете этот подход у себя в проекте, то есть риск сделать bus factor равный 1
  • При использовании CQRS появляется много мелких классов, а есть люди, которые этого не любят. Особенно критично, если такой человек занимает должность вашего руководителя
  • Если в Command и Query появляется общая логика, то нужно использовать наследование или композицию. Это в целом усложняет дизайн системы, но для опытных разработчиков не является большим препятствием
  • Сложно целиком придерживаться CQS и CQRS. Самый простой пример - метод выборки данных из стека. Выборка данных - это Query, но нам надо обязательно поменять состояние и сделать размер стека -1. На практике вы будете искать баланс между жестким следованием принципами и производственной необходимостью
  • Не всегда Eventually Persisted подходит к UX системы, особенно плохо ложится на CRUD приложения

Примеры реализации и подходы

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

Как выглядит вызов Command из контроллера:

public class EditUser
{
    public int UserId { get; set; }
    public string Name { get; set; }
}

public class UserController : Controller
{
    [HttpPost]
    public ActionResult Edit(EditUser context)
    {
        commandHander.Execute(context);

        return this.RedirectToAction(x => x.List());
    }

В методе контроллера Edit мы просим commandHander выполнить команду с контекстом типа EditUser. CommandHandler через IoC-контейнер или другим способ находит конкретный обработчик команды:

public interface ICommandHandler<T> where T : ICommand
{
    void Handle(T command);
}

public class EditUserCommandHandler : ICommandHandler<EditUser>
{
    public void Handle(EditUser context)
    {
            // обновление данных
    }
}

Вызов Query из контроллера и поиск конкретной Query происходит идентичным образом:

public class FindUserById
{
    public int Id { get; set; }
}

public class UserController : Controller
{
    [HttpGet]
    public ActionResult UserDetails(FindUserById context)
    {
        var dto = queryBuilder
            .For<UserForEditDto>()
            .With(context);

        return View(dto);
    }

Два способа маршрутизации

Каким образом происходит сопоставление вызова Command/Query с конкретным обработчиком? Рассмотрим два способа. Первый синхронный через IoC-контейнер, второй синхронный и асинхронный через очередь.

Первый способ через IoC-конейнер:

Пример с абстрактной фабрикой для Query. На старте приложения мы регистрируем все IQuery и назначим фабрику IQueryBuilder, которая может создавать объекты типа IQuery:

container.AddFacility<TypedFactoryFacility>();

var queries = AllTypes.FromAssemblyNamed("Infrastructure")
    .BasedOn(typeof (IQuery<,>))
    .WithService.AllInterfaces()
    .Configure(x => x.LifeStyle.Transient);

container.Register(
    queries,
    Component.For<IQueryBuilder>()
        .AsFactory().LifeStyle.Transient,
    Component.For(typeof (IQueryFor<>))
        .ImplementedBy(typeof (QueryFor<>))); 

При вызове IQueryBuilder наш IoC-контейнер будет искать IQuery с тем же контекстом (тип generic-параметра). То есть, если вызвать queryBuilder.For(new FindUserById(...)), то IoC-контейнер найдет класс FindUserByIdQuery: IQuery<FindUserById>, иначе вернет ошибку. Пример приложения и регистрации зависимостей можно посмотреть в проекте на github или в статье Заменяем QueryFactory на бестелесный IQueryFactory.

Второй способ через шину

Приведу пример с шиной FakeBus в памяти:

public class HomeController : Controller
{
    [HttpPost]
    public ActionResult ChangeName(Guid id, string name, int version)
    {
        var command = new RenameInventoryItem(id, name, version);
        bus.Send(command);

        return RedirectToAction("Index");
    }

public class FakeBus : ICommandSender, IEventPublisher
{
    public void Send<T>(T command) where T : Command
    {
        List<Action<Message>> handlers;
        if (_routes.TryGetValue(typeof(T), out handlers))
        {
            if (handlers.Count != 1)
  throw new InvalidOperationException();
            handlers[0](command);
        }
        else
        {
            throw new InvalidOperationException("no handler registered");
        }
    }

Здесь сопоставление обработчика делается через _routes - это Dictionary с типом контекста и типом обработчика. По сути тоже самое, что делает IoC-контейнер. Прелесть подхода с шиной, заключается в том, что шина в памяти в любой момент может быть заменена на настоящую.

Промежуточный Dispatcher

Еще один часто используемый шаблон:

Что если вам надо при вызове Command/Query проверять права доступа, записать информацию в лог и т.п.? Вставляем Dispatcher между вызовом команды и ее обработчиком. Шаблон одинаково просто реализуется с IoC-контейнером и шиной.

Готовая инфраструктура CQRS на примере .NET приложений

Прежде чем начать менять текущий дизайн вашей системы или начинать новую систему с использованием CQRS, я рекомендую посмотреть на уже готовые проекты:

В CQRS очень просто наделать ошибок и сделать еще хуже, чем было до этого. Примеры должны облегчить понимание в использовании и дать набор готовых приемов.

Разбор примеров

На конференции AgileDays'14 в Москве после доклада на эту тему было много общения по конкретным проектам. Я заметил, что проблемы с горизонтальным масштабирование часто похоже даже на проектах с совершенно разной предметной областью и технологиями.

Если у вас есть проблемы или вопросы по вашей архитектуре, то предлагаю обсуждать в комментариях или пишите мне на почту.


Ссылки

CQRS Documents by Greg Young

Clarified CQRS, Udi Dahan

CQRS, Martin Fowler

Command Query Separation, Martin Fowler

DDDD, CQRS and Other Enterprise Development Buzz-words, Rinat Abdullin

Brownfield CQRS, Richard Dingwall

Command and Query Responsibility Segregation (CQRS) Pattern, MSDN

Книга Implementing Domain-Driven Design, Vaughn Vernon

33 комментария:

  1. Во всех примерах с использованием CQRS очень плохо расписано: как решается вопрос с чтением данных для выполнения Command, содержащий замороченную бизнес-логику? Все красиво на простых запросах и все ломается при реализации сложной логики с промежуточным чтением временных данных для последующей записи результата (и я не вижу возможности сделать это предварительно, перед вызовом самой Command). Если более конкретно, то вот есть метод сервиса, который что-то считает и пишет результат в БД. Но для того, чтобы рассчитать, он должен прочитать из разных место инфу. Все это занимает каких-то 12-15 строк, но при разделении приложения на command и query мы вынуждены изголяться с этим методом, придумывая как бы нам совместить чтение и запись. Под чтением я подразумевая также обращение к сервисам WCF. Имхо все слишком геморройно с CQRS. В то же время, шина очень даже к месту иногда...

    ОтветитьУдалить
  2. Да, вот еще более поздняя его статья http://lostechies.com/jimmybogard/2012/10/08/favor-query-objects-over-repositories/ и человек в комментариях пишет, что использует extension methods для custom запросов. Я с ним согласен, это удобнее и также абсолютно testable. Спасибо за статью.

    ОтветитьУдалить
  3. Вообще есть мысль, что на самом деле мы выделяем в public методы сервисов логическую операцию, которая является транзакцией и должна быть оформлена в отдельный класс. Она же и является объектом, который может обработать worker целиком из очереди запросов. Разделение на Command и Query лишнее, т.е. данную транзакцию мы можем обрабатывать параллельно с другими из очереди, при этом контекст для чтения и записи данных у нас скорее всего один и тот же. Даже если он поменялся, то скорее всего одновременно для Command и Query.

    ОтветитьУдалить
  4. Никто не запрещает в команде читать данные.

    ОтветитьУдалить
  5. Олег, пример как в Command вызывается Query https://github.com/AlexanderByndyu/ByndyuSoft.Infrastructure/blob/master/samples/aspnetmvc/src/Web.Application/Account/Forms/Handlers/SignInHandler.cs

    Пример, как загружается агрегат с ES https://github.com/gnschenker/cqrs-introduction/blob/master/CqrsIntroduction/AggregateFactory.cs



    Не вижу проблем с вызовом Query внутни Command.

    ОтветитьУдалить
  6. Как и любое другое решение, extension methods вполне рабочее. Но надо понимать их недостатки:
    - это статические методы
    - без возможности инжектирования
    - без возможности мОкания

    ОтветитьУдалить
  7. Если я правильно понял, то вы предлагаете что-то похожее на http://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html

    ОтветитьУдалить
  8. Евгений, в видео я рассказывал об этом. Суть в том, что данные не сразу попадают в хранилище для чтения. Значит мы не можем сразу их выбрать. Если вы делаете админку и используете "отложенную" отправку данных на чтение, то пользователю нужно как-то об этом сказать.


    Классический пример, когда пользователь заказывает столик. Он нажимает заказать, формируется Event, который уходит по очереди в БД для чтения. Можем мы сразу сказать пользователю зарезервировал он стоил или нет? Зависит от скорости прохожения Event'а и его публикации. Если событие идет долго, то мы можем сказать пользователю: "Мы получили ваш запрос. Как только он обработается мы скажем успешно или нет зарезервирован столик. Ответ придет вам на почту/телефон".


    Если бы для этой операции мы использовали реляционную СУБД с транзакциями, то пользователь узнал бы ответ сразу.


    На этом простом примере я хотел показать, что UX меняется в случае использования CQRS с разными БД или ES.

    ОтветитьУдалить
  9. а какой смысл их мокать? мокают контекст. Там только linq выражения и то, что продиктовано контрактом IQuerable. Да, статические методы для конкретного контракта, все нормально. В инжектировании тоже нет смысла, они инкапсулируют запрос.

    ОтветитьУдалить
  10. Ссылку изучу, Саша, спасибо.

    ОтветитьУдалить
  11. Если они на столько простые и никогда не разрастаются, то всё ок.

    ОтветитьУдалить
  12. Олег, отличный пример ограничения в применении. Как я и хотел показать CQRS не серебрянная пуля, а прием для решения конкретных проблем.


    Хотя если покрутить твой пример, но возможность передачи контекста можно добиться. Например, создавать контекст на OnRequest в веб-приложении. Правда вся красота применения CQRS пропадает.

    ОтветитьУдалить
  13. Ну, в общем да, есть ограничения. Везде надо думать надо ли оно на самом деле. :)

    ОтветитьУдалить
  14. А может кто-то кинет по доброте душевной ссылку на книгу Vaughn Vernon в оригинале? Устал искать..., а денех жалко... :)

    ОтветитьУдалить
  15. Александр, заранее прошу прощение за вопрос вне основного контекста статьи. Большинство диаграм в последних статьях выполнены в одном стиле. И я бы хотел знать где они нарисованы. Если это Visio, то что за шаблон был использован? Стиль диаграм мне понравился и я хотел бы и его взять на вооружение.

    ОтветитьУдалить
  16. Дмитрий, это одна из стандартных тем Visio 2013

    ОтветитьУдалить
  17. Привет!

    Отличная статья. Пару вопросов по теме -

    С Domain Model работают только команды? Что тогда возвращают Queries?

    Я правильно понимаю, что вместо прослойки, которую принято называть "бизнес-логикой" вы также делаете набор запросов/команд? Если да, то отделяется ли оно от запросов и команд, которые непосредственно работают с хранилищем? Как вообще к этому вопросу подходите?

    ОтветитьУдалить
  18. Привет!

    > С Domain Model работают только команды?

    Это не обязательное условие. Query внутри могут тоже работать с Domain Model.

    > Что тогда возвращают Queries?

    Query могут возвращать сразу DTO объекты или объекты/коллекции из доменных объектов. Тут вопрос выбора под конкретный проект. В любом случае где-то будет маппинг из доменных объектов в DTO для UI.

    > Я правильно понимаю, что вместо прослойки, которую принято называть "бизнес-логикой" вы также делаете набор запросов/команд?

    И да и нет. Тут может быть много вариантов. Возможно за командами стоят еще какие-то сервисы, чтобы логика в командах не дублировалась. Есть вариант вот с такой архитектурой http://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html с разделением "сервисов" на Use Case'ы.

    > Если да, то отделяется ли оно от запросов и команд, которые непосредственно работают с хранилищем? Как вообще к этому вопросу подходите?

    Непосредственно с хранилищами (MSSQL, кэши, MongoDB и т.п.) работают конкретные провайдеры. Сами запросы и команды не такие низкоуровневые. Опять же может зависеть от проекта. Если проект небольшой и хранилище одного, то такого уровня абстракция может быть лишней. Когда, например, DBContext фигурирует сразу в команде/запросе.

    ОтветитьУдалить
  19. Александр, видимо опечатка:

    В интерфейсе объявлен метод Handle(T command)

    >public interface ICommandHandler where T : ICommand
    {
    void Handle(T command);
    }

    А в реализации метод Execute(EditUser context):

    public class EditUserCommandHandler : ICommandHandler
    {
    public void Execute(EditUser context)
    {
    // обновление данных
    }
    }

    ОтветитьУдалить
  20. Максим, да, спасибо. В разных проектах по-разному было :)

    ОтветитьУдалить
  21. >Например, 10 сервисов анализа текста не справляются с нагрузкой, что делать? Создать еще 10 копий этих сервисов и пусть очередь раздает им задачи. Мы ускорили обработку текстов в 2 раза. Просто ведь?
    Просто, да. И в случае взаимодействия через БД все сервисы также будут вычитывать данные из БД и также легко масштабироваться. Т.е. я всё ещё не вижу преимуществ, хотя честно стараюсь их разглядеть :) Понятно, что в случае если хранить много данных в MSSQL, то работать она будет медленно и может стать "узким горлышком". Но ведь тот же MSMQ начинает жутко тормозить при размере около 300000 сообщений. Кроме того, очереди имеют много неявных ограничений, например, упомянутый размер сообщения.


    Я хочу сказать, что в данном случае все знают как работать с MSSQL, но мало кто знает как оптимизировать работу MSMQ. К тому же, в качестве бесплатного преимущества при использовании БД мы получаем реляционность и возможность выполнять аналитические sql запросы к БД, менять существующие записи, как угодно вклиниваться в процесс, чего нельзя сказать об очередях, где часто даже сами сообщения посмотреть не получается. Кроме того, имхо, использование очередей и прочих относительно новых технологий, делает разработку софта более дорогостоящей, т.к. такая разработка требует более квалифицированных программистов.


    PS: MSSQL и MSMQ выбраны просто в качестве примеров, можно заменить на что-то другое.

    ОтветитьУдалить
  22. Vasya, хочу еще раз обратить внимание, что из БД вычитывать ничего не надо.

    Вот кейс с БД:
    1. Два сервиса А и Б
    2. Сервис А обрабатывает данные, кладет результаты в БД
    3. Сервис Б берет данные из БД

    В пп. 2 и 3 мы делаем 2 лишних запроса к БД. Если сервисов много, у каждого сервиса запущено много экземпляров и все они работют с SharedDB, то БД становится узким местом, которое убрать очень тяжело.

    Кейс с очередью:
    1. Два сервиса А и Б
    2. Сервис А обрабатывает данные, кладет результаты в очередь
    3. Сервис Б берет данные из очереди

    Очередь масштабируется очень просто или вообще автоматически. У нас на IronMQ гоняется порядка 10М сообщений и постоянно балансируется кол-во экземпляров сервисов обработчиков в зависимости от нагрузки. Сделать такое с БД за те же деньги просто нереально.

    > возможность выполнять аналитические sql запросы к БД
    Еще раз, речь именно про потоки данных, а не про их хранение. Зачем выполнять реляционные запросы к данным, которые ходят в очереди. Там обычно идет ID и какая-то мета-информация.

    На счет MSMQ сказать ничего не могу, т.к. его не использовали. Для очередей есть гораздо более продвинутые вещи, чем MSMQ. Я бы порекомендовал для начала RabbitMQ.

    ОтветитьУдалить
  23. Александр, недавно прочитал в блоге Марка Симана (http://blog.ploeh.dk/2014/08/11/cqs-versus-server-generated-ids/) про такой аспект бизнес логики, как сохранение сущностей в БД. Ведь обычно, когда мы сохраняем новую сущность в БД, ей присваивается новый ID, который надо вернуть в систему.
    Т.е. получается следующая сигнатура команды: int Create(T item). Получается команда и запрос одновременно, что нарушает принцип CQRS.

    Марк предлагает использовать генерируемые самому Guid'ы в качестве вторичного ключа, чтобы получить следующие методы:
    void Create(Guid id, T item);
    int GetHumanReadableId(Guid id);

    Не кажется ли вам этот подход избыточным и что вы предлагаете в данном случае?

    ОтветитьУдалить
  24. Александр, если мы говорим про асинхронную обработку команд, то это чуть ли не единственный способ. Если обработка происходит синхронно, то, как я писал в статье, не обязательно ортодоксально придерживаться принципа CQRS, можно и вернуть какой-то параметр. Можно еще вернуть параметр через поле в контексте, но это уже на любителя.

    ОтветитьУдалить
  25. Александр, вот именно это я и имел ввиду, когда написал "можно еще вернуть параметр через поле в контексте, но это уже на любителя"

    ОтветитьУдалить
  26. Valentin Miroshnichenko30 августа 2014 г., 1:38

    Александр, а как быть в ситуации, когда у нас в even’те составной ссылочный объект. К примеру, у меня есть класс Order я упаковываю его в OrderChangeEvent и кладу в очередь. Соответственно, когда разбираю очередь в обработчике то состояние класса может снова изменится, а я ещё не успел обработать первое изменение. Как быть в такой ситуации?

    ОтветитьУдалить
  27. Сергей, не совсем понятна цепочка. Вы отсылаете событие в очередь, очередь разбирается последовательно. Как может измениться состояние класса? Приведите, пожалуйста, пример.

    ОтветитьУдалить
  28. Сергей Лукьянов7 сентября 2014 г., 22:08

    К примеру, мы используем tpl dataflow у нас есть два блока первый
    блок OrderPublisher и второй
    блок OrderLitener. OrderPublisher бубликует
    асинхронно в очередь событие о том, что изменился ордер, а в этой очереди к
    примеру, уже скопилось 1000 событий сответвенно когда OrderLitener доберётся до события о том
    что ордер изменился ордер уже может снова изменится.

    ОтветитьУдалить
  29. В этом случае OrderListener будет менять состояние класса в той последовательности, в которой лежат сообщения в очереди. В чем здесь может быть проблема?

    ОтветитьУдалить
  30. Как решить проблему, если у меня команду и запросы используются одновременно в MVC, Web Api и SignalR?

    ОтветитьУдалить
  31. Добрый день. У меня вопрос, если мне нужно выбрать из БД записи и заинклудить какую-либо таблицу, мне нужно писать один query, есть такие запросы, в которых не нужно будет инклудить таблицу, что бы скорость выборки была выше. Как в данном случае поступать? Писать два разлтчных query? Спасибо.

    ОтветитьУдалить

Создаем IT-продукты на заказ

Мы делаем анализ IT-проекта или IT-продукта. За 5 лет мы создали продукты с топовыми e-commerce компаниями, ритейлом, банками и другими бизнесами.

Мы специализируемся на SaaS-решениях на архитектуре микросервисов.

Посмотрите, что говорят о нас клиенты и как комфортно мы стартуем проекты.

Для обсуждения проекта пишите на ceo@byndyusoft.com или звоните +7 (904) 305 5263