Русский
Русский
English
Статистика
Реклама

Cqrs

Очень технический выпуск про DDD и проектирование сложных систем

12.02.2021 16:06:23 | Автор: admin

В свежем выпуске подкаста Сушите вёсла обсудили методологии проектирования сложных систем. Много говорили о Domain Driven Design, Event Sourcing и CQRS. Тема непростая, но, как говорится, очень интересная.

Артём Кулаков и Рома Чорыев разработчики Redmadrobot, они записывают подкаст, где обсуждают различные стороны создания ИТ-продуктов. Ниже ссылка на новый выпуск, тайминг и ответы на душещипательные вопросы. Но вначале небольшой дисклеймер:

Почему все больше и больше ведется разговоров о различных аспектах и методологиях проектирования систем? Потому что наши системы стали действительно большими. Чтобы разобраться, как проектировать такие системы, мы позвали Алексея Мерсона системного архитектора из Karuna. В выпуске попробовали разобраться, что такое Domain Driven Design, как он связан с Event Sourcing и при чем тут CQRS и микросервисы. Снять удалось только первый слой, да и то неравномерно. Но всем, кто хочет начать погружаться в тему, этот выпуск будет несомненно полезен. И обязательно ознакомьтесь с материалами к выпуску.

Тайминг

02:29 Гость студии Алексей Мерсон и как он начинал;

05:02 .Net и DDD;

12:26 почему сейчас все чаще говорят о DDD;

15:30 полезная литература о DDD;

23:01 как начать проектировать систему по DDD;

25:05 Event storming и Miro;

45:15 что такое Event sourcing;

55:00 CQRS и его связь с DDD и Event sourcing;

01:06:10 с чего начать.

DDD что это и почему сейчас?

Domain-Driven Design предметно-ориентированное проектирование. Понятие известно давно, но в последнее время в русскоязычном сообществе о нем говорят всё чаще.

Алексей считает, что возможно, это связано с тем, в Agile-мире запускаются проекты, которые без применения DDD скатятся в печальное состояние, потому что появится расхождение между предметной областью и реализацией.

В первую очередь Domain-Driven Design это история о проектировании, и предметная область в нем ставится во главу угла. И основной акцент в этом подходе делается на взаимодействии с бизнесом с заказчиками ПО и приложений.

Если проще, то бывает, что для решения бизнес-задач конвейер производства ПО растягивается, и в конечном счете в реализации решения этих задач могут оказаться совсем не такими, какими задумывались изначально. И вот тут, чтобы не играть в испорченный телефон, применяется DDD. Его цель сократить расстояние между бизнесом и разработчиками, чтобы последние максимально точно понимали, что за бизнес-процессы они реализуют.

Как спроектировать сложную систему с нуля?

В студии прозвучал резонный вопрос: представим, что мы поняли, что у нас сложная система, которую будем делать по DDD и у нас на это даже есть много времени. С чего стоит начать? Алексей дал несколько советов.

Первое конечно же, придется стартовать с MVP, то есть с маленьких кусочков, которые затем будут разрастаться во что-то большее. Второе понять процессы, которые нужно автоматизировать. При этом важно находиться в контакте с теми, кто ставит задачи. Например, с продактами или с кем-то из бизнеса, топ-менеджерами. Дальше расписать сценарии таким образом, чтобы стал понятен контекст, в котором существуют заданные бизнес-процессы.

И, кстати, многие для этих целей используют системой Event storming это такой способ собрать разработчиков и бизнес-экспертов в одном месте, чтобы вместе исследовать предстоящую разработку.

В Event storming собираются все заинтересованные лица и на доске с помощью стикеров выстраивают последовательность событий и тех объектов, которые генерируют эти события. Получается некая визуальная карта, в которой становится понятно, как взаимодействуют различные сущности и как они объединяются по контекстам.

Event Storming фото Daniel GomesEvent Storming фото Daniel Gomes

Изначально активность проводили офлайн, но сейчас подобное можно спокойно провести онлайн, например, в Miro.

Event Sourcing (не путать с Event storming)

Event Sourcing еще одна популярная сегодня тема. Это архитектурный шаблон, упоминание которого часто всплывает в связи с DDD.

На самом деле, связаны они косвенно, и прямой зависимости между ними нет дело в том, что Event Storming помогает генерировать события, которые можно потом переложить в Event Sourcing архитектуру. В общем, в студии попытались разобраться, почему об Event Sourcing говорят.

Недавно в сообществе SpbDotNet был митап, где рассказывали о том, как одна команда применяла Event Sourcing. Они применяли его в приложении, которое реализовывало аукционы. Там есть ставки, и они, по сути, события, которые конкурируют: важно, в какой момент каждое из них произошло, важно получить из них правильную последовательность, чтобы понять конечный статус агрегата, например, лота или аукциона. И в этой ситуации Event Sourcing вполне оправдан.

Но иногда случается, что Event Sourcing это просто ненужное усложнение процесса. Ведь его легко сделать неправильно и очень сложно сделать правильно, потому что в нем есть много мест, где можно свернуть не туда.

Event Sourcing лучше всего применять в конкурентных и неизменяемых историях, говорит Алексей. Классическое место для этого шаблона финансовые транзакции. Есть финансовые события: зачисления или списания, и разработчику важно, чтобы их последовательность была, во-первых, четкой. Во-вторых, ее невозможно было отменить необходим однозначный конечный баланс. И Event Sourcing дает возможность сделать это так, чтобы цепочка событий всегда шла только вперед.

Подробнее обо всём начинается с 45:15

CQRS yay or nay?

Ребята обсудили, что CQRS это, скорее, паттерн, применяемый в технической области. Связан ли CQRS и DDD?

DDD больше заточено на side effects и изменения, а CQRS больше относится к отображению. И это его качество, как раз, применяется в Event Sourcing, потому что фактически есть только набор ивентов, а показывать, как правило, нужно данные, состояния объектов. А для того чтобы данные эти получить, нужно из ивентов делать проекции. В общем, если смотреть на CQRS под этим углом, получается история о синхронном взаимодействии с точки зрения UI/UX.

Подробное обсуждение этого непростого вопроса с 55:00.

Где и как научиться всему этому? (желательно до выхода на пенсию)

Алексей посоветовал начать с его доклада. Он подметил, что для того и рассказывал, чтобы дать интро по теме.

Кроме того, стоит почитать книги из списка полезных материалов, который будет дальше. В студии согласились с тем, что лучше начать читать что-то более общее и лёгкое, чтобы просто понять принципы. А дальше можно переходить к более сложным и подробным книгам, например, к синей книге Эрика Эванса.

Можно ещё подписаться на сообщество DDDevotion и спросить там обо всем, что интересует. Подписчики могут проконсультировать в самых разных вопросах, например, как применять DDD в PHP, а также в других стэках.

Под конец разговора Рома ещё задал интересный вопрос: Kакая проблема должна стоять перед разработчиком, чтобы он понял, что пришло время углубиться в DDD? Если коротко, то сложно ответить коротко :) А подробно рассказали с 1:10:00.

Полезные материалы

Предыдущие выпуски подкаста Сушите вёсла

Подробнее..

Мир изменился CQRS и ES встречаются в PHP чаще, чем кажется

30.04.2021 20:15:00 | Автор: admin

Генри Форд чуть не прогорел на своей классической фразе про пятьдесят оттенков черного. General Motors стала предлагать разноцветные модели Chevrolet, Pontiac, Buick, Oldsmobile и Cadillac и не прогадала. Глядя на это, даже упрямый Форд изменил свое мышление и разработал новый Ford A, вернувший его на автомобильный Олимп. Бывают времена, когда парадигма мышления должна стать новой ибо человек умирает тогда, когда перестаёт меняться Генри Форд.

Пришло время и для разработчиков. Command Query Responsibility Segregation (CQRS) и Event Sourcing (ES) уже не миф они реально работают. Конечно, не для всех задач как и классический черный цвет Форда, PHP никуда не исчез и нужен по-прежнему. Но теперь уже есть задачи, где мы встречаемся с CQRS и ES чаще, чем нам кажется. Антон Шабовта на PHP Russia 2021 расскажет, как смена парадигмы и взгляд с другой стороны помогают разработчикам. А перед конференцией мы расспросили Антона подробнее о его новых взглядах на разработку, PHP и, конечно, оCQRS и ES.

Антон, расскажи о себе и о своей работе. Чем ты занимаешься?

Последние 12 лет я в коммерческой разработке и большая часть времени занимался проектами связанными с E-Commerce. Но 3 года назад мне захотелось применить свои знания в проектах другой сферы. Так я пришел в сервисную команду Onlinerа это крупнейший белорусский медиа портал с огромным количеством сервисов. Нашу команду разработки можно условно разделить на две части. Команда каталога занимается основном продуктом каталогом товаров. В нем почти два миллиона позиций от тысяч магазинов а это десятки миллионов товарных позиций. И этот действительно большой и сложный E-Commerce продукт, который продолжает развиваться и расти.

Команда сервисов, в которой я и работаю, занимается остальными нашими продуктами. Это сервисы аренды и продажи квартир, сервис поиска и предоставления услуг, авто- и мотоклассифайды и т.д. Есть еще огромный новостной портал с кучей инфраструктуры и сервисов вокруг него (комментарии, система личных сообщений, идентификация пользователей и пр.) Конечно, есть и сервисы, связанные с оплатой банковскими карточками и т.д. То есть это огромное скопление проектов в разных сферах и предметных областях.

По сравнению с E-Commerce это совершенно другой мир и другие задачи. И мне было интересно понять, могу ли я применить знания из предыдущих проектов подстроить, преобразить и переложить опыт из одной сферы на другую. Но и новому тоже хотелось учиться.

Твой доклад на конференции будет про долгий путь к CQRS и Event Sourcing. Это связано с твоей карьерой разработчика?

