Привет, я Сережа из Брянска, и в моем городе и до пандемии почти не было конференций и митапов. Поэтому я привык смотреть записи докладов из разных сообществ. Но как задать их авторам вопрос? Так что я завел подкаст и зову в него интересных докладчиков на поговорить.  Недавно я посмотрел доклад «Как перестать бояться CQRS». Вроде бы простая идея, но есть нюансы. Так и появился этот выпуск. Вы можете включить аудиоверсию — в ней больше технических деталей. Сергей Жук, Skyeng: А может быть CQRS с одной моделью в коде? Или обязательно две? Дмитрий Симушев, Райффайзенбанк: CQRS — это всё-таки про две модели. У тебя может быть одно хранилище данных, одна физическая база. Но в коде это будут именно два кусочка — один на запись, другой на чтение.  Сергей Жук, Skyeng: А какую проблему мы вообще решаем этим? Зачем вообще все усложнять и разделять?  Дмитрий Симушев, Райффайзенбанк: Дело в возрастающей сложности приложений. Когда мы используем одну модель для чтения и записи, вся бизнес-логика кочует в клиентский код. Надо как-то от этого уйти, и мы собираем логику в сущности. А еще мы должны держать много избыточных данных, чтобы строить графические интерфейсы — возникает необходимость очень сложного чтения.   Часто, когда ты строишь графический интерфейс, нужно собирать данные из большого количества сущностей. Скажем, тебе нужен заказ, нужен пользователь, количество средств у него на счету, количество новых сообщений у этого пользователя, что-то ещё. Набор данных, который нужно отобразить в интерфейсе, сильно избыточен для команд. Команды меняют состояние системы — это формализованный скрипт того, как бизнес видит взаимодействия сущностей. Запросы, наоборот, ничего не меняют, они только возвращают данные и помогают абстрагировать логику отображения и выборки данных. Так у тебя органически появляются две модели: одна для чтения, другая для записи. Второй момент, когда CQRS сильно помогает — при разноплановой нагрузке. Например, на запись у тебя маленький поток запросов, а на чтение — очень большой, и ты хочешь независимо вот эту историю масштабировать. Скажем, берёшь и делаешь проекцию в каком-нибудь документоориентированном хранилище, где модель уже специально оптимизирована для чтения. Сергей Жук, Skyeng: А какую роль во всём этом играет ORM? Если мы для чтения можем вообще кастомные штуки делать, получается, мы можем ее выкинуть? Или стоит оставить ее для записи? Дмитрий Симушев, Райффайзенбанк: Если мы говорим о модели записи, ORM все еще нужна, когда у тебя богатая доменная модель. Если всё построено на объектах и эти объекты нужно каким-то образом на реляционную базу отобразить, ORM помогает не думать о твоём коде в терминах БД. Ты работаешь с сущностями, а ORM за тебя всё отображает на базу.  Когда мы переходим к модели чтения, для отображения нужны не сущности, а плоские объекты. PDO может отлично брать SQL-запросы и результаты засовывать в DTO-шки. Это получится сильно быстрее. Получается, мы можем оптимизировать модель чтения, просто выкинув всё ненужное. И тут ORM — как раз та штука, от которой можно отказаться. С ней мы упремся в производительность. Но этот путь стоит проходить эволюционно: если тебе хватает на чтение средств ORM, если тебя в принципе все устраивает — почему бы и нет?  Сергей Жук, Skyeng: Так может, тогда и одну модель оставить? Мы возвращаемся к исходному вопросу. СQRS — это точно про две модели? То есть, видишь, можно оставить одну ORM, а снаружи — поведение, как всё у нас разделено. Мы разделим записи и чтение. Записи будут быстрые. Можно даже асинхронно сделать. Снаружи-то не видно... Дмитрий Симушев, Райффайзенбанк: Ты знаешь, это тоже работает, но это не CQRS.  Сергей Жук, Skyeng: А почему? Дмитрий Симушев, Райффайзенбанк: Принцип Command-query separation (CQS) появился еще раньше: один эндпоинт занимается чтением, другой — записью, и они между не взаимодействуют. CQRS — следующий шаг на этом пути: за чтение и запись отвечают разные подсистемы в коде, и ты используешь вообще разные модели. Если тебе достаточно CQS, если тебе хватает производительности — окей. Если нет, ты уже идёшь в CQRS и в разделение этих моделей. Сергей Жук, Skyeng: Давай тогда поговорим про архитектуру CQRS. Я верно понимаю, что чтение синхронное, а записи — асинхронные? Или необязательно? Например, в рамках http-запроса мы получим данные, поставим команду на выполнение и сразу отдадим ответ: например, там 202? Сама же команда попадает в какую-то очередь, шину, и выполнится за пределами request-response?  Дмитрий Симушев, Райффайзенбанк: Необязательно. У тебя же обычно есть слой контроллеров. Скажем, у тебя может быть контроллер, который отправляет сначала команду, дожидается, пока она будет выполнена, делает запрос и возвращает что-то наружу. Само разделение идёт на более низких слоях.  CQRS не должен просачиваться полностью на все слои твоей архитектуры. Есть слой доменной части, есть слой приложения, есть слой контроллеров. Можно контроллером сначала вызывать команду, потом делать запрос и возвращать что-то по REST. Сергей Жук, Skyeng: Но в ООП есть принцип, что метод у объекта должен либо менять состояние, но ничего не возвращать, либо должен что-то возвращать — но не менять состояние. Я воспринимал CQRS в этом ключе: грубо говоря, эндпоинт либо возвращает какие-то данные, не меняя состояние стораджа, либо он меняет состояние, но при этом всегда void. Дмитрий Симушев, Райффайзенбанк: Знаешь, тут большой вопрос: а нужно ли CQRS проецировать на уровень эндпоинтов?  Ты говоришь про метод, но это метод кода, более низких слоев. Эндпоинты не должны этой истории подчиняться. Точнее, так: необязательно это делать прямо до конца, до эндпоинта. Ты можешь провести границу иначе: сказать, что в ядре, например, CQRS, а дальше я могут быть варианты — работать с REST, GraphQL, навигацию делать, что-то еще.  Сергей Жук, Skyeng: Окей, а как понять, что мой кейс созрел для CQRS? Дмитрий Симушев, Райффайзенбанк: Первое условие: хорошо выраженная предметная область. У тебя должен быть зрелый бизнес-домен. Второй момент: должны быть либо проблемы с производительностью, либо неравномерная нагрузка. Сергей Жук, Skyeng: Расскажи, как ты пришёл к CQRS? Наверняка же сначала возникли какие-то проблемы?  Дмитрий Симушев, Райффайзенбанк: Вот как получилось. Мы начали делать приложение, продумали сущности, стали их реализовывать. И вроде всё хорошо, но потом приходит бизнес: «Ребят, вот здесь нам нужно вывести еще немножко информации». Встает выбор: делать сущности еще одну связь, чтобы при чтении взять её целиком, серилизовать и отдать на клиент. Либо придумывать что-то ещё.  Добавление связей работает только до какого-то определенного количества этих связей. А когда модель распухает, ORM на чтении начинает очень здорово тормозить. И тебе в любом случае приходится как-то это дело оптимизировать. В момент, когда мы поняли, что не можем ничего сделать с производительностью и уперлись на чтение в ресурсах, мы посмотрели по сторонам и подумали: «Окей, а почему бы нам не делать обычные скрипт-запросы в базу и отдавать данные на клиента?»  Поняли, что эта история очень здорово вписывается в CQRS и разбили приложение на два кусочка: один — модели, плюс-минус чистые, без чтения; и второй — модель чтения, которая просто подает данные. Сергей Жук, Skyeng: Как вы поняли, что уперлись?  Дмитрий Симушев, Райфайзенбанк: Стали тормозили эндпоинты. Смотришь, у тебя ручка отрабатывает за секунды. Залезаешь внутрь, смотришь - вроде всё гладко. А потом понимаешь, что большую часть времени работает Doctrine. Начинаешь оптимизировать Doctrine: делаешь всякие радостные хаки с ленивой загрузкой, включением-выключением, кэшем второго уровня. И в какой-то момент понимаешь, что выжал из нее все. Но тебе не хватает. Тогда ты пробуешь переписать запрос на чистый SQL и получаешь прирост х10 просто за счет того, что ты выкинул ORM и в чем-то упростил свой код.  Сергей Жук, Skyeng: Проблема была даже не в медленным запросе, а в том что PHP-код этот медленно обрабатывал? Дмитрий Симушев, Райфайзенбанк: И то, и то. В случае с PHP-кодом, самая большая нагрузка в гидрации у Doctrine: все, что ты притащил, она сначала разворачивает в сущности, а ты сущность все равно серилизуешь в JSON, чтобы отдать на фронт. Но есть и SQL-ная часть — нам нужно было, скажем, половину полей сущности, а из-за специфики ORM выбирали ее всю.  Пока мы остановились на разделении моделей. Нам хватило. PostgreSQL держит нагрузку на этом уровне. Но в целом, никто не мешает пойти дальше и выделить, скажем, отдельный сторадж.  Сергей Жук, Skyeng: Тяжело было затаскивать в уже написанный проект CQRS, разделять модели?  Дмитрий Симушев, Райффайзенбанк: Ты знаешь, очень гладко все проходит. У тебя есть сущность, есть бизнес-модель. Ты понимаешь, что у тебя на чтении система не справляется, и просто сбоку делаешь сервис, который ходит в базу данных. В контроллере используешь его и всё — запрос ты инкапсулировал, постепенно можешь часть нагрузки на чтение снимать с бизнес-модели. И вот так, кусочками, вплоть до единичного запроса и эндпоинтов.  Мы перевели только то, что тормозило: нужно было оптимизировать запросы — мы их оптимизировали. Как только стало нормально — тут же остановились. Часть моделей у нас работает и на чтение, и на запись.  Сергей Жук, Skyeng: У вас были проблемы? Что было тяжело, может быть, пошло не так? Дмитрий Симушев, Райффайзенбанк: Наверное, самое сложное — это решиться. Потому что есть ORM, есть логика, которая собрана в одном месте, есть маппинги, которые в одном месте описаны. И ты вроде как понимаешь, что если ты сейчас начнешь где-то сбоку делать что-то ещё — то рано или поздно это стрельнет в ногу. Самое сложное — принять, что ты уперся и делать надо.  Из сложностей в процессе: любое серьезное изменение начинает появляться в двух местах — модели чтения и модели записи. Если пишешь плоские запросы, надо и в маппингах сущности менять и не забывать прочесывать запросы, чтобы они не падали, когда ты делаешь изменения.  Сергей Жук, Skyeng: А к тестированию как-то подход меняется при этом? Дмитрий Симушев, Райффайзенбанк: Ты немножко меняешь вектор. Когда у тебя одна модель, ты можешь взять юнит и протестировать сущность. У тебя есть геттеры, у тебя есть сеттеры, ты что-то сделал, посмотрел. Когда переезжаешь на историю с двумя моделями и синхронизацию в базе — фокус смещается на интеграционные, end-to-end тесты. Тебе приходится чуть больше тестировать на следующих уровнях пирамиды тестирования, менять юниты на что-то еще.  Сергей Жук, Skyeng: Часто CQRS рассматривается как некий шаг на пути к Event Sourcing. Как тебе удалось спокойно остановиться?)  Дмитрий Симушев, Райффайзенбанк: Когда мы переходим к CQRS, то начинаем сначала с одного стораджа. Если не хватает производительности — делаем слейвы на чтение. А вот если дальше всё равно не хватает — можно задействовать Event Sourcing.  Есть бизнес-домены, где Event Sourcing сам по себе очень полезен. Не как технология, не как следствие CQRS, а сам по себе. В целом, это любая область, где изменение приводит к каким-то последствиям. Классический пример — финансовые истории с бизнес-транзакциями, когда мы рассматриваем, например, какие-то клиентские счета. Тут не так интересна цифра на счете, как история того, что происходило с этим счётом. Ты смотришь в первую очередь на сам процесс, нежели на то, где сейчас находишься. Предметная область моей команды — это справочные данные, связанные с городами, отделениями и прочим таким. По производительности мы еще не дошли до этапа, когда нам надо выделять разнородные хранилища. А поэтому и Event Sourcing не принесет особого профита. Сергей Жук, Skyeng: А если я знаю, что будет нагрузка и что домен подходит под Event Sourcing, но мне надо запилить MVP? Дмитрий Симушев, Райффайзенбанк: Ты можешь начать с самого простого: ORM, одной базы данных, одной модели на запись и на чтение, сериализатора. Потому что никто не мешает тебе в какой-то момент остановиться и выделить отдельные запросы на чтение. Как только ты понимаешь, что надо ускоряться — ты постепенно начинаешь выделять модель чтения. Модель записи можно вообще пока не трогать. Когда выделишь модель чтения, тогда начинай резать и модель записи. В CQRS очень здорово то, что ко всему можно приходить эволюционно. Полезное по теме: Доклад Аделя Файзрахманова про плюсы отделения кода на чтение от кода на запись и про случаи, когда CQRS не нужен. Другие выпуски подкаста «Между скобок».