Отделяем мух от котлет команды от запросов.
Когда в приложении становится слишком много операций чтения, система начинает тормозить. Особенно если при этом количество изменений в данных остаётся небольшим. В таких ситуациях классическая архитектура CRUD даёт сбой: одно и то же хранилище обрабатывает и запросы, и команды, что приводит к конкуренции за ресурсы и снижению производительности.
Решить эту проблему помогает CQRS — архитектурный паттерн, который разделяет обработку команд и запросов. Такой подход особенно полезен в микросервисной архитектуре, где нагрузка может быть неравномерной.
В этой статье разберём, как работает CQRS, какие задачи он решает и чем может быть полезен в реальных проектах.
Что такое CQRS
Представьте себе блог-платформу. Ежедневно десятки авторов публикуют новые статьи, а миллионы пользователей их читают. Количество операций чтения здесь в десятки раз больше, чем операций изменения. В таких случаях CQRS позволяет оптимизировать работу приложения: отдельно проектируется модель для чтения, отдельно — для записи.
Суть паттерна — в разделении двух типов операций:
- Команды (commands) — изменяют состояние системы. Например, создать статью, обновить профиль или удалить комментарий. Команды обычно не возвращают данные, а лишь инициируют изменение.
- Запросы (queries) — читают данные без их изменения. Их задача — как можно быстрее вернуть нужную информацию. Часто для этого используют специализированные модели, базы данных или кэш.
Запросы и команды работают независимо друг от друга, что делает архитектуру гибкой и более предсказуемой при нагрузках. Всегда можно понять, из-за чего именно тормозит система, и оптимизировать именно эту часть приложения.
Из чего состоит CQRS
Представьте, что перед вами бэкенд онлайн-магазина, разработанный на базе паттерна CQRS. В системе есть команды, которые вносят изменения в базу данных (создают и редактируют заказы), и запросы (выводят информацию о заказах). Помимо этого, у нашего магазина есть пользовательский интерфейс, с помощью которого с ним взаимодействуют покупатели, и отдельные базы данных для команд и запросов. Для связи между базами данных предусмотрен модуль синхронизации, который обновляет данные.
Схема паттерна CQRS
Инфографика: Polina Vari для Skillbox Media
Команды и запросы не могут существовать сами по себе. Их объединяют в модули — файлы, в которых содержатся все необходимые функции, классы и переменные. Рассмотрим, какие модули чаще всего встречаются в проектах на основе паттерна CQRS.
Модуль команд
Если пользователь добавит товары в корзину и оформит заказ, то эти операции обработает модуль команд:
- Обработчик примет имя пользователя, наименование товара, количество и внесёт информацию о заказе в базу данных.
- Валидатор проверит команды на корректность входных данных и убедится, что пользователь авторизован. Например, если покупатель закажет товар, которого нет на складе, то система выдаст ошибку.
Модуль запросов
Пользователи онлайн-магазинов не только оформляют заказы, но и запрашивают информацию. Например, чтобы узнать статус доставки или скачать электронный чек. Эти операции не вносят изменений в базу данных, поэтому их выполняет отдельный модуль запросов:
- Обработчик выдаёт данные в ответ на запрос (например, о скачивании чека заказа). Он может обращаться к отдельной базе данных, где хранится оптимизированная для чтения информация.
- Оптимизатор ускоряет выполнение запросов. Например, для ускорения можно использовать специальные базы данных с быстрым доступом, индексирование и кэширование.
Дополнительные компоненты
Для более удобной работы вместе с паттерном CQRS часто используют следующие элементы:
- Механизмы синхронизации — системы, которые согласуют данные между разделёнными системами чтения и записи информации. В нашем примере есть две базы: в одну система вносит изменения, а из другой получает данные. Модуль синхронизации обновляет информацию между базами, чтобы пользователи получали актуальные данные без задержек.
- Агрегаты — группы объектов, объединённые общей бизнес-логикой и целостностью данных. Например, агрегатом может быть заказ, включающий в себя информацию о покупателе, товаре и платеже. В таком случае все изменения в заказе будут обрабатываться как единое целое.
- Сервисы домена — сервисы, отвечающие за выполнение специфичных для предметной области операций, которые не укладываются в рамки одного агрегата. Так, сервис для расчёта скидок может объединять данные из нескольких заказов или даже внешних источников, чтобы определить окончательную стоимость покупки, когда логика распределена между разными частями системы.
CQRS на примере: пишем бэкенд интернет-магазина
Чтобы лучше понять, как работает паттерн CQRS, разработаем бэкенд для онлайн-магазина на Python. В коде разделим модули запросов и обработки заказов. Это позволит нам редактировать модули независимо друг от друга.
Если вы пишете код на другом языке программирования, то вам будет относительно просто разобраться в нашем проекте. Если вы только начинаете изучать разработку, то мы рекомендуем обратить внимание на наше руководство по Python.
Структура проекта
В примере с онлайн-магазином можно применить две стратегии: написать весь бэкенд в одном файле или разделить проект на модули. Предположим, что пользователи чаще будут проверять статусы заказов, чем оформлять покупки. При этом перед праздниками будут возникать аномалии — будет увеличиваться как количество заказов, так и количество запросов к базе данных.
Чтобы система справлялась с такими нагрузками и мы могли масштабировать её при необходимости, применим паттерн CQRS — то есть разделим ответственность за команды (изменения данных) и запросы (получение данных).
Это ключевая идея CQRS: команды и запросы должны быть реализованы независимо друг от друга. На уровне структуры проекта это выглядит так:
- models.py — имитация хранилища данных (вместо базы данных), описывает структуру заказов.
- commands.py — функции для создания и обновления заказов.
- queries.py — функции для получения информации о заказе.
- app.py — основной файл, объединяющий обе части.
Хранение данных
Чтобы не тратить время на настройку базы данных, мы будем хранить данные прямо в Python — в памяти. Такой подход подходит только для демонстрации. Если вы захотите использовать CQRS в реальном проекте, обязательно подключите полноценную СУБД.
Начнём с файла models.py. В нём опишем класс Order со следующими полями:
- order_id — уникальный идентификатор заказа.
- customer — имя покупателя.
- product — название или объект товара.
- quantity — количество товара.
Класс Order — конструктор, с помощью которого мы сможем быстро создавать новые заказы в системе. Полностью код класса выглядит так:
class Order: def __init__(self, order_id, customer, product, quantity): self.order_id = order_id self.customer = customer self.product = product self.quantity = quantity
Класс Order — это не просто шаблон для создания заказов. Чтобы им было удобно управлять, добавим в него вспомогательные функции:
- update() — обновляет поля заказа.
- to_dict() — преобразует объект заказа в словарь, чтобы, например, передать его во внешний сервис или сериализовать в JSON.
Вот как эти функции выглядят:
def update(self, customer=None, product=None, quantity=None): if customer: self.customer = customer if product: self.product = product if quantity: self.quantity = quantity def to_dict(self): return { “order_id”: self.order_id, “customer”: self.customer, “product”: self.product, “quantity”: self.quantity }
Также нам нужно хранилище для всех заказов. Вместо полноценной базы данных мы создадим словарь order_store, где ключом будет order_id, а значением — сам объект Order.
order_store = {}
Полностью код файла models.py выглядит так:
# Определяем модель заказа и глобальное хранилище (имитация базы данных). class Order: def __init__(self, order_id, customer, product, quantity): self.order_id = order_id self.customer = customer self.product = product self.quantity = quantity def update(self, customer=None, product=None, quantity=None): if customer: self.customer = customer if product: self.product = product if quantity: self.quantity = quantity def to_dict(self): return { “order_id”: self.order_id, “customer”: self.customer, “product”: self.product, “quantity”: self.quantity } # Глобальное хранилище заказов order_store = {}
Теперь мы можем создать заказ и сохранить его. Создадим и запишем в переменную order_store заказ пользователя Ивана, который купил два ноутбука:
new_order = Order(1, “Иван”, “Ноутбук”, 2) order_store[new_order.order_id] = new_order
Обработка команд
Наш онлайн-магазин уже умеет сохранять в памяти историю заказов пользователей. Теперь в файле commands.py создадим функции для управления заказами.
Для простоты мы реализуем две команды:
- create_order — создаёт новый заказ.
- update_order — редактирует уже существующий.
Функция create_order принимает данные в формате, который мы описали в файле models.py с помощью класса Order, и проверяет поле order_id. Если заказ с указанным номером уже есть в базе данных, то функция вернёт ошибку. В остальных случаях create_order создаст заказ и сохранит его в переменную order_store.
def create_order(order_id, customer, product, quantity): if order_id in order_store: raise Exception(“Заказ с таким идентификатором уже существует.”) order = Order(order_id, customer, product, quantity) order_store[order_id] = order # Генерируем событие создания заказа (можно интегрировать с event store) return {“event”: “OrderCreated”, “data”: order.to_dict()}
Функция update_order также принимает данные в формате класса-конструктора Order и ищет номер заказа. Если заказа не существует, то функция изменит поля, если нет — выдаст ошибку «Заказ не найден.».
def update_order(order_id, customer=None, product=None, quantity=None): if order_id not in order_store: raise Exception(“Заказ не найден.”) order = order_store[order_id] order.update(customer, product, quantity) # Генерируем событие обновления заказа return {“event”: “OrderUpdated”, “data”: order.to_dict()}
Обе функции возвращают событие в формате {“event»: «OrderUpdated», «data»: order.to_dict()}. Благодаря этому мы можем передавать данные о заказах в сервисы аналитики, генерировать отчёты, выписывать чеки или автоматически отправлять оповещения на электронную почту.
Например, если пользователь Иван закажет два ноутбука, то функция вернёт событие с такими данными:
{‘event’: ‘OrderCreated’, ‘data’: {‘order_id’: 1, ‘customer’: ‘Иван’, ‘product’: ‘Ноутбук’, ‘quantity’: 2}}
В нём:
- ‘event’: ‘OrderCreated’ — статус события.
- ‘order_id’: 1 — уникальный номер заказа.
- ‘customer’: ‘Иван’ — имя покупателя.
- ‘product’: ‘Ноутбук’ — наименование товара.
- ‘quantity’: 2 — количество.
Полностью код файла commands.py выглядит так:
from models import Order, order_store def create_order(order_id, customer, product, quantity): if order_id in order_store: raise Exception(“Заказ с таким идентификатором уже существует.”) order = Order(order_id, customer, product, quantity) order_store[order_id] = order # Генерируем событие создания заказа (можно интегрировать с event store) return {“event”: “OrderCreated”, “data”: order.to_dict()} def update_order(order_id, customer=None, product=None, quantity=None): if order_id not in order_store: raise Exception(“Заказ не найден.”) order = order_store[order_id] order.update(customer, product, quantity) # Генерируем событие обновления заказа return {“event”: “OrderUpdated”, “data”: order.to_dict()}
Обработка запросов
Важная часть любого онлайн-магазина — возможность проверить статус заказа. Без неё пользователи не смогут отслеживать доставку, а служба поддержки не сможет узнать, что покупатель заказал, чтобы оформить возврат.
Проверка статуса не изменяет данные, а лишь запрашивает их. В паттерне CQRS такие операции выносят в отдельный модуль. Мы сделаем то же самое — создадим файл queries.py и реализуем в нём функцию get_order.
Функция get_order принимает номер заказа, ищет его в системе и возвращает все нужные данные:
def get_order(order_id): if order_id not in order_store: raise Exception(“Заказ не найден.”) order = order_store[order_id] return order.to_dict()
Полностью код файла queries.py выглядит так:
from models import order_store def get_order(order_id): if order_id not in order_store: raise Exception(“Заказ не найден.”) order = order_store[order_id] return order.to_dict()
Всё вместе
Теперь у нас есть все функции бэкенда небольшого онлайн-магазина. Создадим файл app.py и применим в нём все функции, которые описали выше: создадим заказ, отредактируем его и запросим информацию.
from commands import create_order, update_order from queries import get_order if __name__ == ‘__main__’: print(“Создание заказа:”) event = create_order(1, “Иван”, “Ноутбук”, 2) print(event) print(“Обновление заказа:”) event = update_order(1, quantity=3) print(event) print(“Получение заказа:”) order_info = get_order(1) print(order_info)
Если запустить код, Python выведет в терминал следующее:
Создание заказа: {‘event’: ‘OrderCreated’, ‘data’: {‘order_id’: 1, ‘customer’: ‘Иван’, ‘product’: ‘Ноутбук’, ‘quantity’: 2}} Обновление заказа: {‘event’: ‘OrderUpdated’, ‘data’: {‘order_id’: 1, ‘customer’: ‘Иван’, ‘product’: ‘Ноутбук’, ‘quantity’: 3}} Получение заказа: {‘order_id’: 1, ‘customer’: ‘Иван’, ‘product’: ‘Ноутбук’, ‘quantity’: 3}
Почему мы разделили команды и запросы?
В нашей архитектуре команды (create_order, update_order) и запросы (get_order) находятся в разных модулях. Это делает систему гибкой: их можно дорабатывать независимо.
Например, если по аналитическим отчётам мы заметим, что пользователи стали чаще оформлять заказы и реже проверяют их статус, мы сможем перераспределить ресурсы — и при этом не придётся переписывать всю платформу.
Преимущества и недостатки паттерна
Паттерн CQRS — мощный архитектурный подход, но не универсальное решение. У него есть как очевидные плюсы, так и важные ограничения, которые нужно учитывать при выборе архитектуры.
Преимущества:
- Масштабируемость. Чтение и запись работают независимо, поэтому каждую часть можно масштабировать отдельно. Например, если растёт количество запросов к базе, можно добавить серверы только для чтения или внедрить кэш, — и всё это без изменений в логике записи.
- Оптимизация под задачи. CQRS позволяет использовать разные модели данных для разных целей. Для чтения можно применить упрощённые структуры ради скорости, а для записи — строгую и безопасную модель, которая гарантирует корректные изменения.
- Упрощённое тестирование. Когда логика чтения и изменения данных разделена, модули проще тестировать независимо друг от друга. Например, если в коде проекта переписали только модуль запросов, то можно сэкономить время и не тестировать всю систему.
- Поддержка сложных бизнес-процессов. CQRS хорошо сочетается с паттерном event sourcing — когда все изменения сохраняются в виде событий. Это особенно полезно, если важно отслеживать историю изменений, вести аудит или восстанавливать состояние системы.
Недостатки:
- Усложнение архитектуры. Внедрение CQRS сильно усложняет систему, особенно в небольших проектах. Например, код проекта становится запутанным и появляется избыточная абстракция.
- Проблемы синхронизации данных. Если чтение и запись разделены, то все изменения при записи нужно синхронизировать с данными для чтения. Дополнительные механизмы синхронизации приводят к задержкам в обновлении данных.
- Высокие затраты на внедрение. Для каждого модуля нужна отдельная инфраструктура и интеграция с другими компонентами системы. Это дольше и дороже.
- Повышенная сложность отладки и мониторинга. Изменение состояния системы проходит через множество компонентов и слоёв. Отслеживать изменения в разделённой системе сложнее.
Что в итоге
CQRS — это архитектурный паттерн, который разделяет команды (изменение данных) и запросы (чтение данных) в системе. Это даёт возможность не только раздельно обрабатывать данные, но и использовать разные модели данных для каждой задачи.
Такое разделение помогает масштабировать чтение и запись независимо друг от друга, оптимизировать каждую часть для своих целей, проще тестировать и сопровождать системы со сложной бизнес-логикой.
Однако у паттерна есть и недостатки: он усложняет архитектуру, требует дополнительных механизмов синхронизации, и его использование не оправдано в маленьких проектах с простой логикой.
Используйте CQRS, когда система действительно нуждается в масштабировании, гибкости или отслеживании истории изменений. В остальных случаях можно обойтись более простой архитектурой.