Когда обычный CRUD перестаёт работать: знакомимся с CQRS и Event Sourcing на C++
Вы когда-нибудь сталкивались с ситуацией, когда ваша C++ программа, прекрасно работающая на тестовых данных, начинает «тормозить» в production? Или когда бизнес-аналитики просят показать историю изменений, а у вас — только последнее состояние? Знакомо? Тогда добро пожаловать в мир CQRS (Command Query Responsibility Segregation) и Event Sourcing.
Эти два паттерна — не просто модные слова из мира микросервисов. Это мощные инструменты, которые позволяют строить масштабируемые, аудитируемые и гибкие системы. И да, они отлично ложатся на C++ — язык, где производительность и контроль над памятью имеют решающее значение.
В этой статье мы не будем читать скучные лекции. Мы разберём, как реализовать CQRS и Event Sourcing на чистом C++ без магии фреймворков, с реальными примерами и готовыми к использованию шаблонами.
CQRS: Разделяем чтение и запись
В классической архитектуре CRUD (Create, Read, Update, Delete) мы используем одну и ту же модель и для чтения, и для записи. Это удобно, пока система не начинает расти. Проблемы возникают, когда:
- Объект в памяти слишком сложный, чтобы быстро сериализоваться для чтения.
- Бизнес-логика записи требует валидации, которая замедляет чтение.
- Разные части системы хотят видеть одни и те же данные по-разному.
CQRS предлагает простое, но гениальное решение: разделить модель на две части — Command (команда) для изменения состояния и Query (запрос) для чтения. Они могут использовать разные хранилища, разные структуры данных и даже быть реализованы на разных машинах.
Базовая реализация CQRS на C++
Начнём с простого. Представьте, что мы пишем систему управления заказами. Вместо того чтобы хранить всё в одном классе Order, мы разделим обязанности.
Сначала определим интерфейсы для команд и запросов:
#include <memory>
#include <vector>
#include <string>
// Базовый класс для всех команд
class Command {
public:
virtual ~Command() = default;
virtual void execute() = 0;
};
// Базовый класс для всех запросов
template <typename TResult>
class Query {
public:
virtual ~Query() = default;
virtual TResult execute() = 0;
};
Теперь создадим конкретную команду для добавления товара в заказ:
class AddItemCommand : public Command {
private:
std::string orderId_;
std::string productId_;
int quantity_;
// Ссылка на репозиторий (или шину команд)
OrderRepository& repository_;
public:
AddItemCommand(const std::string& orderId,
const std::string& productId,
int quantity,
OrderRepository& repo)
: orderId_(orderId), productId_(productId),
quantity_(quantity), repository_(repo) {}
void execute() override {
// Бизнес-логика: проверяем, есть ли товар на складе
if (quantity_ <= 0) {
throw std::invalid_argument("Quantity must be positive");
}
// Вызываем доменный сервис или агрегат
auto& order = repository_.getById(orderId_);
order.addItem(productId_, quantity_);
repository_.save(order);
}
};
А вот так выглядит запрос для получения сводки по заказу:
class OrderSummaryQuery : public Query<OrderSummary> {
private:
std::string orderId_;
ReadModelDatabase& db_;
public:
OrderSummaryQuery(const std::string& orderId, ReadModelDatabase& db)
: orderId_(orderId), db_(db) {}
OrderSummary execute() override {
// Читаем из оптимизированной БД для чтения
return db_.getOrderSummary(orderId_);
}
};
Важно: Команды ничего не возвращают (void), они только меняют состояние. Запросы возвращают данные, но не меняют состояние. Это жёсткое правило — основа CQRS.
Event Sourcing: Храним историю, а не состояние
Теперь переходим ко второму паттерну — Event Sourcing. Если CQRS разделяет чтение и запись, то Event Sourcing меняет способ хранения данных.
Вместо того чтобы хранить текущее состояние объекта (например, «заказ №5: статус «оплачен»»), мы храним последовательность событий, которые к этому состоянию привели. Например:
- Событие 1: «Заказ создан»
- Событие 2: «Товар "Книга" добавлен»
- Событие 3: «Оплата получена»
Текущее состояние — это просто результат применения всех этих событий по порядку. Это даёт нам суперсилу: мы можем «отмотать» время назад, увидеть состояние системы на любую дату, и, что самое важное, у нас есть полный аудит.
Реализация Event Sourcing на C++
Для начала нам нужна структура события и агрегат, который эти события применяет.
#include <memory>
#include <vector>
#include <variant>
#include <string>
#include <chrono>
// Базовое событие
struct DomainEvent {
std::string aggregateId;
std::chrono::system_clock::time_point timestamp;
};
// Конкретные события
struct OrderCreated : DomainEvent {
std::string customerId;
};
struct ItemAdded : DomainEvent {
std::string productId;
int quantity;
};
struct PaymentReceived : DomainEvent {
double amount;
};
// Агрегат — корень для нашего заказа
class OrderAggregate {
private:
std::string id_;
std::string customerId_;
std::vector<std::string> items_;
double totalAmount_ = 0.0;
bool isPaid_ = false;
// Применение событий для восстановления состояния
void apply(const OrderCreated& event) {
id_ = event.aggregateId;
customerId_ = event.customerId;
}
void apply(const ItemAdded& event) {
items_.push_back(event.productId);
totalAmount_ += event.quantity * 10.0; // Упрощённо
}
void apply(const PaymentReceived& event) {
isPaid_ = true;
totalAmount_ -= event.amount;
}
public:
// Восстанавливаем агрегат из событий
static OrderAggregate loadFromHistory(
const std::vector<DomainEvent>& events) {
OrderAggregate agg;
for (const auto& event : events) {
std::visit([&agg](const auto& e) { agg.apply(e); }, event);
}
return agg;
}
// Метод, который генерирует событие
std::vector<DomainEvent> addItem(const std::string& productId, int qty) {
std::vector<DomainEvent> newEvents;
if (isPaid_) {
throw std::runtime_error("Cannot modify paid order");
}
ItemAdded event;
event.aggregateId = id_;
event.timestamp = std::chrono::system_clock::now();
event.productId = productId;
event.quantity = qty;
newEvents.push_back(event);
return newEvents;
}
const std::string& getId() const { return id_; }
};
Обратите внимание: addItem