CQRS буквально розшифровується як Command Query Responsibility Segregation, що українською означає "розділення відповідальності за команди і запити". Але давайте не будемо кидатися складними термінами і почнемо з суті.
У традиційних додатках ми часто використовуємо одну й ту ж модель даних і для читання, і для запису. Наприклад, уявіть сутність Order у системі онлайн-магазину. Ми використовуємо один і той же об'єкт, щоб:
- Створити нове замовлення (запис).
- Отримати інформацію про замовлення (читання).
Усе виглядає чудово... доки обсяг даних і трафіку не виростає до небес. Тоді з'являються проблеми:
- Різні вимоги до читання та запису. Наприклад, під час читання користувачу може знадобитися інформація з кількох пов'язаних таблиць, а при записі — лише певні поля.
- Складність масштабування. Запити на читання найчастіше складають 80–90% навантаження. Якщо ви використовуєте одну модель, то й читання, й запис будуть "штовхатися ліктями" за ресурси.
- Еволюція моделі. Якщо ви змінюєте модель для запису, це може призвести до необхідності переробити всю логіку читання, і навпаки.
Основна ідея CQRS
CQRS пропонує нам розділити моделі даних і шляхи обробки для читання (Query) і запису (Command):
- Command (команди) — використовуються для зміни стану системи: оновлення, створення, видалення даних.
- Query (запити) — використовуються для отримання даних, причому вони можуть бути оптимізовані саме під операції читання.
Приклад (аналогія): уявіть кухню ресторану. Офіціанти приймають замовлення (команди), а шеф-кухар готує страви. Але щоб показати замовлення клієнту, офіціант бере готову страву (запит). Команди і запити працюють в різних площинах!
Основні принципи CQRS
Перш ніж заглиблюватися в реальну реалізацію, давайте розберемо ключові принципи CQRS.
1. Розділення моделей даних
У CQRS ви використовуєте різні моделі для операцій читання і запису. Модель команд може бути простою і містити лише ті дані, які потрібні для зміни стану. Наприклад, при створенні замовлення може знадобитися лише ідентифікатор користувача і список товарів.
Модель запитів, навпаки, може бути складною, включати агрегацію даних з кількох джерел і бути оптимізованою для швидкого читання. Іноді вона навіть зберігає дані в іншій базі!
2. Механізм обробки команд
Команди в CQRS — це не просто "зберегти об'єкт у базу". Це окремі об'єкти, які:
- Описують дію:
CreateOrderCommand,CancelOrderCommand. - Містять лише ту інформацію, яка потрібна для виконання конкретної операції.
- Реалізуються через обробники команд (command handlers).
3. Механізм обробки запитів
Запити в CQRS використовуються виключно для читання даних. Вони:
- Можуть агрегувати дані з кількох джерел (наприклад, SQL-бази і кешу).
- В деяких випадках мають власне сховище, наприклад, денормалізовану базу даних.
Причини використання CQRS
Може здатися, що CQRS — зайва складність, особливо якщо у вас невеликий проект. Але у певних ситуаціях цей патерн просто необхідний:
1. Високе навантаження на читання
Якщо у вашій системі величезна кількість запитів на читання, а дані потрібно збирати з кількох джерел, CQRS допоможе розділити навантаження.
2. Різні вимоги до читання і запису
Коли читання і запис мають різні бізнес-правила. Наприклад, при записі потрібно перевірити права користувача і виконати складну валідацію, тоді як читання вимагає лише швидкого доступу до даних.
3. Масштабованість
Ви можете окремо масштабувати компоненти для обробки запитів на читання і запис. Наприклад:
- Для читання можна додати більше реплік бази даних.
- Для запису — налаштувати мастер-базу.
4. Еволюція моделі
Якщо розділити моделі для читання і запису, ви зменшите залежність одного типу операцій від іншого. Це робить вашу систему гнучкішою.
Приклад
Уявімо, що у нас є система онлайн-магазину. Клієнти можуть створювати замовлення через REST API. Нам потрібно:
- Приймати нові замовлення (операція запису).
- Повернути користувачу список його замовлень з інформацією про доставку (операція читання).
Реалізація CQRS
Модель команд (Command)
На кроці запису ми хочемо зберегти лише потрібні дані. Ось приклад класу команди:
// Команда для створення нового замовлення
public class CreateOrderCommand {
private final UUID userId;
private final List<OrderItem> items;
// Конструктор
public CreateOrderCommand(UUID userId, List<OrderItem> items) {
this.userId = userId;
this.items = items;
}
// Гетери
public UUID getUserId() {
return userId;
}
public List<OrderItem> getItems() {
return items;
}
}
// Допоміжний клас для опису замовлених товарів
public class OrderItem {
private final String productId;
private final int quantity;
public OrderItem(String productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
// Гетери
public String getProductId() {
return productId;
}
public int getQuantity() {
return quantity;
}
}
Обробник команд
Тепер створимо обробник, який буде обробляти команду CreateOrderCommand:
@Component
public class CreateOrderCommandHandler {
private final OrderRepository orderRepository;
// Впровадження залежностей через конструктор
public CreateOrderCommandHandler(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void handle(CreateOrderCommand command) {
// Перетворюємо команду в сутність Order
Order order = new Order(
UUID.randomUUID(), // Генеруємо унікальний ідентифікатор замовлення
command.getUserId(),
command.getItems(),
LocalDateTime.now()
);
// Зберігаємо замовлення в базі даних
orderRepository.save(order);
}
}
Модель запиту (Query)
Тепер створимо модель для читання даних. Вона міститиме всю необхідну інформацію для клієнта:
// Модель для читання даних про замовлення
public class OrderQueryModel {
private final UUID orderId;
private final String status;
private final LocalDateTime createdDate;
// Конструктор
public OrderQueryModel(UUID orderId, String status, LocalDateTime createdDate) {
this.orderId = orderId;
this.status = status;
this.createdDate = createdDate;
}
// Гетери
public UUID getOrderId() {
return orderId;
}
public String getStatus() {
return status;
}
public LocalDateTime getCreatedDate() {
return createdDate;
}
}
Обробник запитів
Тепер ми реалізуємо обробник для отримання інформації про замовлення через оптимізований запит:
@Component
public class OrderQueryHandler {
private final OrderViewRepository orderViewRepository;
// Впровадження залежностей через конструктор
public OrderQueryHandler(OrderViewRepository orderViewRepository) {
this.orderViewRepository = orderViewRepository;
}
public List<OrderQueryModel> handle(UUID userId) {
// Отримуємо список замовлень з денормалізованої бази даних
return orderViewRepository.findByUserId(userId)
.stream()
.map(order -> new OrderQueryModel(order.getId(), order.getStatus(), order.getCreatedDate()))
.collect(Collectors.toList());
}
}
Переваги розділення на команди і запити
- Простота тестування. Можна тестувати команди і запити окремо.
- Оптимізація. Кожна частина оптимізується під свої задачі (запис даних або їх читання).
- Підготовка до Event Sourcing. CQRS чудово "погоджується" з Event Sourcing, про що ми поговоримо в наступних лекціях.
Чим закінчиться ваша CQRS-авантюра?
CQRS — потужний патерн, який чудово доповнює мікросервісні системи. Однак застосовувати його треба з розумом! Не кожне завдання вимагає роздільних моделей даних. Пам'ятайте золоте правило: "Не додавайте складності, поки це не потрібно". У наступних лекціях ми заглибимося в зв'язок CQRS з Event Sourcing і дізнаємося, як це може вистрілити у найнеочікуваніших місцях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