Поступове впровадження мікросервісів за допомогою Spring Modulith з шістнадцятковою архітектурою

Я деякий час чув про Spring Modulith, але коли минулого року я прочитав книгу Practical Event-Driven Microservices Architecture, я переконався, що використання Spring Modulith повинно враховуватися щоразу, коли я хочу розпочати створення сервісу. Але як це допомагає?

Те, що я дізнався з цієї книги, а також з мого власного досвіду, полягає в тому, що: більшість часу ми повинні уникати розділення наших сервісів на мікросервіси з самого початку.

Наприклад, після того, як ми визначимо кордони нашої доменної області, ми можемо:

  • Створити монолітний сервіс з кількома модулями
  • Реалізувати логіку кожного кордону в його модулі
  • Надати SPI/API для кожного модуля і/або деякі події домену.
  • Спілкуватися між модулями, використовуючи їх SPI або просто продукуючи/споживаючи події домену (Event-Driven)

Як ви бачите, це рішення дозволяє нам розділити кордони з самого початку. Кожен модуль спілкується з іншими, використовуючи абстракцію, яка є інтерфейсом (тобто SPI модуля) або навіть ще більш розв’язно, коли використовуються події (Event-Driven). Тому можливо витягнути один модуль у новий сервіс і реалізувати адаптер для цього інтерфейсу, щоб спілкуватися через REST API / gRPC,… і отримати ті ж самі результати.

Давайте подивимося, як можна реалізувати це рішення за допомогою Spring Modulith і Гексагональної архітектури. Якщо ви не знайомі з Гексагональною архітектурою, я дуже рекомендую цю книгу: Get Your Hands Dirty on Clean Architecture

Давайте розглянемо приклад. Я ілюструю це, використовуючи згенеровану діаграму безпосередньо з Spring Modulith!

pic

Модуль продуктів: Відповідає за управління продуктами. Отже, він повинен надавати API (наприклад, REST) для адміністраторів системи для CRUD продуктів, публікації подій життєвого циклу продукту, щоб інформувати інші сервіси про них, надання API для перевірки наявності продуктів і будь-якого іншого, що пов’язано з продуктами.

Модуль платежів: Відповідає тільки за платежі. Він повинен бути якомога більш універсальним і використовуваним для інших цілей, таких як підписки або цифрові послуги. Модуль платежів має взаємодіяти з зовнішніми платіжними постачальниками (наприклад, PayPal, Stripe), керувати процесом платежу, який зазвичай включає: початковий запит на оплату, надання інтерфейсу для збору платіжних даних, запит на оплату до платіжного постачальника та, нарешті, надання вебхуків для отримання оновлень про платежі від зовнішніх постачальників і, зрештою, інформування інших сервісів про оновлення платежів (ідеально — публікація подій).

Модуль замовлень: Це перша лінія, де ми можемо надавати інтерфейс для кінцевих користувачів. У реальних виробничих сценаріях ми, ймовірно, використовуватимемо BFF Pattern і матимемо окремий фронтенд-сервіс, який споживає API замовлень. Однак у цій демонстрації я пропустив це заради простоти і реалізував простий інтерфейс за допомогою Spring MVC та шаблонів Thymeleaf.

У модулі замовлень нам потрібно мати список продуктів. З іншого боку, ми не відповідаємо за управління життєвим циклом продукту. Тому я використав CQRS Pattern, який зазвичай використовують разом з Event-Driven стилем. Модуль замовлень слухає події продуктів і зберігає копію продуктів у своїй локальній базі даних (згідно з Database-Per-Service Pattern).

Як ви бачите, мутація даних або команда частини системи відокремлена від частини запиту. У модулі замовлень нам потрібно лише читати продукти, і нам не потрібні такі поля, як кількість продукту, і ми не хочемо бути залученими в процес синхронізації оновлень кількості.
Тому ми можемо мати просту модель продуктів і ігнорувати ці частини даних.

Можливо, ви запитаєте, що таке спільний модуль? Я вважаю, що ми повернемося до цього пізніше, оскільки це вимагає більш технічних деталей.

Як я згадував на початку, я використовував Гексагональну архітектуру у своїй реалізації. Можливо, ви вважаєте, що це надмірно складно (додаткові мапери, абстракції тощо), але я твердо вірю, що це того варте, особливо коли ми вирішуємо розділяти деякі частини на різні мікросервіси. Давайте розглянемо комунікацію з зовнішнім середовищем. З рівня додатку ми завжди комунікуємо з портами, коли потрібно отримати доступ до зовнішнього світу (API або БД). Порти є абстракціями, які реалізовані адаптерами. Наприклад, я використовував порт з назвою ApiInvoker для перевірки наявності продукту. У нашій реалізації Modulith це просто викликає usecase, який реалізований в іншому пакеті, але коли ми вирішимо виділити модуль продуктів в окремий мікросервіс, він має викликати API (REST, gRPC і т.д.), щоб дістатися до сервісу продуктів, але нічого не зміниться на рівні додатку.

