Почему CQRS и Event Sourcing — это не «оверкилл», а спасение для сложных систем
Вы когда-нибудь сталкивались с ситуацией, когда ваша система, написанная на Rust, идеально справляется с запросами на чтение, но «падает на колени» при записи? Или наоборот — вы жертвуете производительностью чтения ради возможности сохранить полную историю изменений? Знакомо? Тогда добро пожаловать в мир CQRS и Event Sourcing.
Многие разработчики считают эти паттерны «тяжелой артиллерией», применимой только в enterprise-проектах на C# или Java. Но с ростом популярности Rust в высоконагруженных системах (от игровых серверов до финансовых платформ) CQRS и Event Sourcing становятся не просто полезными — они становятся необходимыми. Rust с его нулевой стоимостью абстракций и строгой типизацией — идеальный язык для реализации этих паттернов.
В этой статье мы не просто поговорим о теории. Мы напишем рабочий код на Rust, который вы сможете адаптировать под свои проекты. Вы узнаете, как разделить ответственность между командами и запросами, как хранить события вместо текущего состояния и как всё это ускоряет вашу систему в разы.
Что такое CQRS и Event Sourcing? Простым языком для Rust-разработчика
Представьте, что вы управляете банковским счетом. В традиционной CRUD-модели у вас есть одна таблица «Счета», где хранится текущий баланс. Если нужно посмотреть историю — вы создаете еще одну таблицу «Транзакции». Но что, если вам нужно не просто знать баланс, а понимать, почему он изменился? Или восстанавливать состояние на любую дату?
CQRS: Разделяй и властвуй
CQRS (Command Query Responsibility Segregation) — это принцип, который разделяет операции на две группы:
- Команды (Commands) — изменяют состояние. Они не возвращают данные, только подтверждение или ошибку.
- Запросы (Queries) — только читают данные. Они не меняют состояние.
В Rust это разделение выглядит естественно: типы команд и запросов — это разные структуры, а их обработчики — разные функции или модули. Это не просто «хорошая практика», это способ избежать случайных мутаций при чтении.
// Команда — изменяет состояние
pub struct CreateOrder {
pub user_id: Uuid,
pub items: Vec<OrderItem>,
}
// Запрос — только читает
pub struct GetOrderHistory {
pub user_id: Uuid,
pub limit: usize,
}
Event Sourcing: Храните историю, а не моментальный снимок
Event Sourcing — это подход, при котором мы сохраняем не текущее состояние объекта, а последовательность событий, которые к нему привели. Хотите узнать баланс счета на 1 января 2023 года? Просто «проиграйте» все события с начала до этой даты.
Преимущества очевидны:
- Аудит — каждое изменение зафиксировано.
- Временные срезы — можно восстановить любое состояние.
- Устойчивость к багам — если в коде ошибка, вы не потеряли данные, а просто неправильно их «проиграли».
Практический пример: Система управления заказами на Rust
Давайте построим мини-систему для интернет-магазина. Она будет обрабатывать команды (создание заказа, добавление товара) и запросы (получение корзины, история заказов). Вся логика будет построена на событиях.
Шаг 1: Определяем события и команды
Первое, что нужно сделать — спроектировать домен. В Rust мы используем перечисления (enum) для событий и строгие структуры для команд.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// События — неизменяемые факты
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OrderEvent {
Created { order_id: Uuid, user_id: Uuid, timestamp: i64 },
ItemAdded { order_id: Uuid, item: String, quantity: u32 },
ItemRemoved { order_id: Uuid, item: String },
Submitted { order_id: Uuid, timestamp: i64 },
}
// Команды — намерения
#[derive(Debug)]
pub enum OrderCommand {
CreateOrder { user_id: Uuid },
AddItem { order_id: Uuid, item: String, quantity: u32 },
RemoveItem { order_id: Uuid, item: String },
SubmitOrder { order_id: Uuid },
}
Шаг 2: Event Store — простое хранилище событий
Event Store — это сердце системы. В реальном проекте вы бы использовали PostgreSQL, DynamoDB или специализированное решение. Мы для простоты напишем in-memory хранилище. Главное — понять принцип: мы никогда не обновляем записи, только добавляем новые.
use std::collections::HashMap;
use std::sync::Mutex;
pub struct EventStore {
// Каждый заказ — это вектор событий
events: Mutex<HashMap<Uuid, Vec<OrderEvent>>>,
}
impl EventStore {
pub fn new() -> Self {
Self { events: Mutex::new(HashMap::new()) }
}
// Добавляем событие к существующей последовательности
pub fn append(&self, order_id: Uuid, event: OrderEvent) {
let mut store = self.events.lock().unwrap();
store.entry(order_id).or_insert_with(Vec::new).push(event);
}
// Получаем все события для агрегата
pub fn get_events(&self, order_id: &Uuid) -> Vec<OrderEvent> {
self.events.lock().unwrap()
.get(order_id)
.cloned()
.unwrap_or_default()
}
}
Шаг 3: Обработчик команд — бизнес-логика на событиях
Теперь самое интересное. Обработчик команды не просто изменяет состояние, а генерирует событие. Если событие валидно — оно сохраняется. Если нет — возвращается ошибка.
#[derive(Debug)]
pub enum CommandError {
OrderNotFound,
OrderAlreadySubmitted,
ItemNotInOrder,
}
pub struct CommandHandler {
store: EventStore,
}
impl CommandHandler {
pub fn new(store: EventStore) -> Self {
Self { store }
}
pub fn handle(&self, command: OrderCommand) -> Result<(), CommandError> {
match command {
OrderCommand::CreateOrder { user_id } => {
let order_id = Uuid::new_v4();
let event = OrderEvent::Created {
order_id,
user_id,
timestamp: chrono::Utc::now().timestamp(),
};
self.store.append(order_id, event);
Ok(())
}
OrderCommand::AddItem { order_id, item, quantity } => {
// Проверяем, существует ли заказ и не отправлен ли он
let events = self.store.get_events(&order_id);
if events.is_empty() {
return Err(CommandError::OrderNotFound);
}
// Проверяем, не был ли заказ уже отправлен
if events.iter().any(|e| matches!(e, OrderEvent::Submitted { .. })) {
return Err(CommandError::OrderAlreadySubmitted);
}
let event = OrderEvent::ItemAdded {
order_id,
item,
quantity,
};
self.store.append(order_id, event);
Ok(())
}
// ... аналогично для других команд
_ => unimplemented!()