Введение в CQRS + Event Sourcing: Часть 1. Основы

    В первый раз я услышал о CQRS, когда устроился на новую работу. В компании, в которой работаю и по сей день, мне сразу сказали что на проекте, над которым я буду работать используется CQRS, Event Sourcing, и MongoDB в качестве базы данных. Из этого всего я слышал только о MongoDB. Попытавшись вникнуть в CQRS, я не сразу понял все тонкости данного подхода, но почему-то мне понравилась идея разделения модели взаимодействия с данными на две — read и write. Возможно потому что она как-то перекликалась с парадигмой программирования “разделение обязанностей”, возможно потому что была очень в духе DDD.

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

    Сразу хочу уточнить что я работал только со связкой CQRS + Event Sourcing, и никогда не пробовал просто CQRS, так как мне кажется что без Event Sourcing он теряет очень много бенефитов. В качестве CQRS фреймворка я буду использовать наш корпоративный Paralect.Domain. Он чем-то лучше других, чем то хуже. В любом случае советую вам ознакомиться и с остальными. Я здесь упомяну только несколько фреймворков для .NET. Наиболее популярные это NCQRS, Lokad CQRS, SimpleCQRS. Так же можете посмотреть на Event Store Джонатана Оливера с поддержкой огромного количества различных баз данных.

    Начнем с CQRS


    Что же такое CQRS?
    CQRS расшифровывается как Command Query Responsibility Segregation (разделение ответственности на команды и запросы). Это паттерн проектирования, о котором я впервые услышал от Грега Янга (Greg Young). В его основе лежит простое понятие, что вы можете использовать разные модели для обновления и чтения информации. Однако это простое понятие ведет к серьёзным последствиям в проектировании информационных систем. (с) Мартин Фаулер

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

    Вот как я изобразил схему работы CQRS


    Первое что бросается в глаза это то что у вас уже две модели данных, одна для чтения (Queries), одна для записи (Commands). И обычно это значит что у вас еще и две базы данных. И так как мы используем CQRS + Event Sourcing, то write-база (write-модель) — это Event Store, что-то вроде лога всех действий пользователя (на самом деле не всех, а только тех которые важны с точки зрения бизнес-модели и влияют на построение read-базы). А read-база — это в общем случае денормализировнное хранилище тех данных, которые вам нужны для отображения пользователю. Почему я сказал что read-база денормализированная? Вы конечно можете использовать любую структуру данных в качестве read-модели, но я считаю что при использовании CQRS + Event Sourcing не стоит сильно заморачиваться над нормализвацией read-базы, так как она может быть полностью перестроена в любое время. И это большой плюс, особенно если вы не хотите использовать реляционные базы данных и смотрите в сторону NoSQL.
    Write-база вообще представляет собой одну коллекцию ивентов. То есть тут тоже нету смысла использовать реляционную базу.

    Event Sourcing


    Идея Event Sourcing в том чтобы записывать каждое событие, которое меняет состояние приложения в базу данных. Таким образом получается что мы храним не состояние наших сущностей, а все события которые к ним относятся. Однако мы привыкли к тому чтобы манипулировать именно состоянием, оно храниться у нас в базе и мы всегда можем его посмотреть.
    В случае с Event Sourcing мы тоже оперируем с состоянием сущности. Но в отличии от обычной модели мы это состоянием не храним, а воспроизводим каждый раз при обращении.



    Если посмотреть на код, который поднимает агрегат из базы, можно и не заметить какую-то разницу с традиционным подходом.

    var user = Repository.Get<UserAR>(userId);
    


    На самом же деле репозиторий не берет из базы готовое состояние агрегата UserAR (AR = Aggregate Root), он выбирает из базы все события которые ассоциируются с этим юзером, и потом воспроизводит их по порядку передавая в метод On() агрегата.

    Например у класса агрегата UserAR должен быть следующий метод, для того чтобы восстановить в состоянии пользователя его ID

    protected void On(User_CreatedEvent created)
    {
                _id = created.UserId;
    }
    


    Из всего состояния агрегата мне нужна только _id юзера, так же я мог бы восстановить состояние пароля, имени и т.д. Однако эти поля могут быть модифицированы и другими событиями, не только User_CreatedEvent соответственно мне нужно будет обработать их все. Так как все события воспроизводятся по порядку, я уверен, что всегда работаю с последним актуальным состоянием агрегата, если я конечно написал обработчики On() для всех событий которые это состояние изменяют.

    Давайте рассмотрим на примере создания пользователя как работает CQRS + Event Sourcing.

    Создание и отправка команды


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

    public class User_CreateCommand: Command
        {
            public string UserId { get; set; }
    
            public string Password { get; set; }
    
            public string Email { get; set; }
        }
    

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

    var command = new User_CreateCommand
                                  {
                                      UserId = “1”,
                                      Password = “password”,
                                      Email = “test@test.com”,
    
                                  };
                command.Metadata.UserId = command.UserId;
                _commandService.Send(command);
    


    Затем мне нужен обработчик этой команды. Обработчику команды обязательно нужно передать ID нужного агрегата, по этому ID он получит агрегат из репозитория. Репозиторий строит объект агрегата следующим образом: берет из базы все события которые относятся к этому агрегату, создает новый пустой объект агрегата, и по порядку воспроизведет полученные события на объекте агрегата.
    Но так как у нас команды создания — поднимать из базы нечего, значит создаем агрегат сами и передаем ему метаданные команды.

    public class User_CreateCommandHandler: CommandHandler<User_CreateCommand>
        {
            public override void Handle(User_CreateCommand message)
            {
                var ar = new UserAR(message.UserId, message.Email, message.Password, message.Metadata);
                Repository.Save(ar);
            }
        }
    


    Посмотрим как выглядит конструктор агрегата.
    public UserAR(string userId, string email, string password, ICommandMetadata metadata): this()
            {
                _id = userId;
                SetCommandMetadata(metadata);
                Apply(new User_CreatedEvent
                {
                    UserId = userId,
                    Password = password,
                    Email = email
                });
            }
    


    Так же у агрегата обязательно должен быть конструктор без параметров, так как когда репозиторий воспроизводит состояние агрегата, ему надо сначала создать путой экземпляр, а затем передавать события в методы проекции (метод On(User_CreatedEvent created) является одним из методов проекции).
    Немного уточню на счет проекции. Проекция — это воспроизведение состояния агрегата, на основе событий из Event Store, которые относятся к этом агрегату. В примере с пользователем — это все события для данного конкретного пользователя. А агрегате все те же события которые сохраняются через метод Apply, можно обработать во время воспроизведения его состояния. В нашем фреймворке для это достаточно написать метод On(/*EventType arg*/), где EventType — тип события которое вы хотите обработать.

    Метод Apply агрегата инициирует отправку событий всем обработчикам. На самом деле события будут отправлены только при сохранение агрегата в репозиторий, а Apply просто добавляет их во внутренний список агрегата.
    Вот обработчик события(!) создания пользователя, который записывает в read базу собственно самого пользователя.

    public void Handle(User_CreatedEvent message)
            {
                var doc = new UserDocument
                              {
                                  Id = message.UserId,
                                  Email = message.Email,
                                  Password = message.Password
                              };
                _users.Save(doc);
            }
    


    У события может быть несколько обработчиков. Такая архитектура помогает сохранять целостность данных, если ваши данные сильно денормализированы. Допустим мне надо часто показывать общее количество пользователей. Но у меня их слишком много и операция count по всем очень затратна для моей БД. Тогда я могу написать еще один обработчик события, который будет уже относится к статистике и каждый раз при добавлении пользователя будет увеличивать общий счетчик пользователей на 1. И я буду уверен, что никто не создаст пользователя, не обновив при этом счетчик. Если бы я не использовал CQRS, а была бы у меня обычная ORM, пришлось бы следить в каждом месте где добавляется и удаляется пользовать чтоб обновился и счетчик.
    А использование Event Sourcing’а даёт мне дополнительные приемущеста. Даже если я ошибся в каком-то EventHandler’е или не обработал событие везде где мне это надо, я могу легко это исправать просто перегенировав read базу с уже правильной бизнесс логикой.

    С созданием понятно. Как происходит изменение агрегата и валидация команды? Рассмотрим пример со сменой пароля.
    Я приведу только command handler и метод агрегата ChangePassword(), так как в остальных местах разница в общем не большая.

    Command Handler

    public class User_ChangePasswordCommandHandler: IMessageHandler<User_ChangePasswordCommand>
        {
    // Конструктор опущен
            public void Handle(User_ChangePasswordCommand message)
            {
    // берем из репозитория агрегат
                var user = _repository.GetById<UserAR>(message.UserId);
    // выставляем метаданные
                user.SetCommandMetadata(message.Metadata);
    // меняем пароль
                user.ChangePassword(message.OldPassword, message.NewPassword);
    // сохраняем агрегат
                _repository.Save(user);
            }
        }
    
    


    Aggregate Root

    public class UserAR : BaseAR
        {
          //...
    
            public void ChangePassword(string oldPassword, string newPassword)
            {
    // Если пароль не совпадает со старым, кидаем ошибку
                if (_password != oldPassword)
                {
                    throw new AuthenticationException();
                }
    // Если все ОК - принимаем ивент
                Apply(new User_Password_ChangedEvent
                          {
                              UserId = _id,
                              NewPassword = newPassword,
                              OldPassword = oldPassword
                          });
            }
    
    
    // Метод проекции для восстановления состояния пароля
            protected void On(User_Password_ChangedEvent passwordChanged)
            {
                _password = passwordChanged.NewPassword;
            }
    
    // Так же добавляем восстановления состояния пароля на событие создания пользователя
             protected void On(User_CreatedEvent created)
            {
                _id = created.UserId;
                _password = created.Password;
            }
        }
    }
    


    Хочу заметить что очень не желательно чтобы невалидное событие было передано в метод Apply(). Конечно вы сможете его обработать потом в event handler’е, но лучше вообще его не сохранять, если оно вам не важно, так как это только засорит Event Store.
    В случае со сменой пароля вообще нет никакого смысла сохранять это событие, если вы конечно не собираете статистку по неудачным сменам пароля. И даже в этом случае следует хорошенько подумать, ножно ли это событие вам во write модели или есть смысл записать его в какое-нибудь темповое хранилище. Если вы предполагаете что бизнес логика валидации события может измениться то тогда сохраняйте его.

    Вот собственно и все о чем я хотел рассказать в этой статье. Конечно она не раскрывает все аспекты и возможности CQRS + Event Sourcing, об этом я планирую рассказать в следующих статьях. Так же остались за кадром проблемы которые возникают при использовании данного подхода. И об этом мы тоже поговорим.
    Если у вас есть какие-то вопросы, задавайте их в комментариях. С радостью на них отвечу. Так же если есть какие-то предложения по следующим статьям — очень хочется их услышать.

    Sources


    Полностью рабочий пример на ASP.NET MVC находится здесь.
    Базы данных там нету, все храниться в памяти. При желании её достаточно просто прикрутить. Так же из коробки есть готовая реализация Event Store на MongoDB для хранения ивентов.
    Чтобы её прикрутить достаточно в Global.asax файле заменить InMemoryTransitionRepository на MongoTransitionRepository.
    В качестве read модели у меня статическая коллекция, так что при каждом перезапуске данные уничтожаются.

    What's Next?


    У меня есть несколько идей на счет статей по данной тематике. Предлагайте еще. Говорите что наиболее интересно.
    • Что такое Snapshot’ы, зачем нужны, детали и варианты реализации.
    • Event Store.
    • Регенерация базы данных. Возможности. Проблемы, производительность. Распараллеливание. Патчи.
    • Дизайн Aggregate Root’ов.
    • Применение на реальных проектах. Один проект на аутсорсинге, второй — мой стартап.
    • Особенности интеграции сторонних сервисов.
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 11
    • +5
      Вот именно вопрос «производительности» мне и интересен. Потому что на простых примерах (когда за IO можно уследить глазками) всё всегда хорошо. Плохо начинается, когда запросы летят десятками тысяч в секунду. Обычная БД скрипит, но терпит. А если мы ведём журнал + head состояния? Пока всё хорошо, всё хорошо. А потом нам надо rebuild и мы стоим перед задачей перемолотить пол-года того, что в нормальном режиме грузило систему на 50%…
      • 0
        Да, действительно, если при подобной нагрузке на запись регенерация требует дополнительных оптимизаций. Я обязательно подниму эти вопросы в следующих статьях. Спасибо за отзыв.
      • +2
        Спасибо большое за статью и главное за выложенный пример. Буду ждать продолжения в серии.
        • +2
          Спасибо за статью!
          Ждем продолжения, особенно описание Saga.
          • +1
            Большое спасибо за статью. Как раз актуальна эта тема сейчас.
            Ждём продолжения!
            • –2
              Любопытный материал. И все-таки href=«tsya.ru» ться.
              • +1
                Хорошее начало!
                Можно далее рассказать об основной проблеме при использовании CQRS + Event Sourcing: Как строить AR и как решать проблему взаимодействия между разными AR.
                • +1
                  На самом же деле репозиторий не берет из базы готовое состояние агрегата UserAR (AR = Aggregate Root), он выбирает из базы все события которые ассоциируются с этим юзером, и потом воспроизводит их по порядку передавая в метод On() агрегата.

                  А если событий достаточно много, нагрузка возрастает, какие пути решения? Я так понимаю промежуточное хранение состояния через определенный интервал событий?
                  • 0
                    Да, абсолютно верно. Это называется snapshot. Думаю следующая статья будет именно про них.
                  • +1
                    Жду описания применения на реальных проектах
                    • 0
                      Все будет, Рома, все будет =)

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