Добре, давайте пропустимо теорії і перейдемо до реалізації.

Враховуючи, що ви вже ініціювали Spring Boot додаток, використовуючи https://start.spring.io

Переконайтесь, що ви додали ці залежності разом з загальними залежностями для Spring Web, DevTools тощо. Однак повний вихідний код можна знайти на GitHub


 org.springframework.modulith  
 spring-modulith-events-api  


 org.springframework.modulith  
 spring-modulith-starter-core  


 org.springframework.boot  
 spring-boot-starter-data-jpa  


 org.springframework.modulith  
 spring-modulith-starter-jpa  


 org.springframework.modulith  
 spring-modulith-test  
 test  


 org.springframework.modulith  
 spring-modulith-starter-test  
 test  

Перший крок: визначення продуктів. Я використав ініціалізатор продуктів, який запускається під час старту і додає деякі тестові продукти. Як ви бачите, він публікує подію, щоб повідомити інші сервіси про нові продукти

@EventListener(ApplicationReadyEvent.class)  
@Transactional  
public void initializeProducts() {  
 List initialProducts = productRepository.saveAll(  
 List.of(  
 new ProductJpaEntity(null, "LAPTOP123", "Dell XPS 13", 1199.99f, "USD", 50),  
 new ProductJpaEntity(null, "HEADPH001", "Sony WH-1000XM5 Headphones", 399.99f, "USD", 200),  
 new ProductJpaEntity(null, "PHONE202", "iPhone 15 Pro", 999.99f, "USD", 100),  
 new ProductJpaEntity(null, "TVOLED77", "LG OLED C2 77-inch TV", 2499.99f, "USD", 30),  
 new ProductJpaEntity(null, "CAMERA345", "Canon EOS R6", 2499.00f, "USD", 40),  
 new ProductJpaEntity(null, "WATCH789", "Samsung Galaxy Watch 6", 349.99f, "USD", 150),  
 new ProductJpaEntity(null, "TABLET001", "iPad Pro 11-inch", 799.99f, "USD", 120),  
 new ProductJpaEntity(null, "DRONE101", "DJI Mini 3 Pro", 759.99f, "USD", 60)  
 )  
 );  

 initialProducts.stream().map(p -> new ProductCreatedEvent(  
 p.getSku(),  
 p.getName(),  
 new ProductPrice(p.getPrice(), p.getCurrency()).toString()  
 )).forEach(applicationEventPublisher::publishEvent);  
}

Як згадувалося раніше, інші сервіси не цікавляться деталями продукту (такими як точність ціни, доступна кількість).
Отже, ми надаємо просту репрезентацію, яка включає тільки необхідні дані.

Можливо, ви запитаєте, що таке спільний модуль? Я вважаю, що до цього ми повернемося пізніше, оскільки це потребує більше технічних деталей.

Як я згадував на початку, я використовував Гексагональну архітектуру у своїй реалізації. Ви можете вважати, що це надмірно складно (додаткові мапери, абстракції тощо), але я твердо вірю, що це того варте, особливо коли ми вирішуємо розділяти деякі частини на різні мікросервіси. Давайте розглянемо комунікацію з зовнішнім середовищем. З рівня додатку ми завжди комунікуємо з портами, коли потрібно отримати доступ до зовнішнього світу (API або БД). Порти є абстракціями, які реалізовані адаптерами. Наприклад, я використовував порт з назвою ApiInvoker для перевірки наявності продукту. У нашій реалізації Modulith це просто викликає usecase, який реалізований в іншому пакеті, але коли ми вирішимо виділити модуль продуктів в окремий мікросервіс, він має викликати API (REST, gRPC і т.д.), щоб дістатися до сервісу продуктів, але нічого не зміниться на рівні додатку.

Добре, давайте пропустимо теорії і перейдемо до реалізації.

Враховуючи, що ви вже ініціювали Spring Boot додаток, використовуючи https://start.spring.io

Переконайтесь, що ви додали ці залежності разом з загальними залежностями для Spring Web, DevTools тощо. Однак повний вихідний код можна знайти на GitHub


 org.springframework.modulith  
 spring-modulith-events-api  


 org.springframework.modulith  
 spring-modulith-starter-core  


 org.springframework.boot  
 spring-boot-starter-data-jpa  


 org.springframework.modulith  
 spring-modulith-starter-jpa  


 org.springframework.modulith  
 spring-modulith-test  
 test  


 org.springframework.modulith  
 spring-modulith-starter-test  
 test  

Перший крок: визначення продуктів. Я використав ініціалізатор продуктів, який запускається під час старту і додає деякі тестові продукти. Як ви бачите, він публікує подію, щоб повідомити інші сервіси про нові продукти

@EventListener(ApplicationReadyEvent.class)  
@Transactional  
public void initializeProducts() {  
 List initialProducts = productRepository.saveAll(  
 List.of(  
 new ProductJpaEntity(null, "LAPTOP123", "Dell XPS 13", 1199.99f, "USD", 50),  
 new ProductJpaEntity(null, "HEADPH001", "Sony WH-1000XM5 Headphones", 399.99f, "USD", 200),  
 new ProductJpaEntity(null, "PHONE202", "iPhone 15 Pro", 999.99f, "USD", 100),  
 new ProductJpaEntity(null, "TVOLED77", "LG OLED C2 77-inch TV", 2499.99f, "USD", 30),  
 new ProductJpaEntity(null, "CAMERA345", "Canon EOS R6", 2499.00f, "USD", 40),  
 new ProductJpaEntity(null, "WATCH789", "Samsung Galaxy Watch 6", 349.99f, "USD", 150),  
 new ProductJpaEntity(null, "TABLET001", "iPad Pro 11-inch", 799.99f, "USD", 120),  
 new ProductJpaEntity(null, "DRONE101", "DJI Mini 3 Pro", 759.99f, "USD", 60)  
 )  
 );  

 initialProducts.stream().map(p -> new ProductCreatedEvent(  
 p.getSku(),  
 p.getName(),  
 new ProductPrice(p.getPrice(), p.getCurrency()).toString()  
 )).forEach(applicationEventPublisher::publishEvent);  
}

Як згадувалося раніше, інші сервіси не цікавляться деталями продукту (такими як точність ціни, доступна кількість).
Крім оновлень статусу, система повинна обробляти замовлення (для відправки чи іншого кроку), коли платіж буде отримано.

@Async  
@EventListener  
public void onPaymentStatusUpdated(PaymentStatusUpdatedEvent event) {  
 transactionTemplate.executeWithoutResult((status) -> {  
 Order confirmedOrder = orderStorage.updatePaymentStatus(event.orderId(), event.paymentStatus(), event.isPaid());  
 if (confirmedOrder.paid()) {  
 // Викликаємо usecases для наступного кроку, наприклад, відправка замовлення...  
 applicationEventPublisher.publishEvent(orderMapper.toOrderCompletedEvent(confirmedOrder));  
 }  
 });  
}

А в модулі продуктів ми маємо слухача для цієї події:

@ApplicationModuleListener  
public void handleOrderEvent(OrderCompletedEvent event) {  
 productStorage.deductQuantity(new ProductSku(event.productSku()), event.quantity());  
}

Спочатку ми оновлюємо нашу БД, а потім, якщо замовлення оплачено, публікується ще одна подія, щоб повідомити інші сервіси про завершення замовлення. У нашому прикладі модуль продуктів слухає цю подію, щоб відняти продукт зі складу. Якщо у нас є інший сервіс, наприклад, сервіс електронної пошти, він також може слухати цю подію і надіслати підтвердження користувачеві.

Чому ми використовуємо різні анотації для слухачів?

@ApplicationModuleListener є рекомендованим підходом, але давайте подивимося, які інші анотації він містить?

package org.springframework.modulith.events;  

import java.lang.annotation.Documented;  
import java.lang.annotation.ElementType;  
import java.lang.annotation.Retention;  
import java.lang.annotation.RetentionPolicy;  
import java.lang.annotation.Target;  
import org.springframework.context.event.EventListener;  
import org.springframework.core.annotation.AliasFor;  
import org.springframework.scheduling.annotation.Async;  
import org.springframework.transaction.annotation.Propagation;  
import org.springframework.transaction.annotation.Transactional;  
import org.springframework.transaction.event.TransactionalEventListener;  

@Async  
@Transactional(  
 propagation = Propagation.REQUIRES_NEW  
)  
@TransactionalEventListener  
@Documented  
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface ApplicationModuleListener {  
 @AliasFor(  
 annotation = Transactional.class,  
 attribute = "readOnly"  
 )  
 boolean readOnlyTransaction() default false;  

 @AliasFor(  
 annotation = EventListener.class,  
 attribute = "id"  
 )  
 String id() default "";  

 @AliasFor(  
 annotation = EventListener.class,  
 attribute = "condition"  
 )  
 String condition() default "";  
}

Як ви бачите, він також має анотацію @Async разом з @Transactional(propagation = Propagation.REQUIRES_NEW).

Це означає, що це не перериває поточну транзакцію (контекст публікації) і запускає свою власну транзакцію.

Але проблема в тому, що @TransactionalEventListener. За замовчуванням, воно спрацьовує лише після того, як транзакція буде зафіксована. Тому, за замовчуванням, він не спрацьовує, якщо подія публікується без контексту транзакції.

Згідно з документацією:

Якщо подія не публікується в рамках активної транзакції, подія буде відкинута, якщо не встановлено прапор fallbackExecution()

Ми повинні враховувати транзакцію як на стороні публікатора, так і на стороні споживача. У нашому прикладі з оплатою ми можемо надіслати події без транзакції. Тому ми не використовували @ApplicationModuleListener, а замість цього використали анотацію EventListener (прослуховувач подій) разом з @Async.

Але, як ви бачите в коді, ми вручну почали транзакцію.
Отже, прослуховувач подій (Event Listener) продукту може використовувати @ApplicationModuleListener.

Я надав інший API для відправки оновлень статусу платежів на інтерфейс користувача (UI). Хоча в реальних сценаріях платежі обробляються швидко, і немає необхідності додавати зайве навантаження на нашу систему, це дає вам кращу картину того, як наші модулі можуть синхронізувати свої статуси за допомогою подій.

Є API, яке повертає Server-Sent-Event. UI може використовувати цей TextEventStream для відображення останніх оновлень статусу платежу.

@GetMapping(value = "/order/{orderId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)  
public SseEmitter getPaymentStatus(@PathVariable String orderId) {  
 SseEmitter emitter = new SseEmitter(10_000L);  
 ...  
 var order = orderQueryUseCase.retrieveOrder(orderId);  
 emitter.send(SseEmitter.event()  
 .name("payment-status-update")  
 .data(order.get().paymentStatus()));  

 if (order.get().paid()) {  
 emitter.send(SseEmitter.event().name("paid").data(""));  
 break;  
 }  
 ....  
}

Ми охопили весь процес. Тепер давайте подивимося, чому ми використовували спільний модуль? Важливо, щоб ми зберігали модулі слабо зв'язаними та уникали циклічних залежностей. У нашому прикладі модуль замовлень слухає події продуктів. Модуль продуктів, у свою чергу, слухає події замовлень, щоб оновити кількість продуктів. Це означає, що у нас є циклічна залежність!

Давайте подивимося на малюнок, який я наводив раніше:

pic

Тепер зрозуміло, чому нам потрібен спільний модуль. Ми можемо розмістити події, які спільні для різних модулів, в спільному модулі та посилатися на нього звідти. Публікатор також може звертатися до нього зі спільного модуля.

Ми можемо явно оголосити, що спільний модуль не повинен залежати від жодного модуля. В корені спільного пакету ми створюємо файл package-info.java з таким вмістом.

@org.springframework.modulith.ApplicationModule(allowedDependencies = {})  
package com.example.hex_modulith.shared;

Кожен модуль повинен визначити, що він хоче експонувати. Ми можемо або додавати анотацію до кожного компонента за допомогою @NamedInterface, або визначити файл з ім'ям package-info.java і експонувати цілий підпакет.

@org.springframework.modulith.NamedInterface("shared_events")  
package com.example.hex_modulith.shared.event;

Перевірте документацію Spring Modulith для отримання додаткової інформації.

Spring Modulith рекомендує впевнитися, що ми не порушуємо структуру модулів. Він також може генерувати документацію та візуалізації.

@Test  
 void verifyModuleStructure() {  
 var modules = ApplicationModules.of(HexModulithApplication.class).verify();  
 new Documenter(modules)  
 .writeAggregatingDocument()  
 .writeModuleCanvases()  
 .writeModulesAsPlantUml()  
 .writeDocumentation();  
 }

Підсумовуючи, Spring Modulith, (особливо в поєднанні з шістнадцятковою архітектурою), надає потужний підхід до проектування модульних і підтримуваних додатків. Підтримуючи чіткі межі між модулями додатка та використовуючи гнучкість дизайну, орієнтованого на домен, система стає простішою для розвитку з часом. Коли виникає потреба перейти до мікросервісів, чітко визначені межі та розділена архітектура роблять процес безшовним, знижуючи складність, що часто пов'язана з такими переходами. Цей підхід не лише підтримує поточні бізнес-потреби, але й забезпечує масштабованість і адаптованість для майбутніх вимог, роблячи його надійним рішенням для сучасної розробки програмного забезпечення.

Приклад вихідного коду: https://github.com/ahmadzadeh/spring-modulith-demo

Перекладено з: Incremental Adoption of Microservices using Spring Modulith with Hexagonal Architecture

Leave a Reply

Your email address will not be published. Required fields are marked *