Да. Впервые я столкнулся с подходами Command Query Responsibility Segregation (CQRS) и Event Sourcing (ES) еще при работе с E-Commerce в 2015 году. И это стало важной вехой в моей карьере разработчика. Информации о CQRS и ES было много, но столько же возникало вопросов, мифов и недопонимания. Что именно представляют собой эти технологии? Как их использовать? Где стоит применять и какие проблемы они действительно призваны решать? Так вот одна из моих целей на PHP Russia 2021 развенчать часть этих мифов и показать, что мы сталкиваемся с CQRS и ES намного чаще, чем кажется, даже если раньше мы никогда не слышали эти слова.

В 2017 году, проработав два года с CQRS и ES, я сделал доклад об этом в рамках митапов Минской PHP User Group. Но, пересмотрев слайды, я понял, что в корне неверно подходил к объяснению этих технологий. За пять лет мое понимание значительно преобразилось, и я надеюсь, что на этот раз смогу лучше объяснить. Так что во многом доклад для PHP Russia 2021 это еще и работа над ошибками.

У тебя есть опыт с Erlang и Java (про С/С++, C# и Python знаем), или же ты целенаправленно изучаешь практики оттуда, чтобы рассмотреть их для PHP?

По-разному, it depends. Исторически так сложилось, что многие книги по архитектуре программных систем используют примеры и понятия из Java-мира. И чтобы не упустить какие-то ключевые моменты и важные нюансы, пришлось разбираться в Java. Недавно стряхнул пыль с этих знаний, когда искал решения для своих проектов и разбирался с фреймворками Axon и Akka. В целом же, практики из одного языка, по крайней мере, из Java, очень легко и просто переносятся на PHP.

С# я начал изучать, когда разбирался в устройстве фреймворка NServiceBus. В нем реализовано много классных решений, связанных с MBA (message-based architecture), SOA (service-oriented architecture) и сопутствующими технологиями.

Erlang это вообще отдельная история. Интерес к нему пришел в процессе изучения классического понятия ООП (объектно-ориентированного программирования) от Алана Кея и модели экторов. Это реально классный язык, совершенно непохожий на другие. Не могу сказать, что готов сейчас писать на нем production ready код, но изучать его концепции, конечно, продолжу.

Получается, что ты изучаешь, условно говоря, не какие-то языки, а перенимаешь практики из других языков, которые там успешны?

Не всегда то, что успешно работает в одном языке, будет работать в другом. Но в процессе изучения я вижу, когда какие-то практики можно попробовать.

Помогает ли знание других языков лучше писать на PHP?

И да, и нет. С одной стороны, многие практики и идеи можно (и нужно!) применять в PHP, особенно из близких по духу по подходам языков (Java, C#). ООП-модель PHP очень близка к этим языкам. К тому же мир разработки в PHP очень сильно изменился, например, после выхода фреймворка Symfony 2. Команда Symfony проделала колоссальную работу по прививанию паттернов проектирования в PHP community. Но большинство паттернов были придуманы не для PHP, а для других языков, в том числе, для Smalltalk и Java.

С другой стороны, из-за технических ограничений, некоторые интересные концепции невозможно развивать и применять в PHP. Например, многие подходы из мира функционального программирования и модели экторов, пропагандируемые Erlangом. Мы ограничены технологиями своего времени. Но, возможно, часть этих технологий в будущем можно будет применить и в PHP.

Когда нужны подходы из других языков, а когда лучше по старинке или попроще?

Это самый сложный вопрос, независимо от технологии, который только может себе задать разработчик. И простого ответа тут нет.

Я стараюсь для себя придерживаться KISS-принципа, то есть Keep it simple, stupid: если есть возможность делать что-то проще и не усложнять, то лучше так и сделать. Такие серьезные подходы как CQRS и ES несут много изменений не только в коде, а даже в самой модели мышления программиста. Мы ставим себя в определенные рамки, и за это придется платить. Не бывает серебряной пули в программировании.

Поэтому внедрять CQRS и ES не глядя, просто потому что можем очень-очень плохая идея. Можно получить намного больше проблем, чем пользы. Конечно, когда-то это оправдано, но не всегда. Поэтому нужно хорошее изучение problems face, чтобы понимать, зачем мы внедряем эту технологию и какую проблему бизнеса пытаемся ею решить.

Что дают эти подходы разработчику, в чем помогают?

CQRS и ES ориентированы на то, чтобы разработчик больше думал именно о поведении системы в первую очередь, а не о хранении. Это очень часто помогает найти существующие взаимодействия, которые не видны на уровне данных. Эти подходы проявляют эти взаимодействия и позволяют с ними явно работать, упрощая какие-то штуки. Но в то же время очень сложно ментально перестроиться и сменить парадигму мышления.

Поэтому нет популярных фреймворков для CQRS и ES?

Строго говоря, их не может быть. Говоря о фреймворках, мы подразумеваем, в первую очередь, инверсию управления. Когда мы работаем с фреймворком, не мы вызываем его код, а он вызывает наш. Это вопрос инфраструктуры.

С другой стороны, ES и CQRS неразрывно связанны именно с моделированием домена приложения, то есть это доменная модель. А хорошая доменная модель не должна зависеть от фреймворка. Но мы можем ее внедрить в любой фреймворк, с котором нам удобно будет работать.

Создатель этих подходов Грег Янг любит повторять в своих докладах, что для реализации ES и CQRS достаточно двух операций:

  1. Сравнение по образцам (pattern matching);

  2. Лево-ассоциативная свертка списка (left fold) функция высшего порядка.

И многие языки поддерживают эти операции уже на уровне стандартной библиотеки. Например, тот же left fold в PHP существует как array_reduce, и дополнительно можно придумать другие варианты реализации.

Pattern matching, к сожалению, полностью в PHP еще не реализован, хотя работа в этом направлении ведется. Но та малая часть из pattern matching, которая нужна для имплементации ES, легко эмулируется в user-land коде.

Есть также много технологий, которые работают вокруг и рядом с CQRS и ES те же message-based architecture и service-oriented architecture. Для этих технологий уже есть фреймворки, хотя достаточно популярных в PHP-мире пока не сформировалось. Какие-то работы сейчас ведутся и какие-то фреймворки вырастают. Но enterprise production ready, решений уровня того же NServiceBus в C# либо Axon в Java, пока в PHP не сложилось. Ждем!

А есть ли учебник, где на пальцах правильно объясняются эти подходы?

Изучать CQRS и ES стоит с просмотра докладов отца-основателя Грега Янга, с его публичных выступлений, статей, материалов и записей в блоге. Он подробно пишет, как он пришел к этим подходам, и из каких проблем они возникли. Для продолжения есть его книга Versioning in an Event Sourced System там вы найдете для себя кое-какие нюансы.

Много материалов по ES и CQRS подходам можно найти в документации Microsoft. У них есть даже более развернутый вариант, который вышел в виде отдельной книги Exploring CQRS and Event Sourcing. Предисловие к книге написал тот же Грег Янг.

Еще этим технологиям много внимания уделяют те, кто пишут и работают с DDD-подходом (Domain-Driven Design), например, Vaugh Vernon. И у него есть книга Implementing Domain-Driven Design, в которой большая глава посвящена именно ES и CQRS.

Кому можно верить, не проверяя, в мире разработки и PHP: Фаулеру, Мартину, кому-то еще?

Никому. Серьезно. Мартин Фаулер, Роберт Мартин, тот же Грег Янг, а также другие авторы тратят сотни часов времени, чтобы поделиться своими знаниями в статьях, в записях блогов, в докладах и в книгах. Иногда пишутся целые научные работы по каким-то подходам. Это действительно круто, что мы имеем доступ к этой информации.

Но часто разработчик, прочитав статью или увидев доклад о той или иной технологии, тут же бежит её внедрять на своем проекте, забывая о проблеме, которую она должна решать. DDD, CQRS, ES и все другие сложные подходы появились для решения конкретных проблем, с которыми столкнулись их создатели. Вот что должно быть первоочередным для хорошего разработчика.

Я считаю, что безоговорочное доверие авторитетам, этот аристократичный синдром, наравне с культом карго это одна из ключевых проблем современного IT.

То есть ты сам не придерживаешься этого всего?

Я, естественно, изучаю работы всех именитых разработчиков и с интересом наблюдаю за развитием технологий. Про них надо знать и помнить, но нельзя их применять не глядя. Надо сначала столкнуться с проблемой. Понять, что одна из технологий, про которую ты читал, вспомнил, увидел, решает эту проблему. И только тогда думать, как ее внедрять, и действительно ли она решит эту проблему.

Очень часто наш мозг нас немножко обманывает. Он помнит про какое-то изящное и красивое решение, про какую-то очень крутую технологию, и пытается подогнать нашу проблему под нее. С этим, конечно, нужно бороться. И понимание того, когда нужна та или иная технология, приходит только с опытом, с годами работы и ошибок.

Мы, программисты, реализуем идеи, с которыми к нам приходит бизнес. У бизнеса есть какая-то проблема, мы придумываем решение. Разработчики должны исходить из проблем бизнеса, а не использовать технологию ради технологии, для красоты.

Чего ты ждёшь от конференции в этом году?

Онлайн-формат это, конечно, интересно и круто. Но пока я не могу сказать, что он может заменить оффлайн. Поэтому в первую очередь я жду, конечно, отличных докладов, много профессионального общения и интересных обсуждений в кулуарах. Я считаю, что эти обсуждения даже более ценны, чем доклады. Зажав спикера в кольцо, можно ему задать важные вопросы, которые не всегда есть время раскрыть в докладе. Очень часто какие-то действительно problem spaces остаются за рамками докладов.

Еще я жду новостей! С такими переносами, я думаю, к моменту начала конференции у нас их будет немало. Хотелось бы знать:

  • Что нас ждет в PHP 9?

  • Какая будет следующая большая цель на этот релиз? Возможно, это будет асинхронный PHP это важный лично для меня вопрос. На прошлом PHP Russia я читал доклад именно про асинхронный PHP.

  • Вопрос, волнующий всех: появятся ли у нас Generic-типы? Недавно добавили Union-типы. Скорее всего, скоро добавят пересечения, но Generic это то, что возникает так или иначе в вопросах на каждой PHP-конференции.

И афтепати, конечно!

PHP Russia 2021 пройдёт в гибридном формате будет и офлайн, и онлайн. Онлайн-зрители смогут задавать вопросы спикерам в зале, принимать участие в дискуссиях и участвовать в активностях на стендах партнёров.

Мы с нетерпением ждём нашу встречу в офлайне 28 июня 2021 года. Сегодня последний день до повышения цены сейчас стоимость очного участия 27 000 рублей

Подписывайтесь на наши соцсети, чтобы быть в курсе новостей (FB,VK,Telegram-канал,Twitter), общайтесь с коллегами в чате.

Подробнее..

Применение CQRS amp Event Sourcing в создании платформы для проведения онлайн-аукционов

07.07.2020 14:13:48 | Автор: admin
Коллеги, добрый день! Меня зовут Миша, я работаю программистом.

В настоящей статье я хочу рассказать о том, как наша команда решила применить подход CQRS & Event Sourcing в проекте, представляющем собой площадку для проведения онлайн-аукционов. А также о том, что из этого получилось, какие из нашего опыта можно сделать выводы и на какие грабли важно не наступить тем, кто отправится путем CQRS & ES.
image


Прелюдия


Для начала немного истории и бизнесового бэкграунда. К нам пришел заказчик с платформой для проведения так называемых timed-аукционов, которая была уже в продакшене и по которой было собрано некоторое количество фидбэка. Заказчик хотел, чтоб мы сделали ему платформу для live-аукционов.

Теперь чуть-чуть терминологии. Аукцион это когда продаются некие предметы лоты (lots), а покупатели (bidders) делают ставки (bids). Обладателем лота становится покупатель, предложивший самую большую ставку. Timed-аукцион это когда у каждого лота заранее определен момент его закрытия. Покупатели делают ставки, в какой-то момент лот закрывается. Похоже на ebay.

Timed-платформа была сделана классически, с применением CRUD. Лоты закрывало отдельное приложение, запускаясь по расписанию. Работало все это не слишком надежно: какие-то ставки терялись, какие-то делались как будто бы от лица не того покупателя, лоты не закрывались или закрывались по несколько раз.

Live-аукцион это возможность участвовать в реальном офлайн-аукционе удаленно, через интернет. Есть помещение (в нашей внутренней терминологии комната), в нем находится ведущий аукциона с молотком и аудитория, и тут же рядом с ноутбуком сидит так называемый клерк, который, нажимая кнопки в своем интерфейсе, транслирует в интернет ход аукциона, а подключившиеся к аукциону покупатели видят ставки, которые делаются офлайн, и могут делать свои ставки.

Обе платформы в принципе работают в реальном времени, но если в случае timed все покупатели находятся в равном положении, то в случае live крайне важно, чтоб онлайн-покупатели могли успешно соревноваться с находящимися в комнате. То есть система должна быть очень быстрой и надежной. Печальный опыт timed-платформы недвусмысленно говорил нам, что классический CRUD нам не подходит.

Своего опыта работы с CQRS & ES у нас не было, так что мы посовещались с коллегами, у которых он был (компания у нас большая), презентовали им наши бизнес-реалии и совместно пришли к заключению, что CQRS & ES должен нам подойти.

Какая еще есть специфика работы онлайн-аукционов:

  • Много пользователей одновременно пытаются воздействовать на один и тот же объект в системе текущий лот. Покупатели делают свои ставки, клерк вводит в систему ставки из комнаты, закрывает лот, открывает следующий. В каждый момент времени в системе можно сделать ставку только одной величины например, 5 рублей. И только один пользователь сможет сделать эту ставку.
  • Нужно хранить всю историю действий над объектами системы, чтобы в случае необходимости можно было посмотреть, кто какую ставку сделал.
  • Время отклика системы должно быть очень маленьким ход онлайн-версии аукциона не должен отставать от офлайн, пользователям должно быть понятно, к чему привели их попытки сделать ставку успешны они или нет.
  • Пользователи должны оперативно узнавать обо всех изменениях в ходе аукциона, а не только о результатах своих действий.
  • Решение должно быть масштабируемым несколько аукционов могут проходить одновременно.

Краткий обзор подхода CQRS & ES


Не буду подробно останавливаться на рассмотрении подхода CQRS & ES, материалы об этом есть в интернете и в частности на Хабре (например, вот: Введение в CQRS + Event Sourcing). Однако кратко все же напомню основные моменты:

  • Самое главное в event sourcing: система хранит не данные, а историю их изменения, то есть события. Текущее состояние системы получается последовательным применением событий.
  • Доменная модель делится на сущности, называемые агрегатами. Агрегат имеет версию. События применяются к агрегатам. Применение события к агрегату инкрементирует его версию.
  • События хранятся в write-базе. В одной и той же таблице хранятся события всех агрегатов системы в том порядке, в котором они произошли.
  • Изменения в системе инициируются командами. Команда применяется к одному агрегату. Команда применяется к последней, то есть текущей, версии агрегата. Агрегат для этого выстраивается последовательным применением всех своих событий. Этот процесс называется регидратацией.
  • Для того, чтобы не регидрировать каждый раз с самого начала, какие-то версии агрегата (обычно каждая N-я версия) можно хранить в системе в готовом виде. Такие снимки агрегата называются снапшотами. Тогда для получения агрегата последней версии при регидратации к самому свежему снапшоту агрегата применяются события, случившиеся после его создания.
  • Команда обрабатывается бизнес-логикой системы, в результате чего получается, в общем случае, несколько событий, которые сохраняются в write-базу.
  • Кроме write-базы, в системе может еще быть read-база, которая хранит данные в форме, в которой их удобно получать клиентам системы. Сущности read-базы не обязаны соответствовать один к одному агрегатам системы. Read-база обновляется обработчиками событий.
  • Таким образом, у нас получается разделение команд и запросов к системе Command Query Responsibility Segregation (CQRS): команды, изменяющие состояние системы, обрабатываются write-частью; запросы, не изменяющие состояние, обращаются к read-части.



Реализация. Тонкости и сложности.


Выбор фреймворка


В целях экономии времени, а также в силу отсутствия специфического опыта мы решили, что нужно использовать какой-то фреймворк для CQRS & ES.

В целом наш технологический стек это Microsoft, то есть .NET и C#. База данных Microsoft SQL Server. Хостится все в Azure. На этом стеке была сделана timed-платформа, логично было и live-платформу делать на нем.

На тот момент, как сейчас помнится, Chinchilla была чуть ли не единственным вариантом, подходящим нам по технологическому стеку. Так что мы взяли ее.

Зачем вообще нужен фреймворк CQRS & ES? Он может из коробки решать такие задачи и поддерживать такие аспекты реализации как:

  • Сущности агрегата, команды, события, версионирование агрегатов, регидратация, механизм снапшотов.
  • Интерфейсы для работы с разными СУБД. Сохранение/загрузка событий и снапшотов агрегатов в/из write-базы (event store).
  • Интерфейсы для работы с очередями отправка в соответствующие очереди команд и событий, чтение команд и событий из очереди.
  • Интерфейс для работы с веб-сокетами.

Таким образом, с учетом использования Chinchilla, к нашему стеку добавились:

  • Azure Service Bus в качестве шины команд и событий, Chinchilla поддерживает его из коробки;
  • Write- и read-базы Microsoft SQL Server, то есть обе они SQL-базы. Не скажу, что это является результатом осознанного выбора, скорее по историческим причинам.

Да, фронтенд сделан на Angular.

Как я уже говорил, одно из требований к системе чтобы пользователи максимально быстро узнавали о результатах своих действий и действий других пользователей это относится и к покупателям, и к клерку. Поэтому мы используем SignalR и веб-сокеты для оперативного обновления данных на фронтенде. Chinchilla поддерживает интеграцию с SignalR.

Выбор агрегатов


Одной из первых вещей, которую надо сделать при реализации подхода CQRS & ES это определить, как доменная модель будет делиться на агрегаты.

В нашем случае доменная модель состоит из нескольких основных сущностей, примерно таких:

public class Auction{     public AuctionState State { get; private set; }     public Guid? CurrentLotId { get; private set; }     public List<Guid> Lots { get; }}public class Lot{     public Guid? AuctionId { get; private set; }     public LotState State { get; private set; }     public decimal NextBid { get; private set; }     public Stack<Bid> Bids { get; }} public class Bid{     public decimal Amount { get; set; }     public Guid? BidderId { get; set; }}


У нас получилось два агрегата: Auction и Lot (с Bidами). В общем, логично, но мы не учли одного того, что при таком делении состояние системы у нас размазалось по двум агрегатам, и в ряде случаев для сохранения консистентности мы должны вносить изменения в оба агрегата, а не в один. Например, аукцион можно поставить на паузу. Если аукцион на паузе, то нельзя делать ставки на лот. Можно было бы ставить на паузу сам лот, но аукциону на паузе тоже нельзя обрабатывать никаких команд, кроме как снять с паузы.

В качестве альтернативного варианта можно было сделать только один агрегат, Auction, со всеми лотами и ставками внутри. Но такой объект будет довольно тяжелым, потому что лотов в аукционе может быть до нескольких тысяч и ставок на один лот может быть несколько десятков. За время жизни аукциона у такого агрегата будет очень много версий, и регидратация такого агрегата (последовательное применение к агрегату всех событий), если не делать снапшотов агрегатов, будет занимать довольно продолжительное время. Что для нашей ситуации неприемлемо. Если же использовать снапшоты (мы их используем), то сами снапшоты будут весить очень много.

С другой стороны, чтобы гарантировать применение изменений к двум агрегатам в рамках обработки одного действия пользователя, нужно или менять оба агрегата в рамках одной команды с использованием транзакции, либо выполнять в рамках одной транзакции две команды. И то, и другое, по большому счету, является нарушением архитектуры.

Подобные обстоятельства нужно учитывать, разбивая доменную модель на агрегаты.

Мы на данном этапе эволюции проекта живем с двумя агрегатами, Auction и Lot, и нарушаем архитектуру, меняя в рамках некоторых команд оба агрегата.

Применение команды к определенной версии агрегата


Если несколько покупателей одновременно делают ставку на один и тот же лот, то есть отправляют в систему команду сделать ставку, успешно пройдет только одна из ставок. Лот это агрегат, у него есть версия. При обработке команды создаются события, каждое из которых инкрементирует версию агрегата. Можно пойти двумя путями:

  • Отправлять команду, указывая в ней, к какой версии агрегата мы хотим ее применить. Тогда обработчик команды сразу же может сравнить версию в команде с текущей версией агрегата и не продолжать в случае несовпадения.
  • Не указывать в команде версию агрегата. Тогда агрегат регидрируется с какой-то версией, выполняется соответствующая бизнес-логика, создаются события. И уже только при их сохранении может выскочить эксепшен о том, что такая версия агрегата уже существует. Потому что кто-то другой успел раньше.

Мы используем второй вариант. Так у команд больше шансов выполниться. Потому что в той части приложения, которая отправляет команды (в нашем случае это фронтенд), текущая версия агрегата с некоторой вероятностью будет отставать от реальной версии на бэкенде. Особенно в условиях, когда команд отправляется много, и верcия агрегата меняется часто.

Ошибки при выполнении команды с использованием очереди


В нашей реализации, в большой степени обусловленной использованием Chinchilla, обработчик команд читает команды из очереди (Microsoft Azure Service Bus). Мы у себя явно разделяем ситуации, когда команда зафейлилась по техническим причинам (таймауты, ошибки подключения к очереди/базе) и когда по бизнесовым (попытка сделать на лот ставку той же величины, что уже была принята, и проч.). В первом случае попытка выполнить команду повторяется, пока не выйдет заданное в настройках очереди число повторений, после чего команда отправляется в Dead Letter Queue (отдельный топик для необработанных сообщений в Azure Service Bus). В случае бизнесового эксепшена команда отправляется в Dead Letter Queue сразу.



Ошибки при обработке событий с использованием очереди


События, создаваемые в результате выполнения команды, в зависимости от реализации, тоже могут отправляться в очередь и браться из очереди обработчиками событий. И при обработке событий тоже случаются ошибки.

Однако, в отличие от ситуации с невыполненной командой, здесь все хуже может получиться так, что команда выполнилась и события в write-базу записались, но обработка их обработчиками зафейлилась. И если один из этих обработчиков обновляет read-базу, то read-база не обновится. То есть окажется в неконсистентном состоянии. Благодаря наличию механизма повторных попыток обработки события read-база почти всегда, в конечном итоге, обновляется, однако вероятность того, что после всех попыток она останется поломанной, все-таки остается.



Мы у себя столкнулись с этой проблемой. Причина, впрочем, была в большой степени в том, что в обработке событий у нас присутствовала некоторая бизнес-логика, которая при интенсивном потоке ставок имеет хорошие шансы фейлиться из раза в раз. К сожалению, осознали мы это слишком поздно, переделать бизнес-реализацию быстро и просто не получилось.

В итоге, в качестве временной меры мы отказались от использования Azure Service Bus для передачи событий из write-части приложения в read-часть. Вместо нее используется так называемая In-Memory Bus, что позволяет обрабатывать команду и события в одной транзакции и в случае неудачи откатить все целиком.



Такое решение не способствует масштабируемости, но зато мы исключаем ситуации, когда у нас ломается read-база, от чего в свою очередь ломаются фронтенды и продолжение аукциона без пересоздания read-базы через проигрывание заново всех событий становится невозможным.

Отправка команды в качестве реакции на событие


Такое в принципе уместно, но только в случае, когда невыполнение этой второй команды не ломает состояние системы.

Обработка множества событий одной команды


В общем случае в результате выполнения одной команды получается несколько событий. Бывает, что на каждое из событий нам нужно сделать какое-то изменение в read-базе. Бывает также, что последовательность событий тоже важна, и в неправильной последовательности обработка событий не отработает как надо. Все это означает, что мы не можем читать из очереди и обрабатывать события одной команды независимо, например, разными экземплярами кода, который читает сообщения из очереди. Плюс к этому нам нужна гарантия того, что события из очереди будут прочитаны в той же последовательности, в которой они были туда отправлены. Либо нам нужно быть готовыми к тому, что не все события команды будут успешно обработаны с первой попытки.



Обработка одного события несколькими обработчиками


Если в качестве реакции на одно событие системе нужно выполнить несколько разных действий, обычно делаются несколько обработчиков этого события. Они могут отрабатывать параллельно или последовательно. В случае последовательного запуска при неуспешном выполнении одного из обработчиков вся последовательность запускается заново (в Chinchilla это так). При такой реализации важно, чтобы обработчики были идемпотентными, чтобы второй запуск один раз успешно отработавшего обработчика не свалился. В противном случае при падении второго обработчика из цепочки она, цепочка, уже точно не отработает целиком, потому что во вторую (и последующие) попытку упадет первый обработчик.

Например, обработчик события в read-базе добавляет ставку на лот величиной 5 рублей. Первая попытка сделать это будет успешной, а вторую не даст выполнить constraint в базе.



Выводы/Заключение


Сейчас наш проект находится в стадии, когда, как нам кажется, мы наступили уже на бОльшую часть существующих граблей, актуальных для нашей бизнес-специфики. В целом мы считаем свой опыт довольно успешным, CQRS & ES хорошо подходит для нашей предметной области. Дальнейшее развитие проекта видится в отказе от Chinchilla в пользу другого фреймворка, дающего больше гибкости. Впрочем, возможен и вариант отказа от использования фреймворка вообще. Также вероятно будут какие-то изменения в направлении поиска баланса между надежностью с одной стороны и быстротой и масштабируемостью решения с другой.

Что касается бизнес-составляющей, то и здесь некоторые вопросы по-прежнему остаются открытыми например, деление доменной модели на агрегаты.

Хочется надеяться, что наш опыт окажется для кого-то полезным, поможет сэкономить время и избежать граблей. Спасибо за внимание.
Подробнее..

CQS (CQRS) со своим блэкджеком

01.03.2021 00:05:02 | Автор: admin
Command-query separation (CQS) это разделение методов на read и write.

Command Query Responsibility Segregation (CQRS) это разделение модели на read и write. Предполагается в одну пишем, с нескольких можем читать. М масштабирование.

Этот подход часто используют как способ организации кода, даже если хранилище одно. Но как всегда, в реальных более-менее сложных проектах эта штука дает сбой.

Размышления навеяны этой статьей Паттерн CQRS: теория и практика в рамках ASP.Net Core 5 и актуальны для анемичной модели. Для DDD все по-другому.

Историческая справка


Начать пожалуй стоит с исторической справки. Сначала было как-то так:

public interface IEntityService{    EntityModel[] GetAll();    EntityModel Get(int id);    int Add(EntityModel model);    void Update(EntityModel model);    void Delete(int id);}public interface IEntityRepository{    Entity[] GetAll();    Entity Get(int id);    int Add(Entity entity);    void Update(Entity entity);    void Delete(int id);}

С появлением CQS стало так:

public class GetEntitiesQuery{     public EntityModel[] Execute() { ... }}public class GetEntityQuery{     public EntityModel Execute(int id) { ... }}public class AddEntityCommand{     public int Execute(EntityModel model) { ... }}public class UpdateEntityCommand{     public void Execute(EntityModel model) { ... }}public class DeleteEntityCommand{     public void Execute(int id) { ... }}

Эволюция


Как видим, два потенциальных god-объекта разделяются на много маленьких и каждый делает одну простую вещь либо читает данные, либо обновляет. Это у нас CQS. Если еще и разделить на два хранилища (одно для чтения и одно для записи) это будет уже CQRS. Собственно что из себя представляет например GetEntityQuery и UpdateEntityCommand (здесь и далее условный псевдокод):

public class GetEntityQuery{    public EntityModel Execute(int id)    {        var sql = "SELECT * FROM Table WHERE Id = :id";        using (var connection = new SqlConnection(...connStr...))        {             var command = connection.CreateCommand(sql, id);             return command.Read();        }    }}public class UpdateEntityCommand{    public void Execute(EntityModel model)    {        var sql = "UPDATE Table SET ... WHERE Id = :id";        using (var connection = new SqlConnection(...connStr...))        {             var command = connection.CreateCommand(sql, model);             return command.Execute();        }    }}

Теперь к нам приходит ORM. И вот тут начинаются проблемы. Чаще всего сущность сначала достается из контекста и только затем обновляется. Выглядит это так:

public class UpdateEntityCommand{    public void Execute(EntityModel model)    {        var entity = db.Entities.First(e => e.Id == model.Id); // <-- опа, а что это? query?        entity.Field1 = model.Field1;        db.SaveChanges();    }}

Да, если ORM позволяет обновлять сущности сразу, то все будет хорошо:

public class UpdateEntityCommand{    public void Execute(EntityModel model)    {        var entity = new Entity { Id = model.Id, Field1 = model.Field1 };        db.Attach(entity);        db.SaveChanges();    }}

Так а что делать, когда надо достать сущность из базы? Куда девать query из command? На ум приходит сделать так:

public class GetEntityQuery{    public Entity Execute(int id)    {        return db.Entities.First(e => e.Id == model.Id);    }}public class UpdateEntityCommand{    public void Execute(Entity entity, EntityModel model)    {        entity.Field1 = model.Field1;        db.SaveChanges();    }}

Хотя я встречал еще такой вариант:
public class UpdateEntityCommand{    public void Execute(EntityModel model)    {        var entity = _entityService.Get(model.Id); // )))         entity.Field1 = model.Field1;        db.SaveChanges();    }}public class EntityService{    public Entity Get(int id)    {        return db.Entities.First(e => e.Id == model.Id);    }}

Просто перекладываем проблему из одного места в другое. Эта строчка не перестает от этого быть query.

Ладно, допустим остановились на варианте с GetEntityQuery и UpdateEntityCommand. Там хотя бы query не пытается быть чем-то другим. Но куда это все сложить и откуда вызывать? Пока что есть одно место это контроллер, выглядеть это будет примерно так:

public class EntityController{    [HttpPost]    public EntityModel Update(EntityModel model)    {        var entity = new GetEntityQuery().Execute(model.Id);                new UpdateEntityCommand().Execute(entity, model);        return model;    }}

Да и через некоторое время нам понадобилось, например, отправлять уведомления:

public class EntityController{    [HttpPost]    public EntityModel Update(EntityModel model)    {        var entity = new GetEntityQuery().Execute(model.Id);                new UpdateEntityCommand().Execute(entity, model);                _notifyService.Notify(NotifyType.UpdateEntity, entity); // <-- А это query или command?        return model;    }}

В итоге контроллер у нас начинает толстеть.

Лирическое отступление IDEF0 и BPMN


Мало того, реальные бизнес-процессы сложные. Если взглянуть на диаграммы IDEF0 или BPMN можно увидеть несколько блоков, за каждым из которых может скрываться код наподобие нашего кода из контроллера или вложенная серия блоков.

image

И приведу пример одной реальной задачи: по гео-координатам получить погоду в заданной точке. Есть внешний условно-бесплатный сервис. Поэтому требуется оптимизация в виде кэша. Кэш не простой. Хранится в базе данных. Алгоритм выборки: сначала идем в кэш, если там есть точка в радиусе 10 км от заданной и в пределах 1 часа по времени, то возвращаем погоду из кэша. Иначе идем во внешний сервис. Здесь и query, и command, и обращение к внешнему сервису все-в-одном.

Решение


Решение давно витало в облаках, но никак не оформлялось в конкретном виде. Пока я однажды не встретил нечто очень похожее на одном проекте. Я взял его за основу и добавил свой блэкджек.

Как видим искомый CQS изначально создан для абстрагирования на уровне доступа к данным. Там с ним проблем нет. Код, который расположился у нас в контроллере это бизнес-код, еще один уровень абстракции. И именно для этого уровня выделим еще одно понятие бизнес-история. Или Story.

Одна бизнес-история это один из блоков на диаграмме IDEF0. Она может иметь вложенные бизнес-истории, как блок IDEF0 может иметь вложенные блоки. И она может обращаться к искомым понятиям CQS это к Query и Command.

Таким образом, код из контроллера мы переносим в Story:
public class EntityController{    [HttpPost]    public EntityModel Update(EntityModel model)    {        return new UpdateEntityStory().Execute(model);    }}public class UpdateEntityStory{    public EntityModel Execute(EntityModel model)    {        var entity = new GetEntityQuery().Execute(model.Id);                new UpdateEntityCommand().Execute(entity, model);                _notifyService.Notify(NotifyType.UpdateEntity, entity);        return model;    }}

И контроллер остается тонким.

Данная UpdateEntityStory инкапсулирует в себе законченный конкретный бизнес-процесс. Ее можно целиком использовать в разных местах (например в вызовах API). Она легко подвергается тестированию и никоим образом не ограничивает использование моков/фейк-объектов.

Диаграмму IDEF0/BPMN можно разбросать по таким Story, что даст более легкий вход в проект. Все изменения можно будет уложить в следующий процесс: сначала меняем документацию (диаграмму IDEF0) затем дописываем тесты а уже в конце дописываем бизнес-код. Можно наоборот, по этим Story автоматически построить документацию в виде IDEF0/BPMN диаграмм.

Но чтобы получить более стройный подход, необходимо соблюдать некоторые правила:
1. Story входная точка бизнес-логики. Именно на нее ссылается контроллер.
2. Но внутрь Story не должны попадать такие вещи как HttpContext и тому подобное. Потому что тогда Story нельзя будет легко вызывать в другом контексте (например в hangfire background job или обработчике сообщения из очереди там не будет никаких HttpContext).
3. Входящие параметры Story опциональны. Story может возвращать что-либо или не возвращать ничего (хотя для сохранения тестируемости хорошо бы она что-нибудь возвращала).
4. Story может работать как с бизнес-сущностями, так и с моделями и DTO. Может внутри вызывать соответствующие мапперы и валидаторы.
5. Story может вызывать другие Story.
6. Story может вызывать внешние сервисы. Хотя внешний вызов можно тоже оформить как Story. Об этом ниже с нашим сервисом погоды.
7. Story не может напрямую обращаться к контексту базы данных. Это область ответственности Query и Command. Если нарушить это правило, все запросы и команды вытекут наружу и размажутся по всему проекту.
8. На Story можно навешивать декораторы. Об этом тоже ниже.
9. Story может вызывать Query и Command.
10. Разные Story могут переиспользовать одни и те же Query и Command.
11. Query и Command не могут вызывать другие Story, Query и Command.
12. Только Query и Command могут обращаться к контексту базы данных.
13. В простых случаях можно обойтись без Story и из контроллеров вызывать сразу Query или Command.

Теперь тот самый пример с сервисом погоды:
public class GetWeatherStory{    public WeatherModel Execute(double lat, double lon)    {        var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);        if (weather == null)        {             weather = _weatherService.GetWeather(lat, lon);             new AddWeatherCommand().Execute(weather);        }        return weather;    }}public class GetWeatherQuery{    public WeatherModel Execute(double lat, double lon, DateTime currentDateTime)    {        // Нативный SQL запрос поиска записи в таблице по условиям:        // * в радиусе 10 км от точки lat/lon        // * в пределах 1 часа от currentDateTime        // С использованием расширений PostGis или аналогичных        return result;    }}public class AddWeatherCommand{    public void Execute(WeatherModel model)    {        var entity = new Weather { ...поля из model... };        db.Weathers.Add(entity);        db.SaveChanges();    }}public class WeatherService{    public WeatherModel GetWeather(double lat, double lon)    {        var client = new Client();        var result = client.GetWeather(lat, lon);        return result.ToWeatherModel(); // маппер из dto в нашу модель    }}


Декораторы


И в заключении о декораторах. Чтобы Story стали более гибкими необходимо cложить их в DI контейнер / mediator. И добавить возможность декорировать их вызов.

Сценарии:

1. Запускать Story внутри транзакции scoped контекста базы данных:
public class EntityController{    [HttpPost]    public EntityModel Update(EntityModel model)    {        return _mediator.Resolve<UpdateEntityStory>().WithTransaction().Execute(model);    }}// или[Transaction]public class UpdateEntityStory{    ...}

2. Кэшировать вызов
public class EntityController{    [HttpPost]    public ResultModel GetAccessRights()    {        return _mediator            .Resolve<GetAccessRightsStory>()            .WithCache("key", 60)            .Execute();    }}// или[Cache("key", 60)]public class GetAccessRightsStory{    ...}

3. Политика повторов
public class GetWeatherStory{    public WeatherModel Execute(double lat, double lon)    {        var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);        if (weather == null)        {             weather = _mediator                 .Resolve<GetWeatherFromExternalServiceStory>()                 .WithRetryAttempt(5)                 .Execute(lat, lon);             _mediator.Resolve<AddWeatherCommand>().Execute(weather);        }        return weather;    }}// или[RetryAttempt(5)]public class GetWeatherFromExternalServiceStory{    ...}

4. Распределенная блокировка
public class GetWeatherStory{    public WeatherModel Execute(double lat, double lon)    {        var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);        if (weather == null)        {             weather = _mediator                 .Resolve<GetWeatherFromExternalServiceStory>()                 .WithRetryAttempt(5).                 .Execute(lat, lon);             _mediator.Resolve<AddWeatherStory>()                 .WithDistributedLock(LockType.RedLock, "key", 60)                 .Execute(weather);        }        return weather;    }}// или[DistributedLock(LockType.RedLock, "key", 60)]public class AddWeatherStory{    ...}

И тому подобное.
Подробнее..

Перевод Погружение в CQRS

03.03.2021 10:13:29 | Автор: admin

Эта статья является конспектом материала Clarified CQRS.

Прежде чем начать разбираться с CQRS, нужно понять две основные движущие силы, стоящие за ним: сотрудничество и устаревание.

Сотрудничество относится к ситуациям, когда несколько участников используют или изменяют один и тот же набор данных независимо от того, действительно ли они намерены сотрудничать. Зачастую имеются правила, которые указывают какие пользователи могут выполнять модификации. И эти модификации могут быть приемлемыми в одном случае и неприемлемыми в другом. В качестве пользователя может выступать реальный человек или программное обеспечение.

Устаревание касается того факта, что в многопользовательских системах, как только данные были показаны пользователю, те же самые данные могут быть в дальнейшем изменены и стать устаревшими. Почти любая система, использующая кеширование, например, с целью улучшения производительности, работает с устаревшими данными. Это означает, что нельзя полностью доверять принятым решениям наших пользователей, поскольку они могли быть приняты на основе устаревших данных.

Стандартная многоуровневая архитектура не решает ни одну из этих проблем. Хотя размещение всех данных в одной БД может способствовать улучшению совместной работе, устаревание обычно усугубляется в такой архитектуре из-за использования кеширования в качестве средства повышения производительности.

Рис.1. Модель CQRSРис.1. Модель CQRS

Компоненты на рисунке с названием AC являются автономными системами. Позже будет описано, что делает их автономными во время обсуждения команд (Commands CQRS). Но для начала давайте разберемся с запросами (Queries CQRS)

Запросы (Queries)

Если данные, которые собираемся показывать пользователям, все равно устаревшие, нужно ли идти в основную БД и получать их из нее? Зачем преобразовывать эти структуры в доменные объекты, если нам нужны только данные, а не поведение?

Короче говоря, похоже на то, что мы делаем много ненужной работы, основываясь на предположение, что повторное использование уже написанного кода проще, чем решение текущей проблемы. Давайте попробуем другой подход.

Как насчет того, чтобы создать дополнительное хранилище данных, данные которого могут быть немного не синхронизированы с основной БД имеется в виду, что данные, которые показываются пользователям, все равно устарели, так почему бы не отразить их в самом хранилище данных?

Какая же будет структура такого хранилища данных? Как на счет по одной таблице для каждого представления? Данные формируются с помощью одного запроса SELECT * FROM MyViewTable и передаются пользователю на экран. Это было бы максимально просто. Можно при необходимости это обернуть тонким фасадом. В итоге данные для представления уже будут готовы и не нужно преобразовывать их во что-то другое (например, в доменные объекты).

Хранилище данных для запросов

В качестве хранилища данных для запросов можно использовать текущую БД, но это не единственный вариант. Нужно учесть, что схема запросов идентична модели представления. Нет никаких связей между различными моделями представления, поэтому и не нужны никакие связи между таблицами в БД для запросов. Это указывает на то, что использовать реляционную БД необязательно в данном случае.

Масштабирование запросов

Если в итоге запросы выполняются из отдельного хранилища данных, а не из главной БД, можно легко добавить больше экземпляров этих хранилищ. Тот же механизм, который обновляет один экземпляр, может использоваться для многих экземпляров.

Это дает дешевое горизонтальное масштабирование для запросов. Кроме того, поскольку выполняется меньше преобразований, задержка на запрос также снижается. Простой код это быстрый код.

Модификация данных

Поскольку наши пользователи принимают решение на основе устаревших данных, нам нужно быть осторожными с тем, какие вещи мы пропускаем.

Допустим, у нас есть представитель службы поддержки клиентов, который разговаривает по телефону с клиентом. Этот пользователь смотрит на данные клиента на экране и хочет их изменить (адрес, ФИО и др.). Однако пользователь не знает, что после отображения данных на экране произошло некое событие. Из отдела выставления счетов пришло уведомление, что этот же клиент не оплачивает свои счета они просроченные. На данном этапе пользователь отправляет данные для изменения. Стоит ли принимать эти изменения?

К сожалению, для наших пользователей, обычно изменения отклоняются. Пользователь должен обновить свой экран, чтобы получить актуальные данные, и набрать заново все предыдущие изменения, надеясь на то, что в этот раз не произойдет оптимистическая блокировка.

По мере того, как наши сущности становятся все больше за счет увеличения количества полей в них, мы также получаем больше акторов, работающих с теми же самыми сущностями. Вследствие этого повышается вероятность возникновения конфликтов параллелизма.

Чтобы предоставить правильный уровень детализации данных и избежать конфликтов используются команды (CQRS).

Команды (Commands)

Ключевым элементом CQRS является переосмысление дизайна пользовательского интерфейса, чтобы избежать конфликтов конкурентного доступа к данным. Использование пользовательского интерфейса, подобного Excel (как я понял, автор оригинала имел в виду сущности с большим количеством полей денормализованные данные), для изменения данных не подходит.

Можно бы даже подумать о том, чтобы разрешить пользователям отправлять новую команду еще до того, как они получат подтверждение предыдущей. Можно иметь небольшой виджет сбоку, показывающий пользователю ожидающие команды, проверяя их асинхронно. Когда получаем подтверждение от сервера, помечаем их соответствующим образом (выполнилась успешно или нет). Затем пользователь может щелкнуть на неудачную команду, чтобы увидеть причину сбоя.

Следует обратить внимание, что клиент отправляет команды серверу, но не публикует их. Публикация - это про события, которые констатируют факт, что что-то произошло, и издателя не заботит то, что подписчики делают с этим событием.

Команды и валидация

При обдумывании того, что может привести к сбою команды, одна из тем, которая возникает - это валидация. Валидация отличается от бизнес-правил тем, что она устанавливает контекстно-независимый факт о команде. Либо команда действительна, либо нет. С другой стороны, бизнес-правила зависят от контекста.

В раннем примере данные, представленные нашим представителем службы поддержки клиентов, были действительными. Их отклонение произошло только из-за того, что пришло событие биллинга, которое потребовало отклонения команды. Если бы это событие биллинга не произошло, данные были бы приняты. Это указывает на то, что даже, если команда валидная, все равно могут быть причины для ее отклонения.

Таким образом, даже если клиент выполняет проверку данных перед их отправкой, сервер все равно будет проверять все входящие команды, не доверяя клиенту.

Переосмысление UIs и команды с точки зрения валидации

Клиент может использовать хранилище запросов (Queries) для проверки команд. Например, перед отправкой команды, мы можем проверить, существует ли название улицы в хранилище запросов.

В этот момент мы можем переосмыслить UI и сделать автоматическое заполнение текстового поля для названия улицы, таким образом, гарантируя, что название улицы, которое мы передадим в команде, будет действительным. Но почему бы не пойти дальше, например, указывать ID улицы вместо ее названия. Пусть команда представляет улицу не в виде строки, а в виде ID (int, guid или др.).

Таким образом, на стороне сервера будет только одна причина, по которой такая команда не выполнится проблема согласованности. Кто-то может удалить эту улицу и хранилище запросов не успеет обновиться.

Причины сбоя валидных команд и что с этим делать

Итак, даже если клиент посылает полностью валидные команды, то сервер все равно может отклонить их. Часто это связано с тем, что другие акторы изменяют состояние, имеющие отношение к обработке этой команды.

В приведенном выше примере отклонение происходит только потому, что событие биллинга пришло первым. Но первый может быть за миллисекунду до команды. Что, если пользователь нажал кнопку на миллисекунду раньше? Должно ли это на самом деле изменить бизнес-результат?

Если событие биллинга пришло вторым, то определенные данные пользователя, например, статус, должны измениться. Также отправится уведомление на почту. В таком случае почему бы не сделать также, если уведомление биллинга пришло первым? И если уже есть настроенная модель уведомлений, действительно ли нужно возвращать ошибку?

Если мы решили не возвращать ошибки клиенту (который уже отправил нам валидную команду), возможно, все, что нужно сделать на клиенте при отправке команды, это уведомить пользователя: Спасибо, вы получите подтверждение по электронной почте в ближайшее время. Нам даже не нужен виджет пользовательского интерфейса, показывающий ожидающие команды.

Команды и автономность

Мы видим, что в такой модели, команды не нужно обрабатывать сразу их можно поставить в очередь. Это одна из вещей, которая делает обработку команд автономной с точки зрения времени выполнения нам не требуется постоянное соединение с клиентом.

Кроме того, не нужно обращаться к хранилищу запросов для обработки команды. Состояние, которое затрагивает команда, должно управляться автономным компонентом это часть смысла автономности.

В случае неудачной обработки сообщения из-за того, что база данных не работает, не является причиной, по которой нужно возвращать такие ошибки клиенту. Можно просто откатиться и попробовать еще раз. Когда база данных восстановится, все сообщения, ожидающие в очереди, будут успешно обработаны, и пользователи получат уведомления.

Поскольку через эту БД больше не проходят запросы для отображения информации, сама БД способна хранить больше данных в памяти, которые необходимы командам, повышая производительность.

Автономные компоненты

Хотя на рисунке 1 мы видим, что все команды передаются в один и тот же AC, мы могли бы обработать каждую команду другим AC, у каждой из которых своя очередь. Это дало бы нам представление о том, какая очередь является самой большой, что позволит легко увидеть, какая часть системы является узким местом. И можно будет добавить дополнительные узлы обработки очередей, чтобы масштабировать такие части системы, которые работают медленно.

Уровень обслуживания (service layer)

Наши объекты обработки команд в различных автономных компонентах фактически составляют наш уровень обслуживания. Причина, по которой не видно явно этот слой в CQRS, заключается в том, что его на самом деле не существует, по крайней мере, не как идентифицируемая логическая коллекция связанных объектов. И вот почему.

Смотря на уровень обслуживания с точки зрения команд мы видим, что объекты обрабатываются различными командами. Каждая команда не зависит от другой, так почему мы должны позволять объектам, которые их обрабатывают, зависеть друг от друга? Зависимости это вещи, которых следует избегать, если для них нет веской причины.

Сохранение независимости объектов обработки команд друг от друга позволит более легко создавать новые версии нашей системы, по одной команде за раз, без необходимости даже останавливать всю систему, учитывая, что новая версия обратно совместима с предыдущей.

Где доменная модель?

Хотя на рисунке 1 можно увидеть, что доменная модель располагается рядом с автономным компонентом обработки команд, на самом деле это просто деталь реализации. Нет ничего, что утверждало бы, что все команды должны обрабатываться одной и той же доменной моделью.

Еще одна вещь, которую следует понять это то, что доменная модель не использует запросы (CQRS). Итак, вопрос в том, зачем нужно иметь так много связей между сущностями в доменной модели?

Действительно ли нам нужна коллекция заказов для сущности клиент? В какой команде нам нужно перемещаться по этой коллекции? Нужно ли в самом деле для команды отношение один ко многим?

Любые операции агрегатов, которые вычисляются путем перебора дочерних сущностей, могут быть предварительно вычислены и сохранены как свойство родительской сущности. Следуя такому принципу для всех сущностей в нашей доменной модели приведет к тому, что изолированные сущности не потребуют ничего, кроме нескольких свойств для идентификации связанных с ними сущностей.

Таким образом, команды могут быть полностью обработаны одной сущностью корнем агрегата, который является границей согласованности.

Персистентность для обработки команд

Учитывая, что БД, используемая для обработки команд, не используется для запросов и что большинство команд содержат идентификаторы строк, на которые они будут влиять, действительно ли нужен столбец для каждого отдельного свойства объекта домена? Что, если просто сериализовать доменную сущность и поместить ее в один столбец, а другой столбец будет содержать идентификатор? Это звучит очень похоже на хранилище key-value. В таком случае действительно ли нужно объектно-реляционное преобразование данных? Также можно выделить дополнительные свойства для каждого фрагмента данных, которое требует обеспечения уникальности.

Автор оригинала не предлагает это делать во всех случаях, а просто пытается заставить читателя переосмыслить некоторые базовые предположения. То, как обрабатываются команды, является лишь деталью реализации CQRS.

Синхронизация хранилища запросов

После того, как автономный компонент обработки команды решил принять команду, изменяя данные своего хранилища по мере необходимости, он публикует событие, уведомляющее об этом подписчиков. Название события обычно идет в прошедшем времени:

MakeCustomerPerferredCommand CustomerHasBeenMadePerferredEvent

Публикация события выполняется транзакционно вместе с обработкой команды и изменениями в БД. Таким образом, любой сбой фиксации приведет к тому, что событие не будет отправлено.

Автономный компонент, который обрабатывает эти события и обновляет хранилище данных запросов, довольно прост. Он преобразует данные события в данные модели представления.

Ограниченный контекст

Хотя CQRS затрагивает многие части архитектуры ПО, он все еще не находится на вершине пищевой цепочки. Если используется CQRS, то в ограниченном контексте или бизнес-компоненте (SOA), который является частью предметной области. На события, публикуемые одним бизнес-компонентом, подписываются другие бизнес-компоненты, каждый из которых обновляет свои хранилища запросов и команд по мере необходимости.

Пользовательский интерфейс, находящийся в каждом бизнес-компоненте, может быть собран в одно приложение, которое предоставляет пользователям единое составное представление обо всех частях предметной области.

Вывод

CQRS предлагает подходящую архитектуру для многопользовательских приложений. Он учитывает такие факторы, как устаревание и изменчивость данных, и использует эти характеристики для создания более простых и масштабируемых конструкций.

Невозможно по-настоящему оценить преимущества CQRS, не рассматривая пользовательский интерфейс. Принимая во внимание проверку на стороне клиента, структуры команд могут быть скорректированы. Обдумывание порядка, в котором обрабатываются команды и события, может привести к использованию нотификаций, которые сделают возврат ошибок ненужным.

Хотя вследствии применения CQRS в проекте приведет к более удобному обслуживанию и повышению производительности, эта простота и масштабируемость требует понимание подробных бизнес-требований.

Подробнее..

Как дойти до CQRS, если у тебя PHP

17.03.2021 10:08:48 | Автор: admin

Привет, я Сережа из Брянска, и в моем городе и до пандемии почти не было конференций и митапов. Поэтому я привык смотреть записи докладов из разных сообществ. Но как задать их авторам вопрос? Так что я завел подкаст и зову в него интересных докладчиков на поговорить.

Недавно я посмотрел доклад Как перестать бояться CQRS. Вроде бы простая идея, но есть нюансы. Так и появился этот выпуск.

CQRS vs CQS (не перепутай)

Аудио

Вы можете включить аудиоверсию в ней больше технических деталей.

Сергей Жук, 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 не нужен.

Другие выпуски подкаста Между скобок.

Подробнее..

Паттерн CQRS теория и практика в рамках ASP.Net Core 5

24.02.2021 00:19:22 | Автор: admin

Скорость разработки и производительность программистов могут отличаться в зависимости от их уровня и используемых в проектах технологий. Для проектирования ПО нет станд

артов и ГОСТов, только вы выбираете, как будете разрабатывать свою программу. Один из лучших способов повысить эффективность работы применить шаблон проектирования CQRS.

Существует три вида паттерна CQRS: Regular, Progressive и Deluxe. В этой статье я расскажу о первом классическом паттерне Regular CQRS, который мы используем в DD Planet в рамках разработки онлайн-сервиса Выберу.ру. Progressive и Deluxe более сложные архитектуры и влекут за собой использование обширного набора абстракций.

Я поделюсь опытом своей команды: как мы применили паттерн CQRS в бизнес-приложениях и беспроблемно внедрили его в существующие проекты, не переписывая тысячи строк кода.

Классический Onion

Чтобы было понятно, для чего нужен паттерн CQRS, сначала рассмотрим, как выглядит классическая архитектура приложения.

Классическая луковая архитектура состоит из нескольких слоев:

  1. Доменный слой наши сущности и классы.

  2. Слой бизнес-логики, где происходит вся обработка доменной логики.

  3. Слой приложения логика самого приложения.

  4. Внешние слои: слой UI, базы данных или тестов.

Это идеальная архитектура, которая существует множество лет, но она не создает ограничений для связанности компонентов.

Так произошло с нашим сайтом Выберу.ру. Мы получили спагетти-код, в котором связанность была на очень высоком уровне. Новые разработчики приходили в шок, когда его видели. Самое страшное, что могло случиться введение нового сотрудника в приложение. Объяснить, что и почему, казалось просто невозможным.

И мы подошли к моменту, когда перед нами встала важная задача устранить этот недостаток инкапсулировать доменную логику в одном месте, уменьшить связанность и улучшить связность. Мы начали искать новые паттерны проектирования и остановились на CQRS.

CQRS

Определение и задачи

CQRS (Command Query Responsibility Segregation) это шаблон проектирования, который разделяет операции на две категории:

  • команды изменяют состояние системы;

  • запросы не изменяют состояние, только получают данные.

Подобное разделение может быть логическим и основываться на разных уровнях. Кроме того, оно может быть физическим и включать разные звенья (tiers), или уровни.

Обратите внимание, что это не паттерн кодирования, это паттерн проектирования. В разных компаниях этот паттерн используют по-разному, мы используем его в нашей команде Выберу.ру, чтобы решить нескольких задач:

  • повысить скорость разработки нового функционала без ущерба для существующего;

  • снизить время подключения нового работника к проекту;

  • уменьшить количество багов;

  • упростить написание тестов;

  • повысить качество планирования разработки.

Благодаря CQRS мы получаем архитектуру, в которой все аккуратно разложено и понятно (меньше связанность, больше связности), человек может открыть код команды или запроса, увидеть все его зависимости, понять, что он делает, и продолжать работать над ним в рамках только этой команды/запроса, без копания в других частях программы.

Практика

Хочу поделиться, как мы используем шаблон CQRS на практике, и наглядно показать его плюсы.

Мы используем ASP.NET Core 5.0, поэтому примеры реализации паттерна будут в контексте этого фреймворка.

Помимо встроенных механизмов ASP.NET Core 5.0, нам понадобятся еще две библиотеки:

  • MediatR небольшая библиотека, помогающая реализовать паттерн Mediator, который нам позволит производить обмен сообщениями между контроллером и запросами/командами без зависимостей.

  • FluentValidation небольшая библиотека валидации для .NET, которая использует Fluent-интерфейс и лямбда-выражения для построения правил валидации.

Реализация REST API с помощью CQRS

Наши команды и запросы очень хорошо ложатся на REST API:

  • get это всегда запросы;

  • post, put, delete команды.

Добавление и настройка MediatR:

Чтобы добавить библиотеку в наш проект, выполним в консоли команду:

dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Далее зарегистрируем все компоненты нашей библиотеки в методе ConfigureServices класса Startup:

namespace CQRS.Sample{   public class Startup   {       ...       public void ConfigureServices(IServiceCollection services)       {           ...           services.AddMediatR(Assembly.GetExecutingAssembly());           services.AddControllers();           ...       }   }}

После мы напишем первую команду, пусть это будет команда добавления нового продукта в нашу базу данных. Сначала реализуем интерфейс команды, отнаследовавшись от встроенного в MediatR интерфейса IRequest<TResponse>, в нем мы опишем параметры команды и что она будет возвращать.

namespace CQRS.Sample.Features{   public class AddProductCommand : IRequest<Product>   {       /// <summary>       ///     Алиас продукта       /// </summary>       public string Alias { get; set; }        /// <summary>       ///     Название продукта       /// </summary>       public string Name { get; set; }        /// <summary>       ///     Тип продукта       /// </summary>       public ProductType Type { get; set; }   }}

Далее нам нужно реализовать обработчик нашей команды с помощью IRequestHandler<TCommand, TResponse>.

В конструкторе обработчика мы объявляем все зависимости, которые нужны нашей команде, и пишем бизнес-логику, в этом случае сохранение сущности в БД.

namespace CQRS.Sample.Features{   public class AddProductCommand : IRequest<Product>   {       /// <summary>       ///     Алиас продукта       /// </summary>       public string Alias { get; set; }        /// <summary>       ///     Название продукта       /// </summary>       public string Name { get; set; }        /// <summary>       ///     Тип продукта       /// </summary>       public ProductType Type { get; set; }        public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>       {           private readonly IProductsRepository _productsRepository;            public AddProductCommandHandler(IProductsRepository productsRepository)           {               _productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));           }            public async Task<Product> Handle(AddProductCommand command, CancellationToken cancellationToken)           {               Product product = new Product();               product.Alias = command.Alias;               product.Name = command.Name;               product.Type = command.Type;                await _productsRepository.Add(product);               return product;           }       }   }}

Чтобы вызвать исполнение нашей команды, мы реализуем Action в нужном контроллере, пробросив интерфейс IMediator как зависимость. В качестве параметров экшена мы передаем нашу команду, чтобы механизм привязки ASP.Net Core смог привязать тело запроса к нашей команде. Теперь достаточно отправить команду через MediatR и вызвать обработчик нашей команды.

namespace CQRS.Sample.Controllers{   [Route("api/v{version:apiVersion}/[controller]")]   [ApiController]   public class ProductsController : ControllerBase   {       private readonly ILogger<ProductsController> _logger;       private readonly IMediator _mediator;        public ProductsController(IMediator mediator)       {           _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));       }              ...        /// <summary>       ///     Создание продукта       /// </summary>       /// <param name="client"></param>       /// <param name="apiVersion"></param>       /// <param name="token"></param>       /// <returns></returns>       [HttpPost]       [ProducesResponseType(typeof(Product), StatusCodes.Status201Created)]       [ProducesDefaultResponseType]       public async Task<IActionResult> Post([FromBody] AddProductCommand client, ApiVersion apiVersion,           CancellationToken token)       {           Product entity = await _mediator.Send(client, token);           return CreatedAtAction(nameof(Get), new {id = entity.Id, version = apiVersion.ToString()}, entity);       }   }}

Благодаря возможностям MediatR мы можем делать самые разные декораторы команд/запросов, которые будут выполняться по принципу конвейера, по сути, тот же принцип реализуют Middlewares в ASP.Net Core при обработке запроса. Например, мы можем сделать более сложную валидацию для команд или добавить логирование выполнения команд.

Нам удалось упростить написание валидации команд с помощью FluentValidation.

Добавим FluentValidation в наш проект:

dotnet add package FluentValidation.AspNetCore

Создадим Pipeline для валидации:

namespace CQRS.Sample.Behaviours{   public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>       where TRequest : IRequest<TResponse>   {       private readonly ILogger<ValidationBehaviour<TRequest, TResponse>> _logger;       private readonly IEnumerable<IValidator<TRequest>> _validators;        public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators,           ILogger<ValidationBehaviour<TRequest, TResponse>> logger)       {           _validators = validators;           _logger = logger;       }        public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,           RequestHandlerDelegate<TResponse> next)       {           if (_validators.Any())           {               string typeName = request.GetGenericTypeName();                _logger.LogInformation("----- Validating command {CommandType}", typeName);                 ValidationContext<TRequest> context = new ValidationContext<TRequest>(request);               ValidationResult[] validationResults =                   await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));               List<ValidationFailure> failures = validationResults.SelectMany(result => result.Errors)                   .Where(error => error != null).ToList();               if (failures.Any())               {                   _logger.LogWarning(                       "Validation errors - {CommandType} - Command: {@Command} - Errors: {@ValidationErrors}",                       typeName, request, failures);                    throw new CQRSSampleDomainException(                       $"Command Validation Errors for type {typeof(TRequest).Name}",                       new ValidationException("Validation exception", failures));               }           }            return await next();       }   }}

И зарегистрируем его с помощью DI, добавим инициализацию всех валидаторов для FluentValidation.

namespace CQRS.Sample{   public class Startup   {       ...       public void ConfigureServices(IServiceCollection services)       {           ...           services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));           services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());           ...       }   }}

Теперь напишем наш валидатор.

public class AddProductCommandValidator : AbstractValidator<AddProductCommand>{   public AddProductCommandValidator()   {       RuleFor(c => c.Name).NotEmpty();       RuleFor(c => c.Alias).NotEmpty();   }}

Благодаря возможностям C#, FluentValidation и MediatR нам удалось инкапсулировать логику нашей команды/запроса в рамках одного класса.

namespace CQRS.Sample.Features{   public class AddProductCommand : IRequest<Product>   {       /// <summary>       ///     Алиас продукта       /// </summary>       public string Alias { get; set; }        /// <summary>       ///     Название продукта       /// </summary>       public string Name { get; set; }        /// <summary>       ///     Тип продукта       /// </summary>       public ProductType Type { get; set; }        public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>       {           private readonly IProductsRepository _productsRepository;            public AddProductCommandHandler(IProductsRepository productsRepository)           {               _productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));           }            public async Task<Product> Handle(AddProductCommand command, CancellationToken cancellationToken)           {               Product product = new Product();               product.Alias = command.Alias;               product.Name = command.Name;               product.Type = command.Type;                await _productsRepository.Add(product);               return product;           }       }        public class AddProductCommandValidator : AbstractValidator<AddProductCommand>       {           public AddProductCommandValidator()           {               RuleFor(c => c.Name).NotEmpty();               RuleFor(c => c.Alias).NotEmpty();           }       }   }}

Это сильно упростило работу с API и решило все основные задачи.

На выходе получился красивый инкапсулированный код, понятный всем сотрудникам. Так, мы можем быстро ввести человека в процесс разработки, сократить затраты и время на его реализацию.

Текущие результаты можно посмотреть на GitHub.

Подробнее..

CQRS что делать с кодом, который нужно использовать сразу в нескольких обработчиках?

18.03.2021 16:05:16 | Автор: admin


При использовании архитектуры в стиле вертикальных слайсов, рано или поздно встает вопрос а что делать, если появляется код, который нужно использовать сразу в нескольких хендлерах?


TLDR: нужно создать промежуточный слой обработчиков и добавить специализированные маркерные интерфейсы, чтоб было ясно, какие обработчики холистические абстракции, а какие нет.

Ответ на этот вопрос не всегда очевиден. Джимми Боггард, например предлагает просто использовать приемы рефакторинга. Я всецело поддерживаю такой подход, однако форма ответа видится мне такой-же полезной, как и предложение воспользоваться свободной монадой для внедрения зависимостей в функциональном программировании. Такая рекомендация точна и коротка, но не слишком полезна. Я попробую ответить на этот вопрос более развернуто.


Рефакторинг


Итак, я буду пользоваться двумя приемами рефакторинга:


  1. Извлечь метод
  2. Извлечь класс

Допустим, код обработчика выглядит следующим образом:


public IEnumerable<SomeDto> Handle(SomeQuery q){    // 100 строчка кода,    // которые потребуются в нескольких обработчиках    // 50 строчек кода, которые специфичны именно    // для этого обработчика    return result;}

В реальности, бывает и так, что первые 100 и вторые 50 строчек перемешаны. В этом случае, сначала придется их размотать. Чтобы код не запутывался, заведите привычку жамкать на ctrl+shift+r -> extract method прямо по ходу разработки. Длинные методы это фу.

Итак, извлечем два метода, чтобы получилось что-то вроде:


public IEnumerable<SomeDto> Handle(SomeQuery q){    var shared = GetShared(q);    var result = GetResult(shared);    return result;}

Композиция или наследование?


Что же выбрать дальше: композицию или наследование? Композицию. Дело в том, что по мере разрастания логики код может приобрести следующую форму:


public IEnumerable<SomeDto> Handle(SomeQuery q){    var shared1 = GetShared1(q);    var shared2 = GetShared2(q);    var shared3 = GetShared3(q);    var shared4 = GetShared4(q);    var result = GetResult(shared1,shared2, shared3, shared4);    return result;}

В особо сложных случаях структура зависимостей может оказаться весьма разветвленной и тогда вы рискуете нарваться на проблему множественного наследования.

Так что, гораздо безопаснее воспользоваться внедрением зависимостей и паттерном компоновщик.


public class ConcreteQueryHandler:     IQueryHandler<SomeQuery, IEnumerable<SomeDto>>{    ??? _sharedHandler;    public ConcreteQueryHandler(??? sharedHandler)    {        _sharedHandler = sharedHandler;    }}

Тип промежуточных хендлеров



В слоеной/луковой/чистой/порты-адаптершной архитектурах такая логика обычно находится в слое сервисов предметной области (Domain Services).


У нас вместо слоев будут соответствующие вертикальные разрезы и специализированный интерфейс IDomainHandler<TIn, TOut>, наследуемый от IHandler<TIn, TOut>.


Некоторые предпочитают используют вертикальные слайсы на уровне запроса и сервисы на уровне домена. Хотя подход и жизнеспособен, он подвержен тем же недостаткам, что и слоеная архитектура в первую очередь, риском повышения связности приложения. Поэтому мне больше нравится использовать вертикальную компоновку и на уровне домена.


public class ConcreteQueryHandler2:    IQueryHandler<SomeQuery,  IEnumerable<SomeDto>>{    IDomainHandler<???, ???> _sharedHandler;    public ConcreteQueryHandlerI(IDomainHandler<???, ???> sharedHandler)    {        _sharedHandler = sharedHandler;    }}public class ConcreteQueryHandler2:    IQueryHandler<SomeQuery,  IEnumerable<SomeDto>>{    IDomainHandler<???, ???> _sharedHandler;    public ConcreteQueryHandlerI(IDomainHandler<???, ???> sharedHandler)    {        _sharedHandler = sharedHandler;    }}

Зачем нужны специализированные маркерные интерфейсы?


Возможно, у вас появится соблазн использовать IHandler во всех случаях. Я не рекомендую так поступать, потому что такой подход почти наверняка приведет либо к проблемам с производительностью, либо к проблемам с сильной связностью в долгосрочном сценарии.



Интересно, что несколько лет назад мне попадалась статья, предостерегающая от использования одного интерфейса на все случаи жизни. Тогда я решил ее проигнорировать, потому что я сам умный и мне виднее. Вам решать, следовать моему совету или проверять его на практике.


Тип промежуточных хендлеров


Осталось чуть-чуть: решить, какой тип будет у IDomainHandler<???, ???>. Этот вопрос можно разделить на два:


  1. Стоит ли мне передавать ICommand/IQuery в качестве входного параметра?
  2. Стоит ли мне использоватьIQueryable<T> в качестве возвращаемого значения?

Стоит ли мне передавать ICommand/IQuery в качестве входного параметра?


Не стоит, если ваши интерфейсы определены как:


public interface ICommand<TResult>{}public interface IQuery<TResult>{}

В зависимости от типа возвращаемого значения IDomainHandler вам может потребоваться добавлять дополнительные интерфейсы на Command/Query, что не улучшает читабельность и увеличивает связность кода.


Стоит ли мне использоватьIQueryable<T> в качестве возвращаемого значения?


Не стоит, если у вас нет ORM:) А вот, если он есть Не смотря на явные проблемы LINQ с LSP я думаю, что ответ на этот вопрос зависит. Бывают случаи, когда условия получения данных настолько запутаны и сложны, что одними спецификациями выразить их не получается. В этом случае передача IQueryable во внутренних слоях приложения меньшее из зол.


Итого


  1. Выделяем метод
  2. Выделяем класс
  3. Используем специализированные интерфейсы
  4. Внедряем зависимость слоя предметной области в качестве аргументов конструктора

public class ConcreteQueryHandler:    IQueryHandler<SomeQuery,  IEnumerable<SomeDto>>{    IDomainHandler<        SomeValueObjectAsParam,        IQueryable<SomeDto>>_sharedHandler;    public ConcreteQueryHandler(        IDomainHandler<            SomeValueObjectAsParam,            IQueryable<SomeDto>>)    {        _sharedHandler = sharedHandler;    }    public IEnumerable<SomeDto> Handle(SomeQuery q)    {        var prm = new SomeValueObjectAsParam(q.Param1, q.Param2);        var shared = _sharedHandler.Handle(prm);        var result = shared          .Where(x => x.IsRightForThisUseCase)          .ProjectToType<SomeDto>()          .ToList();        return result;    }}
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru