Як, для чого і де використовувати CQRS, Event Sourcing та DDD
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.
Вітаю! Мене звуть Глазунов Павло. Зараз я працюю в компанії Sitecore Ukraine на посаді Developer, маю загальний досвід в ІТ 7+ років і в основному пишу на C# та трішки фронт на TypeScript і Angular. У статті я хочу поділитися своїм досвідом роботи з CQRS/Event Sourcing/DDD, оскільки доводилось бачити як вдале застосування цих підходів, так і не дуже. Стаття націлена на розробників рівня middle і вище, які або чули щось раніше або взагалі не знайомі з цією темою і хочуть спробувати свої сили в DDD і CQRS, але не знають, з чого почати. Стаття має оглядовий характер і не містить технічних деталей.
Domain Driven Design
Domain Driven Design або проблемно-орієнтоване проектування — це підхід, запропонований Еріком Евансом у своїй книзі, присвяченій DDD, при якому ми тримаємо фокус на вивченні доментої області. Тобто це підхід, орієнтований на потреби клієнта/бізнеса і на ефективне вирішення потреб бізнеса саме через розуміння суті проблеми. Такі речі як архітектура, інфраструктура і будь-які новітні технології важливі, але не першочергові. Код завжди має приносити користь, а не просто бути крутим з точки зору архітектури і т. д. Якщо цей підхід орієнтований на потреби бізнеса, то логічно буде говорити на мові цього бізнесу. Саме для цього всі учасники в процесі роботи використовують єдину мову домену або Ubiquitous language. Це робиться для того, щоб всі розуміли один одного і говорили про одні і ті ж речі, тобто використання єдиної мови виключає хибне трактування термінів і неоднозначность. Іншою важливою фішкою DDD є обмежені контексти або Bounded Context, в межах яких ця єдина мова і застосовується. Контекст — це спосіб розділення сутностей бізнес логіки на повністю незалежні і автономні юніти. Обмежені контексти як слабка зв’язність, але на більш високому рівні. Першим етапом як раз і буде побудова Context Map і формування єдиної мови. Насправді єдина мова існує лише в рамках певного контексту, тому формування єдиної мови тісно пов’язане з побудовою контекстів і їх зв’язків. Після завершення даного етапу можна обирати технологічний стек і на основі виділених контекстів формувати мікросервіси Хоч питання архітектури не є першочерговим, але воно теж має значення. І коли доходить справа до її вибору, то найпростішою і найпоширенішою буде n-layer (насправді, я бачив проекти в яких DDD був представлений лише цією архітектурою. Ніякого обмеженого контексту чи агрегатів). У більш просунутому варіанті ця архітектура переростає у гексагональну. На додачу до інверсії залежностей через розшарування отримуємо Порти і Адаптери для взаємодії компонентів в середині системи і зовні. Спускаємося на рівень нижче і наступною необхідною складовою будуть шаблони (як і скрізь шаблони вирішують все): Aggregate, Entity, Value Object, Service, Repository, Event. Я перерахував найбільш вживані шаблони і зараз їх коротко опишу. Repository — інкапсулює доступ до даних і реалізує пошук, також є обгорткою доступу до БД. Entity — об’єкти, які мають унікальний ідентифікатор, бо їхній життєвий цикл має значення для бізнес логіки. По цьому ідентифікатору можна знайти сутність і порівняти з іншими, якщо ідентифікатори однакові, то вони визначають одну сутність. Інакше кажучи набір атрибутів не визначають сутність. Value Object — прості об’єкти, набір атрибутів навпаки визначає унікальність цього об’єкту в системі. Цей об’єкт є незмінним протягом всього життєвого циклу, простіше кажучи він immutable. Aggregate — це сукупність доменних об’єктів, які є одним цілим. Агрегат має корінь через який здійснюється доступ до об’єктів в середині агрегату. Агрегат інкапсулює певний стан, логіку валідації цього стану і поведінку. Service — реалізує певну бізнес логіку, якій не місце в агрегаті/сутності. Агрегати можуть лише містити ключі на інші агрегати і не можуть мати посилань на об’єкти з іншого агрегату, що є золотим правилом DDD. За допомогою контекстів також відбувається розподіл коду на модулі/компоненти/мікросервіси і т. д. Якщо комусь здається, що DDD це складно, то так і є. Часто про цю складністю забувають і додають ще більше складності в архітектуру свого продукту. Повертаючись до прикладу про реалізації шаруватої архітектури, також хочу додати, що в тому проекті кожен шар був повністю ізольований один від одного настільки, що навіть нумератори дублювались на кожному шарі і мапились один в одного. Тому потрібно тримати баланс у розділенню сервісу на рівні (шари), бо вони є логічним розділенням яке повинно не заплутувати, а полегшувати розуміння бізнес-процесів. Те саме стосується контекстів і розподілу на сервіси, тому що ці два процеси часто суперечать один одному. Однією з перевірок запаху коду (архітектури) може бути перевірка автономності сервісу, якщо сервіс не може самостійно обробити прямий запит, то це означає що він не є самостійним і скоріше за все має спільний контекст з іншим сервісом, що в свою чергу означає його несамостійність. Зазвичай говорять, що підхід DDD доцільно застосовувати до великих мікросервісних систем, а не монолітів. І найчастіше великими називають додатки з кількістю мікросервісів від 20 і більше, але я б додав, що доцільно використовувати цей підхід в ентерпрайзі. Проте, що таке ентерпрайз? Мені подобається таке визначення цього терміну: система, яка працює з даними для вирішення потреб бізнесу і при цьому цінність цих самих даних в рази вища за цінність продукту. Переваги від використання DDD лежать на поверхні:
- Чиста архітектура
- Бізнес-орієнтований продукт
Але і недоліки теж досить суттєві:
- Ускладнюється кодова база
- Важко працювати з традиційними методами розробки ПО
- Важко масштабувати без додаткових рішень (як при будь-якому традиційному підході)
- Як і в будь-якій традиційній моделі присутня втрата даних, а саме відсутність проміжкових станів, що вимагає додаткових рішень, наприклад, для аналітики
- Транзакції гальмують систему, бо для їх цілісності необхідні блокування, які тимчасово уповільнюють роботу з даними. І чим більше транзакцій в системі, то тим більше буде проблем зі швидкістю
Event Sourcing — історична модель мислення
Багато хто б поставив цей розділ в кінець статті, але я по прикладу Грега Янга вирішив піти в правильному напрямку, бо використовувати CQRS без Event Sourcing однозначно принесе користь, але робити навпаки немає жодного сенсу і це є проблема. Ви можете використовувати CQRS самостійно і бути повністю щасливими, але Event Sourcing без CQRS буде ефективним поки в системі невеликий обсяг даних. Тому про це поговоримо далі. Цей підхід полягає в тому, щоб записувати історію всіх подій, які призвели систему в поточний стан в точній послідовності. Проте ES не просто аудит лог з тимчасовими даними про бізнес транзакції в системі, а послідовна історія зміни агрегатів, бо недарма підхід називаеться Event Sourcing. Ваші івенти (їх також часто називають комітами) — це єдине джерело правди. Ідея підходу чимось нагадує систему контролю версій, де ми в EventStore зберігаємо в точній послідовності всі дельти стану нашого агрегату.
На перший погляд звучить дивно, але насправді більшість серйозних систем, які нас оточують і з якими ми взаємодіємо, не використовують концепт current state або фінальний стан, а саме: фінансові, банківські і багато інших. Як казав Грег Янг в одному зі своїх виступів, що ваш рахунок в банку — це не просто колонка в табличці, а сума всіх транзакцій, які були здійснені з вашим рахунком (поповнення, різні списання і відрахування). Наприклад, якщо у вас є розбіжність з вашим банком щодо балансу рахунку і банк стверджує що на рахунку 103 долари, але ваша позиція що має бути 207. Ви ніколи не почуєте у відповідь «колонка стверджує що там 103 долари і це все, що ви маєте». Особливості ES:
- Усі події в системі immutable. Це обмеження в основному пов’язане з тим що доменні події зберігаються в документо-орієнтованій БД і тому їх треба якось серіалізувати/десеріалізувати, але це залежить від реалізації серіалізатора. Деякі ES рішення підтримують версіонування і тому залишається лише обмеження не видаляти поля івентів.
- Доменні події можна лише додавати, тобто вони append only. Така особливість більше пов’язана просто зі здоровим глуздом, бо видаляти івенти з івент стора більше схоже на постріли собі в ногу або стромлянням палок собі в колеса.
- Можна реєструвати стільки, скільки завгодно івентів. Оскільки івенти не бажано видаляти, то для таких випадків можна створювати компенсуючі івенти, які будуть новими версіями вже існуючих івентів, але які мають додаткові поля. Також гарною практикою буде створювати нові івенти для виняткових подій, наприклад, в адресі клієнта необхідно виправити помилку або адреса змінилась в зв’язку з переїздом і це дві різні події, а не просто CustomerAddressChanged.
- Ім’я кожної події має певну семантику, яка говорить про те, що відбулося в системі. Івенти завжди говорять про минуле і вони реально мають розповідати своїм ім’ям що саме змінилось і як саме (наприклад, OrderCanceled, OrderItemAdded, ProductRemoved, OrderPlaced). Правильні імена об’єктів подій мають допомогти з пошуком проблем і дебагом (поганим прикладом є Event1, Ordered, Created).
Нероздільною частиною ES є поняття snapshot — проміжкові зрізи (знімки) стану агрегату в EventStore. Часом для того, щоб отримати фінальний стан об’єкту, треба скласти дуже велику кількість івентів, починаючи від найпершого. Тому з ціллю оптимізації робиться снепшот, і ми будемо відновлювати фінальний стан не від найпершого івенту в системі, а від останнього снепшоту. Попри очевидну користь від цього патерну його слід застосовувати лише тоді, коли на отримання фінального стану витрачається занадто багато часу. Багатьом може здатися очевидним мінусом те що об’єм даних при такому підході росте експоненціально, але це не так. Як видно на попередньому зображенні, то в івентах зберігаються не повні моделі даних, а лише зміна стану. Насправді, зниження швидкості роботи такої системи пов’язане з побудовою агрегатів з усіх доменних подій, які відбулись до тепер. Саме тому ES без CQRS’а не такий корисний і більше буде нагадувати «костиль». Отже, ES — це зберігання серії івентів і схема даних, яка відображає ці івенти є першою похідною від цих івентів. Схема даних в ES-системах є тимчасовою і може бути в будь-який час перебудована або відновлена з івентів. Чим не машина часу?! User cases не змінюються, а поведінка — постійно.
CQS або розділяй і пануй
Command Query Separation був вперше описаний Бертраном Мейєром. Суть підходу полягає в розділені методів на два різних потоки: команд і запитів. По своїй суті шаблон є антипатерном до стеку і черги, бо в ньому запити ніколи не модифікують стан системи і лише команди модифікують стан, але нічого не повертають (умовно нічого). CQS застосовується на рівні компонентів, на відміну від CQRS, який є еволюцією CQS. Переваги такого підходу, як і недоліки, лежать на поверхні:
- зростає кодова база від розділення методів
- entity стають зрозумілими, код отримує достатній рівень поділу відношень (SoC)
- зменшується кількість побічних ефектів і це мотивує більше дотримуватися принципів SOLID
- код стає легше тестувати, бо сам підхід стимулює писати більше тестів
Я не дарма згадав про цей підхід, бо він не тільки ліг в основу CQRS і їх часто плутають, але й тому що він чудово комбінується з DDD. Якщо точніше, то можна розділяти методи агрегатів на команди і запити, що неодмінно збільшить користь від використання DDD, бо це вважається гарною практикою коли метод або виконує команду (command/mutation/modifier) або запит в один момент часу, а не одночасно. Щодо того, що команда ніколи не повертає даних, то це не зовсім так, бо допускається повертати результат операції або ідентифікатор об’єкта, наприклад.
CQRS — продовжуємо розділяти
Command Query Responsibility Segregation сформульований Грегом Янгом і Джонатаном Олівером на основі CQS і полягає в розділенні операцій читання і запису в обмежених контекстах. Однією з причин появи CQRS це нерівномірний розподіл навантаження на підсистеми читання і запису. Цей нерівномірний розподіл у свою чергу від того, що бізнес-правила або бізнес-логіка зосереджені на етапі запису інформації і одночасно з цим запити на багато частіше використовуються, ніж команди. Коли я тільки почав знайомитися з цим шаблоном, то мені трапилась стаття про те, що CQRS має певні типи і він не просто чорне і біле. Спочатку ця думка була незрозумілою мені, але з часом, коли я познайомився ще з кількома проектами, в яких використаний CQRS, то я чітко зрозумів правидівість тієї статті. Для першого типу необхідно розділити ієрархію класів, тобто для команд і запитів в нас будуть окремі класи, які будуть повними або частковими копіями. Для команд буде клас в форматі доменного об’єкту, а для запитів DTO. Таке дублювання насправді дасть змогу використовувати лише необхідні поля для команди/запиту, що дасть певну мінімізацію даних і відповідно оптимізацію. Також цей розподіл дасть гнучкість у використанні класів і при подальшій оптимізації запитів. Для другого типу необхідно розділити API (застосувати CQS на всіх шарах). Від контролеру до репозиторію наші класи будуть мати окремі методи для читання даних і їх модифікації, які будуть оперувати окремими моделями даних з першого типу. Як і з попереднім типом отримуємо чергову порцію оверхеда, але більше ніж на першому кроці, та ще більше можливостей для оптимізації запитів (різноманітні кеші, масштабувати АПІ окремо один від одного і тд). Слід зазначити, що CQS розділить обов’язки репозиторію на 2 окремі класи: command handler i query handler. Третій тип дасть ще більше оверхеду і оптимізації операцій читання за рахунок розділення БД і свободи вибору різних постачальників БД. Якщо бази різних типів, то треба різні адаптери, що додає складності в кодову базу. На перевагу цьому можна різні бази маштабувати як завгодно і оптимізувати схеми даних. Проте найбільшою проблемою даного типу CQRS буде синхронізація даних в репліках відома як eventually consistent, бо дані в базах для читання і для запису мають відповідати один одному. Можна використовувати кожен тип по мірі необхідності і потребах проекту. Тим самим можна плавно мігрувати на подібну архітектуру без великих поломок і простоїв системи через оновлення. Зараз хочу підвести невеликий підсумок: кілер-фітчею даного підходу є надзвичайно швидкий і оптимізований read-side, який заточений під UI і не тільки, де не потрібно писати складні запити з join-ами або робити на рівні БД views, бо read база оптимізована під читання (як показує мій досвід не дуже приємно, коли в проекті схеми баз однакові і є потреба робити join-йни в MongoDb). Але треба буде змиритися з великим оверхедом і підтримувати eventually consistent баз запису і читання. Насправді, баз для читання може бути дуже багато (під кожен мікросервіс по кілька), вони можуть бути різні (sql, nosql, full-text search), можуть відображати різні схеми даних і різні потреби: база для звітів, база для графіків, база агрегатів і т. д.
CQRS + ES
Як я писав раніше ES не тільки неефективний без CQRS, але і неможливий, бо виглядає як «костиль», тому хочу представити повну картину DDD з CQRS + ES. Бо як наголошував Грег Янг, єдина причина використовувати Event Sourcing — це використання CQRS, але не навпаки. Як видно зі спрощеної схеми, то EventStore як раз і проводить ту лінію Responsibility Segregation. Зараз я більш детально опишу, як це працює. Усе починається з того, що в систему приходить команда, наприклад, додати товар до замовлення. Відповідний команд хендлер перехоплює цю команду і піднімає з івент стора агрегат замовлення (зі сторони коду це виглядає як і в традиційному підході — метод getById). Потім виконуємо відповідний метод на агрегаті (order.AddProductItem) і виконуємо метод Save, щоб зберегти сам агрегат. Тим часом агрегат робить валідацію вхідних даних і свого стану, оновлює свій стан і стріляє івент (ProductItemAdded), який потім записується в БД для запису (насправді доменні події відправляються в брокер повідомлень і вже потім з брокера потрапляють в проекцію). Далі цей івент перехоплює проекція (пласке відображення стейту), оновлює БД для читання і збільшує свій внутрішній лічильник. Проекції так називаються через аналогію з геометрією, де тінь об’ємної фігури на папері є проекцією. Проекція повністю обробляє модель для читання, тобто оновлює дані і дозволяє читати дані (містить методи для читання і методи обробки подій). Проте це не є суворою умовою і можна проекцію розділити на два окремі класи для по принципу CQRS і отримаємо EventHandler і QueryHandler (це вже питання уподобань і зручності). Іншою особливістю проекцій є те, що їх може бути скільки завгодно і вони можуть обробляти які-завгодно події, таким чином можна створити різні моделі даних під різні потреби. Оскільки наш справжній стейт зберігається в EventStore, то дані в read базах можна в будь-який момент видалити і знову відновити дані по івентах і цей процес називається ребілдом проекцій. З власного досвіду скажу, що подібний функціонал обов’язковий до реалізації з наступних причин:
- Користувацькі сценарії рідко змінюються на відміну від поведінки системи, тобто агрегати мають бути максимально продуманими і доменні події також, бо вони відображають юзер кейси. На відміду від проекцій які відображають поведінку і можуть часто змінюватися або десь може бути допущена помилка, яка легко виправляється і робиться ребілд проекції.
- Історичні дані, які є в write базі, як раз і дозволяють змінювати поведінку в проекціях за першою вимогою та додавати нові проекції на основі існуючих історичних даних.
Принцип роботи і схема CQRS з ES не така вже і складна. Особливо в порівнянні з деякими ситуаціями, які потрібно правильно обробляти. Перша найпоширеніша задача це валідація пошти користувача (чи є такий вже в системі) і для вирішення цієї задачі є кілька підходів:
- Агрегат для валідації користується проекцією (read базою), щоб дізнатися чи є така поштова адреса в системі, якщо є то кидає exception і на цьому все. Це один з правильних варіантів.
- За допомогою компенсуючого івенту. Але є один нюанс. Багато людей переконані що мішати підсистему для читання і запису змішувати не можна, але все не настільки суворо (про це навіть Грег Янг казав). Проте якщо переконання не дають так робити, то можна скористатися патерном Сага. Цей патерн був вигаданий для вирішення проблем з транзакціями в мікро-сервісній архітектурі і в нашому випадку вона теж буде створювати транзакцію. Саги можуть реагувати на івенти, таймери або певний розклад і можуть виконувати будь-які команди в системі, робити запити в проекції і навіть запити до зовнішніх систем (чого не може робити агрегат, бо він не повинен мати побічних ефектів). Сага може обробити подію NewUserNotActivated і виконати одну з команд: ActivateNewUser або SoftDeleteNewUser. Це теж правильний варіант.
- Нарешті опишу один не правильний варіант, який мені доводилось зустрічати. В базі на колонку Email навішуємо constraint на унікальність. Тепер можна створювати нового користувача завжди і просто обробляти exception з БД в агрегаті.
Наостанок перерахую недоліки (які вже і так очевидні), переваги і сферу застосування зв’язки CQRS + EventSourcing. Недоліки:
- складність підходу в порівняти з традиційними моделями
- величений оверхед по коду
- вимагає приділяти багато уваги проектуванню агрегатів та доменних подій. Не кожного мідла, а тим більше джуна допустять до такого відповідального завдання
- всі сукупні мінуси CQRS та ES
- немає готових рішень/фреймворків або якщо вони є, то не покривають всіх потреб. Тому завжди доводиться писати свої велосипеди
Переваги:
- надзвичайно швидкі операції читання
- можливість створювати альтернативні гілки подій, різні маніпуляції зі стейтом по відношенню до послідовності івентів
- широкі можливості для масштабування окремих частин системи за вимогою
Цей підхід доцільно використовувати в DDD системах (мікро-сервіси у обмежених контекстах) з нерівномірним навантаження на read і write підсистеми і їх треба масштабувати, якщо потрібні історичні дані, якщо потрібна звітність і повнотекстовий пошук. Наостанок хочу додати, що всі вище перераховані підходи не є чимось новим і модним. Вони мають свою сферу застосування. Використовувати певний підхід або технологію тільки заради наявності цього в проекті шкідливо, бо будь-який інструмент ефективний не завжди, а лише за певних обставин. Також будь-які збіги з реальними проектами і рішеннями, які я наводив як приклади, випадкові (:
50 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів