Когда говорят про Command and Query Responsibility Segregation (CQRS) и прикручивают к нему шину команд, обязательно где-то рядом есть и шина запросов. Кто-то где-то увидел такой подход и, не разбираясь, решил всем рассказать под соусом единственного верного решения.
Что такое CQS?
Принцип Command Query Separation (CQS) впервые представил Бертран Мейер в 1988 году и, как видно по году, концепция далеко не нова. Этот принцип очень хорошо укладывается в ООП, где объекты управляют своим состоянием.
Принцип гласит, что один конкретный метод объекта должен быть или командой (операцией записи), или запросом (операцией чтения), но одновременно и тем и другим быть не может.
Другими словами, чтобы быть командой, метод должен изменять состояние объекта, но при этом ничего не возвращать. А чтобы быть запросом, метод должен что-то возвращать, но при этом не вносить никаких изменений в состояние объекта.
Это позволяет писать более надежный и предсказуемый код и, если рассматривать с точки зрения SRP при очень крайней детализации, то каждый метод имеет лишь одну причину для изменения, а не две разных.
А что такое CQRS?
В 2010 году, спустя 28 лет с момента публикации CQS, Грег Янг описывает новую концепцию CQRS в контексте DDD, которая, по сути, представляет из себя дальнейшую эволюцию CQS. При этом следует четко понимать, что это разные концепции, называть одно вторым и второе первым некорректно.
В отличие от разделения по CQS команд и запросов на отдельные методы, CQRS переносит концепцию отделения запросов от команд в более глобальную архитектурную плоскость. Теперь следует отделять команды и запросы в отдельные сервис-функции, тем самым предоставляя более удобные отделенные компоненты приложения. Особенно учитывая, что операции чтения могут использовать вообще другое хранилище, чем операции изменения.
Важно понимать одно из распространенных заблуждений, что CQRS всегда подразумевает шины команд и запросов. Нет, CQRS не про шины команд и запросов. Это архитектурный подход, который говорит только о разделении на обособленные архитектурные блоки. Использование шин — это про конкретную имплементацию, которая подбирается, исходя из множества разных факторов, начиная от возможностей фреймворка и платформы, заканчивая личными пожеланиями разработчиков и уровнем их знаний.
Кратко о шине команд
Шина команд, как одна из возможных реализаций отделения команд, очень хорошо подходит для приложений с большим количеством разнообразия операций записи.
Сам контракт шины команд относится к ядру приложения, тогда как ее реализация относится к инфрастуктурным компонентам приложения. Шина команд используется не только в пользовательском интерфейсе (Primary Adapters), но и в прикладных подписчиках на события, где происходит делегация основной бизнес-логики обработчику команды.
При этом ввод шины команд может сильно облегчить инфраструктурную возню с бизнес-валидацией команды, работу с транзакционностью, работу с отправкой событий в outbox-хранилище, очистку кучи (heap) у EntityManager и много всего другого. Шина команд может также помочь с псевдосинхронностью, если обработчики отделены в отдельные потоки или процессы.
Однако, как и любой другой подход, шина команд может только усложнить простейший проект, где всего из перечисленного выше просто нет ни в каком виде.
Шина команд предоставляет результат своей работы в двух видах: или выполнение без ошибок (void), или конкретное исключение. Возвращать какой-либо другой результат — нарушать фундаментальный принцип CQS, наследуемый в CQRS.
Что такое запрос в общем случае
Рассмотрим запросы в целом. Запрос — это некоторая инфраструктурная выборка данных для передачи в виде модели представления клиенту из клиентского интерфейса. При этом, выборка осуществляется из какого-либо источника, будь то контролируемая внепроцессная зависимость в виде СУБД или неподконтрольная внепроцессная зависимость в виде стороннего сервиса.
Мы вправе вообще не обращаться к ядру приложения, доставая данные из хранилища напрямую и оборачивая их в Read Model. Нам никто не запретит это делать, потому что клиентский интерфейс (Primary Adapter) — это не про бизнес, а про инфраструктуру. Мы уже находимся в инфраструктуре.
В свою очередь, ядру приложения не нужно знать о том, что выходит за его рамки. Для ядра приложения все эти выборки сущностей по каким-то критериям не важны, они никак не нужны бизнес-логике. У идеального ядра приложения нет четкого знания о хранилище или знания о внепроцессных зависимостях.
Клиентский интерфейс может изменяться, он может расширяться, в нем могут поменяться протоколы общения, он может подстроиться под требования клиента, в него может внедриться кэширование. Бизнес-процессы это никак не затрагивает, так как это все про инфраструктуру (Secondary Adapters) и про работу с клиентом.
Кроме того, такие выборки, особенно в приложениях, где преобладают операции чтения из СУБД, а не записи, даже выгоднее делать на более низкоуровневых DBAL-компонентах, составляя оптимизированные запросы. Без трат времени на гидрации и логику кэширования используемой ORM-библиотеки.
А еще можно обратиться к оригинальному тексту Грега Янга, где он описывает ровно те же проблемы работы с запросами и вводит такое понятие как «тонкий слой чтения» (Thin Read Layer). Этот слой включает в себя все нужные инфраструктурные выборки и преобразование данных из хранилища в модель представления данных для клиентского интерфейса.
Возможно, вам не нужна шина запросов
Если есть шина команд, то должна быть и шина запросов! Зачем? Какую пользу она приносит? Давайте порассуждаем.
Шина запросов является одной из реализаций отделения запросов, но с ней далеко не так все однозначно. Шина запросов, в отличие от шины команд, обычно представляет из себя инфраструктурный компонент и используется для того, чтобы делегировать некоторую выборку из инфраструктурного слоя с клиентским интерфейсом в тот самый «тонкий слой чтения».
И, если в шине не происходит ничего, кроме выбора из провайдера обработчиков нужного обработчика и передачи в него запроса, шина команд становится инфраструктурным переусложнением.
У каждого запроса всегда ровно один обработчик, их не может быть несколько. И, так как мы уже находимся в инфраструктурном слое, дополнительная абстракция не несет никакой полезной нагрузки, а лишь увеличивает цепочки конфигурации приложения и прохождения запроса от места инициализации до места обработки и обратно.
В динамических языках с отсутствием обобщений (generics), таких, как PHP, тип результата обработчика неизвестен на уровне компилятора, из-за чего мы вынуждены использовать псевдодженерики, которые гарантируют нам верные связи только на уровне статического анализатора.
Так почему бы не использовать тот самый обработчик напрямую?
Конечно же, если по функциональным требованиям ожидается распределенность нагрузки и обработка запросов в разных потоках, шина запросов может пригодиться для того, чтобы инкапсулировать внутри себя работу с этой инфраструктурой. Но давайте будем честны: так ли часто в наших приложениях такое требуется? И является ли наш хайлоад реальным хайлоадом, с которым не справляется балансер перед СУБД?