Особливості event-driven architecture на прикладах з практики
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу поділитися своїм досвідом роботи з такою цікавою темою як event-driven architecture та розкрити ті теми, які з нею пов’язані. Спочатку для комунікації між сервісами і системами використовувалися синхронні механізми request/response, які і зараз досить поширені. Проте з появою розподілених систем та мікросервісної архітектури, у них почала застосовуватися event-driven architecture. Ми розглядаємо цей матеріал на деяких тренінгах. Постійно зіштовхуючись із цією темою, у мене набралося достатньо матеріалу для окремої статті. Тому я наведу як переваги та особливості цієї архітектури, так і практичні приклади її використання. Оскільки це дуже велика тема і всю її неможливо розглянути в одній статті (тим більше, з практичними прикладами), то мені будете цікаво в коментарях почути ваші історії її використання (як вдалі, так і тi, що не вдалися).
Комунікація між мікросервісами
Для зручності розберемо такий практичний приклад. Уявімо, що ми розробляємо мікросервісний проєкт для покупки квитків. Ми так розділили доменну модель проєкту, що намітили у тому числі 4 мікросервіси:
1) Квитковий — відповідає за квитки та замовлення (бронювання).
2) Сервіс поїздок — керує маршрутами та поїздками.
3) Сервіс нотифікацій — надсилає нотифікації (email, СМС, месенджери) користувачеві.
4) Платіжний — здійснює інтеграцію з платіжними системами та оплату.
У нашому UI покупка квитка організована таким чином (спрощено):
Кожен крок — це окрема сторінка (екран) у клієнтському додатку. На сторінці оплати користувач вибирає платіжну систему та її атрибути, підтверджує оплату або зберігає замовлення (бронює місце) з можливістю оплатити пізніше.
І тут постає цікаве питання. Оплата замовлення, як будь-яка фінансова операція може тривати тривалий час. Вона може залучати інші фінансові (банківські) сайти та системи, включаючи авторизацію в них і т. д. Як ми дізнаємось у нашій системі, що оплата пройшла успішно?
Зрозуміло, коли ми надсилаємо запит до платіжної системи, ми передаємо callback з нашого API, і це буде API платіжного сервісу. Але як інші послуги дізнаються про це? Квитковому сервісу, наприклад, потрібно буде оновити стан замовлення, сервісу нотифікацій повідомити клієнта і відправити квиток разом з нотифікацією. Сервіс поїздок також буде залучений у цей ланцюжок, тому що йому потрібно буде помітити одне місце як продане (зайняте).
Дуже важливий нюанс — вся ця операція, що залучає кілька мікросервісів, повинна бути атомарною і відповідати ACID принципам. Уявимо, наприклад, що якийсь користувач купив квиток на автобусний рейс. Ми після цього маємо зменшити кількість вільних місць на рейсі на одну одиницю в сервісі поїздок. І при цьому з’ясовується, що вільні місця закінчилися. Можливо, відбулася колізія при одночасному придбанні квитка кількома пасажирами або програмна чи конфігураційна помилка. Нам потрібно відразу повідомити про це покупця, повернути гроші за квитки або запропонувати поміняти квиток на інший.
Таким чином, операція купівлі квитка, по суті, стає транзакцією, але не звичайною локальною транзакцією, керованої СУБД, як ми звикли, а розподіленою (distributed) транзакцією. Такій транзакції притаманні всі стандартні ACID атрибути, але вона виконується на різних JVM та різних серверах.
Повернемося до комунікації між мікросервісами. Найбільш простим способом комунікації є REST API, але чи підходить він для цього випадку? Нам доведеться з платіжного сервісу надіслати три запити на залучені мікросервіси (сервіс поїздок, квитковий та сервіс нотифікацій). А це в якомусь сенсі порушує автономність та ізольованість наших мікросервісів, оскільки вони мають добре знати про функціональність та API один одного. Є ще один мінус у використанні REST API для такого завдання. Уявимо, що платіжний сервіс не зміг відправити запит на сервіс поїздок (навіть з використанням retry logic), оскільки останній був недоступний. Ми не можемо точно знати, коли відновиться доступ до сервера (або мережевих ресурсів). Як бути у такому разі? Можна скасувати транзакцію, що буде песимістичним результатом. Але можна зберегти цей запит у певній черзі, щоб виконати його пізніше за розкладом. При цьому ця черга повинна бути персистентною (durable) і бажано розподіленою, щоб ми не втратили її вміст в результаті збою/перезавантаження.
У REST API підходу є й інші мінуси для нотифікації сервісів про успішний платіж:
1) Сервіс-відправник повинен знати всіх одержувачів нотифікації та їх API. Більше того, по суті, він має надіслати не нотифікацію, а команду-запит, наприклад, змінити стан замовлення або зменшити кількість вільних місць на рейсі. Виходить, що функціональність, пов’язана з конкретним мікросервісом, розмивається між рештою, порушуючи принцип SRP. Далі, якщо нам потрібно поміняти одержувачів нотифікації, нам довелося б міняти і сервіс-відправник, порушуючи його автономність і ізольованість.
2) Використовуючи REST API, складно реалізувати гарантовану доставку нотифікації в разі недоступності одного з одержувачів.
3) Оскільки загальна операція має бути атомарною, нам необхідний API як для відправки нотифікації (команди), так і для відправки rollback-нотифікації, що загалом ускладнює кожен мікросервіс і робить їх ще більш пов’язаними.
Як же вийти з цього становища? Нам потрібен підхід, який би дозволив:
- Зробити наші мікросервіси ізольованими та прибрати тісний зв’язок між ними.
- Надати засіб для гарантованої та максимально швидкої відправки/отримання/зберігання нотифікацій про зміну стану моделі даних — подій (або повідомлень).
Messaging systems and patterns
По суті, нам потрібна реалізація структури даних «черга» для наших нотифікацій. Але не та черга (Queue), яка існує в JDK, а більш досконала, яка зберігає дані поза JVM на дисковій підсистемі, підтримує high-availability (failover), динамічне підключення та масштабування роботи підписувачів. Такі технології (або платформи) існують давно і в залежності від своєї функціональності є:
- Messaging queuing service (StormMQ, Amazon SQS).
- Message-oriented middleware или message broker (RabbitMQ).
- Stream processing или event streaming (Apache Kafka).
Однак ці системи об’єднує одне — вони призначені, щоб пересилати повідомлення (messages) між відправником і одержувачем (чи споживачем). Тому ми не помилимося, якщо використовуватимемо для них такий загальний термін як messaging systems.
Такі системи мають досить складну архітектуру, використовують різні математичні алгоритми для обробки та відправлення даних, тому їх пишуть не з нуля, а використовуючи готові напрацювання або ті ж патерни. Ці патерни відносяться до групи Enterprise Integration Patterns, тобто патернів, які допомагають будувати (інтегрувати) розподілені системи. Усього таких патернів 65, і вони добре описані в однойменній книзі. На жаль, ми не зможемо торкнутися всіх патернів, та й це не ціль цієї статті. Але найцікавіші ми розберемо, оскільки вони допоможуть зрозуміти особливості використання messaging systems:
- Перший та найпростіший патерн — Message (повідомлення). Ми використовуємо саме це слово, а не нотифікацію, тому що в повідомленні можна передати все, що завгодно, а не тільки повідомлення про якусь подію. Повідомлення — це абстрактна структура даних (value object), яку можна передати через Message Channel.
- Message Channel — спосіб організації доставки повідомлень, коли відправник посилає повідомлення в певний канал повідомлень, а одержувач підключається до цього каналу для отримання повідомлень. Цей термін має альтернативні назви, наприклад, топік в Apache Kafka.
- При цьому одержувач та відправник знають ідентифікатор (назву) каналу і виходячи з цього, знають які дані та якого формату там мають бути. У самих повідомленнях немає потреби додавати метадані про структуру. Це передбачає патерн Datatype channel.
- Message — це упаковка для наших повідомлень. Залежно від того, що ми хочемо надіслати — інформацію про подію, команду або дані, ми можемо використовувати патерни Event Message, Command Message або Document Message.
- Пересилання повідомлень по одному вкрай неефективне, тому їх зазвичай поєднують у групи (batches) як при пересиланні, так і при отриманні. Для цього використовують пат-терн Message Sequence.
- Повідомлення — це набір байт, у яких ми серіалізували наш вихідний Java об’єкт. Але формат даних при серіалізації може бути різним, що ускладнить інтеграцію наших додатків (сервісів). Тому патерн Canonical Data Model пропонує використовувати один загальний формат даних для всіх учасників обміну повідомленнями, який, по суті, інкапсулює той формат, який використовує конкретний сервіс.
- Для того, щоб перетворювати дані з Java об’єктів у бінарні повідомлення та навпаки, потрібен патерн Messaging Mapper
- Паттерн Message Bus (шина повідомлень) пропонує обмін повідомленнями в розподіленій системі, коли учасники цієї схеми нічого не знають один про одного, і будь-який сервіс може як приєднатися, так і відключитися від цієї шини.
- Якщо у вашого повідомлення повинен бути тільки один одержувач, то рекомендується використовувати патерн Point-To-Point. При цьому навіть якщо у вас до каналу повідомлень підключено кілька одержувачів, то тільки один з них отримає доступ до цього повідомлення.
- Якщо ж ви хочете, щоб ваше повідомлення могло бути доставлене всім споживачам (consumers), то рекомендується використовувати патерн Publish-Subscribe.
- Якщо при обробці повідомлення виникла помилка, пов’язана з некоректним вмістом цього повідомлення, то патерн Invalid Message Channel пропонує відправити це повідомлення в окремий канал.
- Дуже важливо, щоб при відправці повідомлення воно не загубилося у великій розподіленій системі. Паттерн Guaranteed Delivery гарантує, що повідомлення буде збережено в системі і не видалено, поки його не опрацюють усі споживачі. Це в свою чергу вимагає від messaging system наявності надійного сховища даних для повідомлень.
- Що якщо повідомлення все ж таки не вдалося відправити? Тоді згідно з патерном Dead letter channel його потрібно відправити в спеціальний канал для подальшого аналізу.
- Для надсилання та отримання повідомлень потрібен API клієнт (схожий на HTTP клієнт у REST API), який дозволить інтегруватися з messaging system. Такий клієнт є реалізацією патерну Channel Adapter.
- Якщо API клієнт є низькорівневим способом доступу до messaging system, то патерн Messaging Gateway пропонує інкапсулювати такий API, щоб спростити додатком роботу з повідомленнями.
- Якщо одержувач готовий до прийому повідомлень, він може використовувати найпростіший — синхронний блокуючий підхід, коли він підключається до messaging channel і чекає на їх надходження. За це відповідає патерн Polling Consumer.
- У той самий час найефективніше асинхронне отримання повідомлень пов’язані з паттер-ном Event-Driven Consumer. Одержувач підписується на прийом повідомлень, вказуючи метод-callback, який і буде викликаний у цьому випадку.
- Послідовне отримання повідомлень одним споживачем — найпростіший, але з найшвидший варіант. Для прискорення роботи можна обробляти повідомлення паралельно, що вимагає наявності кількох одержувачів в одного каналу та підтримки такої функціональності на рівні messaging channel. Такий патерн називається Competing Consumers.
- Необхідно зберігати стан кожного споживача — які повідомлення він уже прочитав, а які ще немає. Це дозволить уникнути пропущених повідомлень, якщо споживач, наприклад, перевантажується чи недоступний. Для цього існує патерн Durable subscriber. При цьому кожен споживач повинен мати унікальний ідентифікатор, щоб його можна було відрізняти від інших споживачів.
- Що, якщо при обробці повідомлення виникне помилка, і одержувачеві потрібно прочитати та обробити повідомлення ще раз? Якщо обробка зводиться до додавання даних, необхідно передбачити, щоб повторна обробка не призвела до дублювання даних. Це передбачає патерн Idempotent Receiver.
Загалом структура взаємодії одержувача та підписувачів виглядає так:
Особливості event-driven architecture
Тепер поговоримо про особливості самої архітектури, які важливі для майбутньої розробки та відмінності від REST API.
Перша особливість така. Якщо в REST API ми надсилаємо запит і знаємо за відповіддю або його відсутністю, наскільки успішно він виконаний, то в новому варіанті дещо складніше. Якщо ми відправили в message broker подію «Платіж оплачений» і отримали повідомлення, що подія доставлена, то не означає 100% гарантії, що вона не буде втрачена. Наприклад, подія була доставлена в primary broker, але ще не було синхронізовано з репліками. Якщо primary broker впаде (або стане недоступним) і одну з реплік виберуть лідером, повідомлення вже не буде доставлене споживачам. Наш сервіс буде думати, що операція доставки пройшла успішно. Але навіть якщо подія була успішно доставлена, ми ніяк не дізнаємося, що хтось цю подію отримав, а якщо отримав, то обробив. Більше того, ми не можемо заздалегідь знати, в який момент ця подія буде отримана. Це ускладнює логіку роботи додатку загалом. Якщо квитковий мікросервіс не зможе (з різних причин) отримати або обробити цю подію, то ми отримаємо не консистентний стан замовлення. Гроші користувач сплатить, а квиток виписаний не буде. Така неконсистетність — наслідок розподіленої роботи наших сервісів, і вона потребує контролю над такими ситуаціями.
Друга особливість полягає в тому, а що взагалі являє собою подію? Якщо ми використовуємо REST API, найчастіше вибираємо популярний формат JSON і HTTP як транспортний протокол для комунікації. Це дуже зручно, тому що в будь-якій мові програмування є свій HTTP клієнт і можливість створення/парсингу JSON документів. А це дозволяє створювати нові мікросервіси будь-якою мовою програмування і на будь-яких платформах. У messaging systems кожна подія — це лише масив байт. В Apache Kafka він зберігається на диску як байтовий масив. Тому нам потрібно спочатку серіалізувати нашу об’єкт-подію, а потім десеріалізувати. А це призводить до думки, що алгоритм для серіалізації має бути платформенно-незалежним. Наприклад, ми перетворимо об’єкт на JSON, а потім в масив байт. І все б добре, але JSON — не найкомпактніший, і не найшвидший у плані парсингу. Набагато ефективніше було використовувати бінарний формат, який добре зарекомендував себе (Protobuf, FlatBuffers, Kryo, MsgPack), але потрібно бути впевненим, що для нього є підтримка у використовуваних платформах/мовах програмування.
Третя особливість стосується збереження коду подій. Уявімо, що ми маємо мікросервіс, написаний на Java, який відправляє подію в message broker. Ця подія буде прийнята та оброблена іншими мікросервісами. Де зберігатиметься код класу-події? Він не може зберігатися в самому мікросервісі, значить нам знову потрібно його винести в якусь загальну бібліотеку. Тоді сервіси-споживачі при отриманні події дізнаються за метаінформацією його тип, завантажать його сигнатуру із загальної бібліотеки і виконувати десеріалізацію. Але що якщо вони написані не на Java (Python, Ruby)? Адже вони не зможуть завантажити Java байт-код. А це означає, що в такому випадку потрібно використовувати якусь кросплатформну мову (Interface definition language, IDL) для опису моделі (схеми) подій (Apache Thrift, Avro, Pro-tobuf).
Такий підхід був свого часу революційним для IT, тому що раніше ми завжди вручну писали код. Якщо ми використовуємо Apache Thrift, наприклад, то можемо описати не лише наші класи, але й винятки та навіть описи сервісів:
exception IllegalOperationException {
1: i32 erroCode,
2: string description
}
struct PaymentEvent {
1: i32 paymentId,
2: string customer,
3: double amount
}
Схоже виглядає схема для Google Protocol Buffers:
message PaymentEvent {
optional int32 paymentId = 1;
optional string customer = 2;
optional double amount = 3;
}
З цієї схеми за допомогою спеціальних утиліт (компіляторів) можна згенерувати код як клієнтської, так серверної частини). Це ускладнює роботу, так як ми обмежені можливостями такої мови. Ми не можемо використовувати звичні типи даних із Java SDK, тільки примітиви та колекції. Але це дозволяє бути більш незалежним у виборі платформи для розробки.
Ще один плюс використання таких схем (або контрактів) в тому, що вони є частиною так званого contract-first design, коли всі зацікавлені сторони можуть обговорити контракт на визначення моделі даних і сервісів і тільки потім почати їх реалізацію. Використання схем (а не коду) дозволяє говорити однією, платформо-незалежною мовою.
Четверта особливість стосується зміни нашого API. Як і у випадку з REST API, всі використовуються класи-події є частиною публічного API. І це означає, що ми можемо змінювати їх, не повідомивши всіх споживачів (клієнтів). Ситуація ускладнює тим, що Kafka зберігає події на своїх серверах. І якщо ми змінили вміст події у відправнику, то сервіс-споживач зі старою схемою не зможе його розпізнати. Якщо така ситуація можлива, нам доведеться вводити версіонування, яке підтримується не всіма IDL реалізаціями (наприклад, Apache Avro).
П’ята особливість стосується конвенцій та правил. У REST API було набагато простіше, тому що ми використовували RESTful веб-сервіси, де вже сформувалися загальні правила та best practices з проєктування такого API. У messaging systems немає жодних обмежень (constraints) на опис подій. Візьмемо для прикладу оплату платежу. Яку подію (або події) потрібно згенерувати? Як будуть називатися класи подій і яку інформацію вони містять? Звідки ми знатимемо, що наявної інформації достатньо інших послуг для обробки цієї події? У разі великого корпоративного проєкту легко створити ситуацію, коли в кожній команді (мікросервісі) будуть власні конвенції та практики з використання подій.
Шоста особливість стосується асинхронної обробки подій. Така обробка є великим плюсом з погляду продуктивності. Але з іншого боку, вона вимагає більш складного керування всім процесом комунікації між сервісами, особливо обробки помилок. Це насамперед стосується атомарності наших операцій. Якщо платіж успішно оплачений, а потім виявилося, що вільних місць на рейсі немає або він скасований, необхідно скасувати і оплату. Чим більше послуг задіяно в одній операції, тим складніше забезпечити її атомарність та обробку помилок.
Сьома особливість стосується масштабування. Якщо ми використовуємо REST сервіси, то з збільшенням навантаження стикаємося з тим, що сервери не встигають обробляти вхідні запити. І як вихід — горизонтальне масштабування, коли ми створюємо кластер із серверів, а перед ним ставимо load balancer. Він перенаправлятиме запити по закладених у ньому алгоритмах, балансуючи навантаження. Якщо ми використовуємо messaging systems, то тут у нас більше можливостей. Ми можемо збільшувати кількість споживачів. Але при цьому виникають дві проблеми. Нам потрібно гарантувати, що кожну подію буде оброблено лише один раз (одним споживачем). Друга проблема полягає в тому, що споживачі будуть конкурувати за вхідні події, то це знизить загальну продуктивність. Тому є ще одна опція — зберігати події (повідомлення) у кількох місцях (partitions) в брокері, відповідно призначаючи кожному споживачеві свій partition. Це прискорить обробку за рахунок паралелізму, але тут можлива ще одна складність. Потрібно гарантувати, що події будуть оброблені саме в тому порядку, в якому вони відправлені. Інакше може вийде, що подія «Платіж оплачений» прийде раніше, ніж «Платіж створено». І четверта проблема полягає в тому, що у споживача може статися збій (програмний або апаратний) при обробці події. Якщо така подія буде позначена в брокері як прочитана, то вона буде просто втрачена і ніколи не буде оброблена. Тому як варіант тут можна включати ручне підтвердження прочитання споживачем
Восьма особливість полягає в тому, що подія зберігає в собі будь-яку операцію над сутністю (або root aggregate), успішну або невдалу. А коли ми маємо справу з критично важливою інформацією, то необхідно робити її аудит. У найпростішому випадку це можна реалізувати за допомогою AFTER INSERT/UPDATE тригера, який просто копіює новий стан запису. Але в такому разі ми змушені зберігати всю інформацію, а не тільки ту, що змінилася, і ми не зберігаємо контекст, тобто в рамках якоїсь бізнес-операції це виконувалося і для чого. Наші події є immutable даними, які можна зберігати в базі даних (event store) як для аудиту, так і щоб можна було переглянути стан сутності на будь-яку дату. В такому випадку можна взагалі не зберігати в БД поточний стан сутності, тому що його можна отримати, застосувавши всі зміни, що стосуються його подій. Такий патерн називається event sourcing, і вiн досить популярний зараз для фiнансових проєктiв.
Як ви бачите, у event-driven architecture є велика кількість особливостей і підводних каменів, тому завжди перед початком розробки варто продумати, що краще використовувати для комунікації між сервісами: її, REST API або якісь інші підходи.
Domain and integration events
Думаю, що всі добре знають підхід Domain-Driven Design (DDD) і часто його використовують, створюючи сутності і пов’язуючи їх один з одним. Події також є частиною DDD, оскільки є частиною бізнес-правил та реакцією на зміну стану сутності у цій моделі. Тому проєктування подій має бути нерозривно пов’язане з нашою існуючою моделлю даних. Давайте торкнемося особливості проєктування самих подій.
Отже, перша особливість у тому, що події не однорідні і діляться на доменні (domain) і integration (інтеграційні). Доменні події є частиною доменної моделі мікросервісу, в якому вони визначені (його Bounded Context). Вони не повинні залишати свій мікросервіс, і вони не застосовні поза своєю моделлю. По суті, такі події зручні для того, щоб обмінюватись нотифікаціями між компонентами однієї програми. Їх ключова риса — їх можна відправляти в рамках локальної транзакції, при цьому вони будуть підкоряються відомим принципам ACID. Нас більш цікавлять інтеграційні події. Їхня відмінна риса — вони є частиною доменної моделі декількох мікросервісів (Shared Bounded Context). Наприклад, подію «Платіж оплачено» або «Рейс скасовано». Тому ми можемо спокійно пересилати їх між сервісами, які можуть обробити таку подію. Тому їх необхідно зберігати у загальних бібліотеках, до яких мають доступ всi сервiси. Ще одна їх особливість — вони відправляються тільки після завершення локальної транзакції (успішного або неуспішного), коли локальний стан сервісу став стабільним. Проєктування таких подій є більш складним процесом, тому що включає роботу відразу кількох зацікавлених команд. А це певною мірою порушує автономність кожного мікросервісу. Наприклад, ми спочатку відправляли подію «Платіж оплачений», але потім сторонньому сервісу знадобилася знати також про не минулу оплату, і ми створюємо і відправляємо нову подію (Збій при оплаті платежу).
Друга особливість — події є immutable типами. Дуже важливо, щоб ні в кого не було можливості модифікувати вміст події після відправки. Інакше буде неможливо дізнатися, що в оригіналі було відправлено. Крім того, immutability випливає з того факту, що подія — щось, що сталося у минулому. А якщо так, то ми не можемо його змінювати на основі інформації з сьогодення. DTO — найпопулярніший приклад immutable types. Але події, хоч і переносять інформацію між сервісами, як і DTO, мають важливу відмінність. Якщо виникла помилка при відправленні події в інший сервіс, ми маємо повторити відправку того самого об’єкта-події. Але якщо, наприклад, якась подія сталася ще раз (збій при оплаті платежу), то нам необхідно і надіслати нову подію
Третя особливість полягає в тому, що хоча події можуть містити найрізноманітнішу інформацію, але в них обов’язково повинні бути три властивості:
- Аудит — час створення (надсилання), ідентифікатор відправника (користувач або сервіс).
- Унікальний ідентифікатор самої події для того, щоб ми не обробили його двічі.
- Тип події (наприклад, PaymentSuccess). Ми могли б використовувати замість типу назва класу в нашому Java коді, але це ускладнило б рефакторинг. Якби ми хотіли перейменувати клас, то нам потрібно ці зміни відправити всім споживачам. Крім того, потрібно подбати про те, щоб випадково не створити дублікат типу з такою самою назвою. Імовірність такої колізії невелика, адже кожен мікросервіс має свою доменну модель і свою сутність. Але можуть бути сутності, які належать до різних Bounded Context (Користувач), і тоді такий конфлікт може статися.
- Ідентифікатор тієї сутності, до якої належить подія (наприклад, платежу, якщо йдеться про неї).
Також опціонально можуть бути дані про платіж. Але ж які? Звідки наш платіжний сервіс може заздалегідь знати про це? Він і не зобов’язаний це знати, тому що інакше це порушує його ізольованість та автономність. Тому при створенні події можна використовувати два патерни. Перший патерн Event notification передбачає, що у події буде лише ключова інформація — наприклад, ідентифікатор платежу. Якщо якомусь сервісу потрібно більше, він використовує REST API, щоб запросити ці дані. Якщо ми використовуємо патерн Event-carried state transfer, то поміщаємо всю можливу інформацію про платеж. У кожного патерну є свої плюси та мінуси. Перший іноді вимагає робити додаткові запити, що збільшує навантаження послуги, але робить їх незалежнішими. Другий паттерн збільшує навантаження на мережу, оскільки ми постійно надсилаємо надмірну інформацію. Ми в кожному конкретному випадку вирішуватимемо, який із них використовувати.
Четверта особливість стосується того, як ми називатимемо наші події. У REST API прийнято дуже зручну CRUD-модель, де на кожну операцію є свій HTTP-метод:
- GET — отримання ресурсу;
- POST — створення ресурсу;
- PUT — оновлення ресурсу;
- DELETE — видалення ресурсу.
І є спокуса створити універсальні класи PaymentCreatedEvent або PaymentUpdatedEvent, які ми генерували б на будь-яку зміну в стані платежу. Так можна зробити, але буде принципово неправильно відправляти їх у кожному випадку. Справа в тому, що запити в REST API — це команди від зовнішнього клієнта, які визначають однозначну дію над нашим ресурсом (створити платіж, змінити платіж). А події, як уже говорилося, є частиною DDD. І генерація події — реакція на ті бізнес-процеси, що відбуваються у нашій доменній моделі. Якщо операція оплати платежу завершилася успішно, то ми повинні відправити PaymentSuccessEvent
Якщо ж ми відправимо PaymentUpdatedEvent, то одержувачі цієї події не знатимуть, що спричинило його створення. Єдине, в чому вони можуть бути впевнені — що даний платіж був збережений. І така нотифікація може бути використана хіба що для аудиту (або надсилання повідомлення заінтересованим особам). Одержувачі навіть не знатимуть, які поля були змінені чи додані. Тому в нашому випадку ми використовуватимемо події, прив’язані до доменної моделі.
Створюємо події
Для того щоб краще зрозуміти, як виглядають події, створимо їх у нашому коді. Як уже говорилося, більш продуманим підходом є той, за якого ми створюємо схеми (data definitions), використовуючи Apache Avro або Google Protocol Buffers. Але оскільки не всі з ними знайомі, то для простоти можна створити стандартні Java класи.
Де зберігатимуться ці події? Доменні події як частина доменної моделі зберігаються у своєму мікросервісі. А інтеграційні доведеться зберігати в окремій клієнтській бібліотеці, до якої мають доступ (імпортують) усі зацікавлені мікросервіси.
Щоб спростити обробку подій і зробити її універсальною, добре додати клас, який буде базовим для всіх наших інтеграційних подій. Такий підхід дозволить уникнути можливих проблем з несумісністю API різних сервісів і спростить створення нових подій. Він не вплине на автономність мікросервісів, оскільки зачіпає інфраструктурне питання, а не бізнес-орієнтоване.
Отже, базовий клас BaseEvent буде виглядати так:
@Getter
public abstract class BaseEvent<T> {
private final String id;
private final String entityId;
private final String type;
private final String source;
private final LocalDateTime createdAt;
private final T payload;
public BaseEvent(String entityId, String type, String source, T payload) {
this.entityId = entityId;
this.type = type;
this.source = source;
this.payload = payload;
createdAt = LocalDateTime.now();
id = UUID.randomUUID().toString();
}
}
Цей клас оголошений як immutable, тому що його зміна повинна бути неможлива. Розберемо у деталях його вміст:
- id — унікальний (на основі UUID) ідентифікатор події, який генерується відправником;
- entityId — ідентифікатор сутності, прив’язаної до цієї події. Він може і числовим, і рядковим, тому ми вибрали рядковий тип як більш універсальний;
- type — тип події, який його однозначно характеризує (наприклад, PaymentSuccess). Він зроблений рядковим, а не перерахуванням, щоб нам не доводилося щоразу змінювати загальну бібліотеку при додаванні/видаленні/зміні подій;
- source — ідентифікатор модуля-відправника (наприклад, payment-service);
- createdAt — час створення події;
- payload — опціональне поле, в яке можна записати будь-які додаткові дані, специфічні для цієї події (параметризувавши його типом T).
Цей тип можна було б оголосити як Java record, але тоді ми не змогли б його успадкувати. Тепер у платіжному сервісі вже можна додати перерахування PaymentEventType, оскільки воно стосується лише тих подій, що генеруються одним сервісом
public enum PaymentEventType {
PAYMENT_SUCCESS, PAYMENT_FAILURE
}
де ми вкажемо всі інтеграційні події, які можуть відбуватися у платіжному сервісі. Потім додамо два класи-спадкоємці для BaseEvent: PaymentSuccessEvent:
public class PaymentSuccessEvent extends BaseEvent<PaymentDTO> {
public PaymentSuccessEvent(String entityId, String source,
PaymentDTO payload) {
super(entityId, PaymentEventType.PAYMENT_SUCCESS.name(), source, payload);
}
}
та PaymentFailureEvent:
public class PaymentFailureEvent extends BaseEvent<PaymentDTO> {
public PaymentFailureEvent(String entityId, String source,
PaymentDTO payload) {
super(entityId, PaymentEventType.PAYMENT_FAILURE.name(), source, payload);
}
}
Якщо у необхідності додавання події PaymentSuccessEvent немає жодних сумнівів, то наскільки виправданою є поява PaymentFailureEvent? Спочатку він виглядає внутрішньою подією для платіжного сервісу, але він може стати в нагоді для повідомлення квтикового сервісу, що поточне замовлення має проблеми з оплатою і необхідно встановити його стан в ERROR (або FAILED).
Як payload ми вибрали найбільш загальний та повний варіант — PaymentDTO з усіма даними платежу, таким чином ми використовуємо патерн Event-carried state transfer:
@Getter
@Setter
public class PaymentDTO {
@NotEmpty
private String id;
@NotEmpty
private String userId;
private double amount;
private String currency;
private int ticketId;
private int orderId;
private String paymentType;
private boolean success;
private String errorDescription;
private LocalDateTime createdAt;
}
Як уже говорилося, його мінус у тому, що подія тут містить надлишкові дані, але оскільки сам PaymentDTO дуже простий і містить трохи полів, то такий вибір є виправданим. Як альтернативний варіант — створити DTO, де будуть лише ті дані, які специфічні для невдалого платежу: errorDescription, createdAt та orderId.
Висновки
У цій статті ми розглянули основні особливості event-driven architecture, головні патерни з групи Enterprise Integration Patterns, виділили два типи подій і навели приклади Java-коду для цих подій.
Такий підхід дозволяє зробити послуги слабозв’язаними (на відміну від REST API), підвищити їхню надійність (за рахунок зберігання повідомлень у messaging systems) і продуктивність (оскільки ми не чекаємо відповіді від сервера). Але при цьому підвищуються інфраструктурні витрати (потрібно купувати сервери або послуги для messaging) і ускладнюється загальна обробка помилок та забезпечення атомарності операцій (робота з розподіленими транзакціями).
113 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів