Введение в 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’ов.
    • Применение на реальных проектах. Один проект на аутсорсинге, второй — мой стартап.
    • Особенности интеграции сторонних сервисов.
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 12
    • +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
                      Все будет, Рома, все будет =)
                    • 0

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

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

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