Як використовувати події в Java-додатках (як з точки зору Java SE, так і Enterprise Java)

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу поділитися своїм досвідом роботи з такою цікавою темою як події в Java додатках та їхня обробка. Спочатку для комунікації між сервісами і системами використовувалися синхронні механізми request/response, які і зараз досить поширені. Проте з появою розподілених систем та мікросервісної архітектури у них почала застосовуватися event-driven architecture. Ми розглядаємо цей матеріал на деяких тренінгах. Але генерація і обробка подій може здійснюватися в рамках однієї JVM, на жаль, це реалізується різними способами в різних технологічних стеках. Постійно зіштовхуючись із цією темою, у мене набралося достатньо матеріалу для окремої статті. Тому в цій статті я наведу прості і не дуже прості приклади, щоб наочно показати, як це найкраще робити в сучасних Java проєктах.

Що таке подія

Насамперед, що така подія? Взаємодія будь-яких компонентів або програмних систем можна організувати за допомогою трьох способів комунікації:

1) Команди.

2) Запити.

3) Події.

Команда зазвичай починає певну дію (action request), яка змінює стан робочого компонента. Запит (query) має на увазі отримання даних. І ці два варіанти поєднує так зване тісне зв’язування (tight coupling), коли обидва учасники комунікації повинні добре знати одне одного і той API, який вони надають. Під подією розуміють зміну стану деякого об’єкта чи компонента. Це також може бути початок та завершення будь-якого процесу або його стадії. Тобто це і нотифікація, і деякі дані про цю зміну. Плюс такого механізму — ні відправник, ні одержувач не знають одне про одного заздалегідь, що означає слабке зв’язування (Loose Coupling). Крім того, відправник нотифікації не вимагає відповіді, а це означає, що такий підхід можна зробити асинхронним. І тут у розробників дві головні завдання — це генерація події та її отримання (обробка), як у рамках однієї програми (JVM), так і в розподіленій системі. У цій статті ми розглянемо перший варіант.

А що на практиці

Щоб розглянути цю тему на практиці, звернемося до відомого use-case. У нас є enterprise-проєкт, в якій необхідно виконати ініціалізацію даних (або імпорт із зовнішнього джерела) при старті програми, але перед тим, як користувачі можуть його використовувати. Зазвичай цей процес запускають після повного завантаження всього додатку. Це робиться для того, щоб, по-перше, наша ініціалізація не гальмувала старт всього додатка, хоча її і можна виконати асинхронно. А по-друге, щоб бути впевненими, що наш контейнер та всі його компоненти (у тому числі й різні connectors) готові до використання.

Як це зробити найкраще? Зазвичай, для повідомлення про зміну стану компонента використовують паттерн поведінки Observer з групи канонічних GoF паттернів. Його додали ще в JDK 1.0 у вигляді інтерфейсу Observer та класу Observable:

@Deprecated(since="9")
public interface Observer {
    void update(Observable o, Object arg);
}

Але вже в JDK 9 їх оголосили deprecated через синхронний і нетипізований механізм реалізації і вкрай низьку затребуваність. Дивно, але в самій JDK немає жодного випадку їхнього використання, так що не виключено, що їх можуть видалити в якихось майбутніх релізах. Але повернемось до подій. У самій Java є мінімальний API для роботи з ними. Насамперед, це клас EventObject, що з’явився в JDK 1.1:

public class EventObject implements java.io.Serializable {
     protected transient Object source;
 
    public EventObject(Object source) {
        if (source == null)
            throw new IllegalArgumentException("null source");
 
        this.source = source;
   }

У ньому є тільки одне поле source — це той об’єкт, який відправив поточну подію. Від такого класу не дуже багато користі, тому його потрібно успадкувати та додати потрібні деталі. Є і інтерфейс для того, щоб зацікавлені компоненти могли підписуватися на події, який є маркер-інтерфейсом без будь-якої функціональності:

public interface EventListener {
}

Як ви бачите, якогось повноцінного SDK тут немає. Для того, щоб працювати з подіями у ваших додатках, потрібно успадковуватися від обох типів і реалізувати вже названий патерн Observer. Використовується такий підхід в основному в UI: бібліотеках AWT та Swing. Мінус такого підходу — тісне зв’язування генератора та обробника подій. Тому іноді використовують патерн publish/subscribe, де передплатники подій нічого не знають про відправників. На жаль, у JDK він у загальному вигляді реалізований. Тому, щоб не винаходити велосипед, доводиться використовувати сторонні бібліотеки, які підтримують так звану publish/subscribe event bus, при чому як у синхронному, так і асинхронному варіанті — Google Guava, MBassador або GreenRoobot. На жаль, їх застосування обмежується Android та JDK, тому для веб-додатку та нашого випадку вони не підходять. Але до EventObject ми ще повернемося.

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

public interface ServletContextListener extends EventListener {
 
    default public void contextInitialized(ServletContextEvent sce) {
    }
 
    default public void contextDestroyed(ServletContextEvent sce) {
    }
}

Усього listeners 9, але нас цікавлять лише цей. Як бачите, він успадкує інтерфейс Event-Listener, а клас ServletContextEvent, як ви вже здогадалися, розширює EventObject. Таким чином, нам потрібно лише реалізувати цей інтерфейс:

@WebListener
public class AppInitializer implements ServletContextListener {

Так роблять багато веб-фреймворків, наприклад, якщо ви використовуєте Spring MVC без Spring Boot, то там є своя реалізація цього listener:

public class ContextLoaderListener extends ContextLoader implements ServletContextListener {

Але тут з’являються дві складності. По-перше, в цьому класі немає прямого доступу до інфраструктури нашого фрейморка (наприклад, ті ж Spring біни), і нам доведеться залізти в нетрі, щоб її отримати. Більш серйозна проблема — взагалі кажучи, у веб-застосунку багато listeners і дуже важливий порядок їх запуску. Потрібно, щоб наш listener був запущений після решти, щоб вони встигли виконати свою ініціалізацію. Це можна зробити, вказавши їхній пріоритет у конфігураційному файлі web.xml. Але якщо якийсь listener оголошений через анотацію @WebListener, то це зробити набагато складніше, а іноді й неможливо.

У специфікації Servlet API 3.0 з’явився новий інтерфейс ServletContainerInitializer:

public interface ServletContainerInitializer {
 
   public void onStartup(Set<Class<?>> c, ServletContext ctx) throws ServletException;
}

Його основна відмінність від попереднього інтерфейсу — використання механізму service provider lookup. Необхідно створити у вашому проєкті папку META-INF/services, у ній файл jakar-ta.servlet.ServletContainerInitializer, де вказати повний шлях класу-реалізації. Ще одна відмінність — наявність загадкового параметра, який буде містити набір всіх типів, зазначених анотацією @HandleTypes. До речі, Spring, починаючи з версії 3.1, використовує вже цей інтерфейс у рамках переходу на Java-based configuration:

@HandlesTypes(WebApplicationInitializer.class)
public class SpringServletContainerInitializer implements ServletContainerInitializer {

Тут не використовуються події, але може знову виникнути проблема з порядком завантаження цих ініціалізаторів. Можуть допомогти спеціальні файли web-fragment.xml та аттрибут absolute-ordering у web.xml, але у великому складному додатку в цьому легко заплутатися та зіткнутися з тим, що порядок не такий, який нам потрібен.

Тому наступний крок — спробувати використати функціональність нашого веб-контейнера, який суттєво розширює можливості. Почнемо з Spring та Spring Boot. В цьому випадку є спокуса взагалі обійтися без подій, а використовувати таку фічу як post-initialization callback:

@Component
public class DataInitialization {
 
       @PostConstruct
       public void init() {
              // import data
              // initialize data
       }

Такий підхід працюватиме, але має і приховані мінуси:

1) Метод init() буде викликатися синхронно під час завантаження Spring контексту і блокувати старт програми. Уявіть, що ми тут будемо звертатися до зовнішнього джерела даних або зовнішнього сервісу, у будь-якому випадку це буде досить повільно. А зробити це асинхронно, просто додавши інструкцію @Async, не вдасться.

2) Якщо під час виклику init() буде викинуто виняток, який ми не перехопимо, це призведе до завершення роботи програми.

3) Цей метод має викликатися лише один раз. Але оскільки він public, то його можна викликати кілька разів, що явно небажано. Або його треба оголошувати як private/protected, що ускладнить його тестування.

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

public abstract class ApplicationEvent extends EventObject {

І для нової події достатньо створити клас, який до Spring 4.2 повинен був наслідувати ApplicationEvent, а в Spring 4.2 з’явився клас-обгортка PayloadApplicationEvent, і базовий клас вже явно вказати не обов’язково.

Але для нашого завдання нові класи не потрібні. Достатньо перехопити подію ContextRefreshedEvent, яка не тільки є нотифікацією, а й містить як source ApplicationContext:

public class ContextRefreshedEvent extends ApplicationContextEvent {
        public ContextRefreshedEvent(ApplicationContext source) {
              super(source);
       }
}

Spring гарантує, що ця подія буде згенерована після успішного завантаження (або перезавантаження) контексту. І перехопити його нескладно:

@Component
public class DataInitialization {
      
       @EventListener(ContextRefreshedEvent.class)
       public void onInit() {
             
       }

Для Spring Boot ситуація трохи інша, кількість подій більша, тому що більше стадій ініціалізації, включаючи і сам додаток. Тому тут потрібно перехоплювати або ApplicationStartedEvent, або ApplicationReadyEvent (воно генерується найостаннішим):

@Component
public class DataInitialization {
      
       @EventListener(ApplicationReadyEvent.class)
       public void onInit() {

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

       @Async
       @EventListener(ApplicationReadyEvent.class)
       public void onInit() {

Event listeners зручні тим, що у одному класі можна написати відразу кілька callbacks на різні події. Але якщо подія всього одна, то можна просто створити Spring бін, який її оброблятиме, використовуючи інтерфейс ApplicationListener:

@Configuration
public class CustomConfig {
      
       @Bean
       public ApplicationListener<ApplicationReadyEvent> initHandler(DataInitialization initialization) {
              return event -> initialization.init();
       }
 }

Якщо у вас Java EE (Jakarta EE) проєкт, то тут все трохи складніше. Спочатку в EJB була можливість відправки подій, але так як EJB після версії 3.2 в 2013 році практично перестала розроблятися, то ми цю функціональність розглядати не будемо. Але в Java EE 6 з’явилася нова специфікація CDI (Context and Dependency Injection), яку трохи пізніше додали можливість використовувати CDI Events.

Тут немає прив’язки до базового класу EventObject, можна створити та відправити будь-яку об’єкт-подію, а потім перехопити її в CDI біну за допомогою анотації @Observes:

@ApplicationScoped
public class AppListener {
      
       public void listener(@Observes CustomEvent event) {

Якщо ж ми хочемо отримати нотифікацію про те, що Java EE контейнер успішно завантажився, потрібно перехопити подію, яка специфічна для самого контейнера. Наприклад, для JBoss Weld це ContainerInitialized:

@ApplicationScoped
public class AppListener {
      
       public void listener(@Observes ContainerInitialized event, DataInitialization initialization) {
              initialization.init();
 }

Це не дуже зручно, тому що ми прив’язуємося не до специфікації CDI, а до її конкретної реалізації. І є більш загальний варіант, коли прив’язуємося до закінчення ініціалізації самого CDI контексту, використовуючи анотацію @Initialized:

@ApplicationScoped
public class AppListener {
 
       public void listener(@Observes @Initialized(ApplicationScoped.class) DataInitialization initialization) {
              initialization.init();
       }
}

При цьому мається на увазі, що DataInitialization — це CDI бін, який до нас приходить як аргумент методу.

Висновки

У цій статті я постарався розповісти про те, як можна використовувати події в Java додатках (як з точки зору Java SE, так і Enterprise Java). JDK надає вам тільки базові можливості для створення власної моделі подій, тому організувати їх відправлення отримання потрібно або через додаткові бібліотеки, або веб-контейнери.

У той же час у event-driven approach є як свої переваги, так і недоліки. Важливо пам’ятати, що синхронне оброблення подій може заблокувати основний процес. У той же час обмін інформацією та нотифікаціями за допомогою подій допомагає зробити систему більш гнучкою і позбутися сильної зв’язаності між компонентами.

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
1) Команди.

2) Запити.

3) Події.

Один із найбільш шкідливих і деструктивних паттернів.

Використання на практиці призводить до побудови багатогілкового дерева наслідування із купою помилок, костилів та заплаток, яке вкрай важко підтримувати. Призводить до роздування коду, дуплікації, копіпасти класів і створення зайвих сутностей і цілих псевдо-шарів в архітектурі.

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

У той же час у event-driven approach

У статті взагалі нічого немає про цю архітектуру. Розкидати ліснери і обсервери, намазавши зверху @Async — це не про архітектуру.

Наведіть свої приклади, що краще

Ти про що саме?
Якщо про мою критику

1) Команди.
2) Запити.
3) Події.

то моя відповідь проста — не використовувати це взагалі. Не створювати типізацію повідомлень, засновану на розгалудженому наслідуванні і не робити з того священну корову.

Замість структукри:

abstract class Message
abstract class Command extends Message
abstract class Request extends Message
abstract class Event extends Message
class DoSmthCommand extends Command
class SmthDoneEvent extends Event

насправді потрібні лише:

class DoSmth
class SmthDone
Якщо дуже хочеться вставити якусь айдішку, ну можна один спільний класс-предок зробити максимум.

Якось так. Це полегшить дизайн і прибере купу непотрібного псевдо-інфраструктурного коду і не виклече купу майбутніх проблем, особливо якщо у людей на проекті загострення пурізму.

Підписатись на коментарі