Event-driven архитектура (event-driven architecture, EDA) является архитектурным шаблоном, основанном на создании, подписке на события и реакции на них.
Зачем нужна событийно-ориентированная архитектура? Представим функциональность оформления заказа в интернет-магазине:
public void checkoutShoppingCart(ShoppingOrder order) { persistInDatabase(order); sendEmailNotification(order); shipToTheNearestWarehouse(order); scheduleShippingToCustomer(order); exportToERP(order); }
Нетрудно заметить тенденцию, что метод checkoutShoppingCart
становится неподдерживаемым беспорядком.
Растущая сложность enterprise приложений часто приводит к плохой архитектуре, и организация тратит больше и больше денег на создание IT систем. Event-driven архитектура призвана частично решить эту проблему путем снижения связанности компонентов ПО или сервисов. Целью event-driven архитектуры является построение систем, в которых компоненты слабо связаны (loosely coupled).
Ключевым понятием в событийно-ориентированной архитектуре является событие. Событие — это значительное изменение состояния. События передаются между слабо связанными сервисами и представляют этапы в каком-то бизнес-процессе. Сервис подписывается, наблюдает (observe) за событиями и реагирует на них.
Шаблон Observer (наблюдатель) помогает понять концепцию event-driven архитектуры. В шаблоне Observer объект, который называют subject (субъект), содержит список объектов, которые называются observers (наблюдатели), и оповещает их о любом изменении состояния. Использование событий и наблюдателей позволяет сделать сервисы слабо связанными.
Стоит сказать, что называется слабой связанностью (loose coupling). Компоненты слабо связаны, если у них очень мало или вообще нет никакого знания друг о друге. Связанность относится к классам, интерфейсам, сервисам, компонентам ПО. Когда класс содержит ссылку на другой конкретный класс, который предоставляет определенный функционал, они связанны сильно (tightly coupled). Когда используются события и наблюдатели, класс, который создает событие (fire event), ничего не знает о классах, которые наблюдают за событиями и реагируют на них.
Event-driven архитектура помогает создавать высокопроизводительные и высокодоступные системы. Проектирование асинхронных от начала и до конца систем позволяет минимизировать время блокировок потоков на IO операциях и использовать пропускную способность сети на полную мощность. Все наблюдатели могут реагировать на оповещение о событии параллельно, заставляя многоядерные процессоры и кластеры работать на самой высокой мощности. Когда распределенная системы работает в кластере, события могут быть доставлены на любой узел кластера, предоставляя прозрачную балансировку нагрузки (load-balancing) и отказоустойчивость (failover).
События можно использовать и в Java EE CDI приложениях. Рассмотрим следующий пример.
Класс ShoppingOrderEvent
определяет событие, используя свойства, у которых есть getter и setter методы.
private ShoppingOrder order; /*...*/ public ShoppingOrderEvent() { }
События обрабатываются, используя метод-наблюдатель.
public void persistInDatabase(@Observes ShoppingOrderEvent event) { /*...*/ } public void sendEmailNotification(@Observes ShoppingOrderEvent event) { /*...*/ } public void shipToTheNearestWarehouse(@Observes ShoppingOrderEvent event) { /*...*/ } public void scheduleShippingToCustomer(@Observes ShoppingOrderEvent event) { /*...*/ } public void exportToERP(@Observes ShoppingOrderEvent event) { /*...*/ }
Чтобы создать событие (fire event) и оповестить методы-наблюдатели, необходимо вызвать метод javax.enterprise.event.Event#fire(T)
.
@Inject private Event<ShoppingOrderEvent> orderEvent; public void checkoutShoppingCart(ShoppingOrder order) { ShoppingOrderEvent orderEventPayload = new ShoppingOrderEvent(); /*...*/ orderEvent.fire(orderEventPayload); }
Каждый метод-наблюдатель и метод, создающий событие, может находиться в разных классах и пакетах.
Отправители и получатели событий полностью независимы и ничего не знают друг о друге. Таким образом достигается максимально слабая связанность.
Говоря о event-driven архитектуре, стоит упомянуть о message-oriented middleware (MOM). Message-oriented middleware — это ПО, которое занимается отправкой и доставкой сообщений в распределенных системах. События могут быть представлены в виде сообщений. MOM иногда называют messaging система или message брокер. Популярные реализации MOM: ActiveMQ, HornetQ, Tibco EMS.
Messaging системы предоставляют протоколы или API для отправки и получения сообщений:
— JMS (Java Message Service);
— AMQP (Advanced Message Queuing Protocol);
— STOMP (Simple Text Oriented Messaging Protocol);
— RESTful API;
— Java API.
Messaging системы обычно поддерживают 2 стиля асинхронного обмена сообщениями:
— Point-to-Point;
— Publish-Subscribe.
Java Message Service (JMS) — это стандарт промежуточного По для обработки сообщений, который позволяет приложениям создавать, отправлять, получать и читать сообщения. JMS — это Java API, которое является частью спецификации Java EE.
Рассмотрим пример использования JMS 2.0.
Отправка сообщения, используя JMS выглядит следующим образом.
@Resource(mappedName = "java:jboss/jms/queue/exampleQueue") private Queue exampleQueue; @Inject private JMSContext context; /*...*/ public void sendMessage(String text) { context.createProducer().send(exampleQueue, text); }
Синхронное получение сообщений в JMS:
@Resource(mappedName = "java:jboss/jms/queue/exampleQueue") private Queue exampleQueue; @Inject private JMSContext context; /*...*/ public String receiveMessage() { return context.createConsumer(exampleQueue) .receiveBody(String.class); }
Получение сообщений при помощи with message-driven bean (MDB), EJB который позволяет Java EE приложениям обрабатывать сообщения асинхронно.
@MessageDriven(name = "ExampleMDB", activationConfig = { @ActivationConfigProperty(propertyName = "destinationLookup", propertyValue = "java:jboss/jms/queue/exampleQueue"), @ActivationConfigProperty(propertyName = "destinationType", propertyValue = "javax.jms.Queue"), @ActivationConfigProperty(propertyName = "acknowledgeMode", propertyValue = "Auto-acknowledge")}) public class ExampleMDB implements MessageListener { public void onMessage(Message message) { try { if (message instanceof TextMessage) { TextMessage textMessage = (TextMessage) message; /*...*/ } } catch (JMSException e) { throw new RuntimeException(e); } } }
Шаблон Message Queue:
1) Сообщение отправляется в очередь;
2) Сообщение сохраняется, чтобы предоставить гарантию доставки;
3) Messaging система доставляет сообщение получателю;
4) Получатель обрабатывает и подтверждает получение (acknowledge) сообщения;
5) Сообщение удаляется из очереди, после чего больше недоступно для доставки;
6) Если система падает до того, как messaging система получает acknowledgement от получателя, то при восстановлении (recovery) сообщение снова будет доставлено получателю.
Шаблон Publish-Subscribe:
1) Сообщение отправляется в topic;
2) Каждая подписка получает копию каждого сообщения отправленного в topic;
3) Долговечные (durable) подписки получают все сообщения, отправленные в topic, даже если получатель был недоступен какое-то время;
4) Недолговечные (non durable) подписки получают только те сообщения, которые были отправлены, когда получатель был доступен.
Все JMS провайдеры предоставляют надежную доставку сообщений. Доставка сообщений осуществляется в два этапа:
— На первом этапе сообщение от отправителя попадает в место назначение на брокере;
— На втором этапе сообщение с брокера доставляется получателю.
Сообщение может быть потеряно на одном из трех участков:
— При передаче от отправителя на брокер;
— При передачи с брокера получателю;
— Пока оно находится в памяти брокера, и брокер упадет.
Надежная доставка гарантирует, что доставка сообщения не упадет на любом из этих участков. В первом случае на отправителе будет выброшено исключение о невозможности отправки сообщения, что будет являться гарантией того, что сообщение не будет доставлено, и необходимо повторить попытку, не опасаясь дубликатов. Во втором и третьем случае сам брокер доставит сообщение повторно.
Для обеспечения надежной доставки используется 2 механизма: подтверждение получения (acknowledgment) сообщения и транзакции. Чтобы удостовериться, что сообщение было благополучно получено, messaging система сохраняет сообщения в постоянном хранилище, которое называется журналом. Если брокер упадет до того, как сообщение будет получено, его сохраненная копия будет повторно доставлена при восстановлении.
Сообщения бывают долговечными (durable) и недолговечными (non-durable). Долговечные сообщения будут сохранены и могут пережить падение или рестарт брокера. Недолговечные сообщения не переживут падение или рестарт брокера.
Если брокер упадет, доставка некоторых сообщений может быть не успешной. Такие сообщения возвращаются в JMS queue или topic и будут доставлены повторно.
Чтобы предотвратить засорение системы сообщениями, которые безуспешно доставляются снова и снова, messaging системы вводят концепцию dead letter queue (DLQ) или dead message queue (DMQ). После указанного числа неудачных попыток доставки, сообщение удаляется из очереди и отправляется в DLQ. Messaging систему также вводят концепцию повторной доставки с задержкой (delayed redelivery). Повторная доставка будет запланирована с определенной задержкой, которая может увеличиваться экспоненциально между попытками.
Время, на протяжении которого сообщения находятся в messaging системе, до того как будут удалены, может быть ограничено при помощи параметра timeToLive
при отправке JMS сообщения или свойство priority
класса MessageProducer
. Когда срок хранения истекает, сообщения удаляются из очереди или topic’а и отправляются в expiry очередь (expiry queue).
По умолчанию messaging системы работают в режиме FIFO. Когда сообщениям явно устанавливается приоритет, JMS очередь будет работать как очередь с приоритетами (priority queue). Приоритет сообщения может использоваться для влияния на очередность доставки сообщений. Значение приоритета сообщения может принимать значения от 0 (минимальное) до 9 (максимальное). Сообщения с более высоким приоритетом будут доставлены раньше сообщений с более низким приоритетом. По умолчанию сообщения имеют приоритет 4.
Если бизнес-процесс может быть разбит на набор задач, приоритет сообщений может быть использован для приостановки низкоприоритетных бизнес-процессов, если высокоприоритетный бизнес-процесс был начат. Задачи с высоким приоритетом будут обработаны как можно скорее, а задачи с низким приоритетом будут обработаны, когда появятся свободные вычислительные мощности.
Алгоритм может выглядеть следующим образом:
1) Отправить сообщение с определенным приоритетом в очередь, чтобы стартовать бизнес-процесс;
2) Сервис, который получает сообщения, должен выполнить соответствующие действия и в конце отправить еще одно сообщение в следующую очередь, чтобы продолжить бизнес-процесс, сохраняя оригинальный приоритет сообщения;
3) Если в очереди есть сообщения с более высоким приоритетом и вычислительных ресурсов недостаточно, сообщения с более высоким приоритетом будут обработаны раньше и только потом будут обработаны эти сообщения с более низким приоритетом;
4) Бизнес-процесс с низким приоритетом будет «приостановлен» чтобы дать возможность завершиться бизнес-процессу с высоким приоритетом.
Большинство messaging систем предоставляют возможность запланировать доставку сообщения. Это свойство будет полезно в случаях, когда:
— Бизнес-процесс не должен начаться сразу после отправки сообщения;
— Бизнес-процесс должен быть отменен после определенного timeout’а.
Пример запланированной доставки сообщения в HornetQ.
/*...*/ TextMessage message = session.createTextMessage( "This is a scheduled message message which will be delivered in 5 sec."); message.setLongProperty("_HQ_SCHED_DELIVERY", System.currentTimeMillis() + 5000); producer.send(message); /*...*/ // message will not be received immediately but 5 seconds later TextMessage messageReceived = (TextMessage) consumer.receive(); /*...*/
Чтобы отменить бизнес-процесс после определенного timeout’а достаточно отправить сообщение с максимальным приоритетом с запланированной доставкой после указанного timeout’а. Если получив сообщение соответствующее запросу на отмену бизнес-процесса, сам бизнес-процесс еще не завершился, будут предприняты соответствующие действия для его отмены.
Несмотря на то, что спецификации JMS существует с 2001 года, а в 2013 получила обновление до версии 2.0, она все еще имеет недостатки и ограничения. К таким недостаткам можно отнести отсутствие в стандарте возможности подтвердить получение (acknowledgment) индивидуально для каждого сообщения и в последовательности, отличной от той, в которой они были получены. В режиме CLIENT_ACKNOWLEDGE
при вызове метода javax.jms.Message#acknowledge()
будут подтверждено получение всех сообщений, полученных текущей сессией. ActiveMQ, Tibco EMS и другие реализации JMS предоставляют собственные acknowledgment режимы, которые не являются частью стандарта, и позволяют подтвердить получение каждого отдельного сообщения. Например, режим INDIVIDUAL_ACKNOWLEDGE
в ActiveMQ, который без сомнений является очень полезным.
Еще одним недостатком стандарта JMS является отсутствие негативных подтверждений получения сообщения. В некоторых случаях, если сообщение вызвало исключение в процессе обработки, это сообщение не будет доставлено повторно брокером, пока оригинальная сессия получателя не отключится. Было бы намного удобнее, если бы спецификация предоставляла способ сделать негативное подтверждение получения (negative acknowledgment), чтобы сервер повторно доставил это сообщение той же или другой сессии. JMS предоставляет метод javax.jms.Session#recover()
, который останавливает получение новых сообщений сессией и возвращает все сообщение, на которые не были отправлены оповещения о получении обратно на брокер для повторной доставки. Как и в случае с CLIENT_ACKNOWLEDGE
, Session#recover()
не позволяет вернуть конкретное сообщение на брокер. Например, messaging система RabbitMQ, которая является реализацией AMQP, позволяет сделать негативное подтверждение получения сообщения при помощи методов basic.reject
и basic.nack
.
JMS сессии могут участвовать в распределенных транзакциях. Отправка и получение сообщений может быть частью больших распределенных транзакций, которые включают операции с другими ресурсами, как база данных. Менеджер распределенных транзакций (transaction manager) должен использоваться для работы распределенных транзакций. Такие менеджеры транзакций предоставляются Java EE серверами приложений.
Распределенные транзакции с двухфазным commit’ом (two-phase commit, 2PC) также называются XA транзакциями. Поддержка распределенных транзакций означает, что клиенты могут участвовать в распределенных транзакциях, используя интерфейс XAResource
, предоставляемый JTA. Этот интерфейс предоставляет набор методов, которые используются в реализации двухфазного commit’а. Фаза 1 — подготовка (prepare). Координатор транзакций просит всех участников транзакции пообещать сделать commit или rollback транзакции. Если какой-то из ресурсов не может подготовиться, транзакция откатывается. Фаза 2 — сommit или rollback. Если все участники ответили координатору, что они готовы (prepared), координатор просит все ресурсы сделать commit транзакции.
2PC делается автоматически менеджером транзакций, который обычно является частью Java EE сервера приложений, и не требует дополнительных усилий от рахработчика.
Best effort one-phase commit (best effort 1PC) — еще один подход для работы с распределенными транзакциями. Best effort 1PC шаблон синхронизирует однофазные commit’ы нескольких ресурсов. JMS транзакция начинается до транзакции базы данных, и они заканчиваются (commit или rollback) в противоположном порядке:
1) Начать транзакцию JMS;
2) Получить сообщение;
3) Начать транзакцию базы данных;
4) Обновить базу данных;
5) Сделать commit транзакции базы данных;
6) Сделать commit транзакции JMS.
Если commit базы данных падает, JMS транзакция откатывается. Если commit базы данных происходит успешно, но commit JMS транзакции падает, это приведет к повторному получению сообщения при повторной доставке (дубликат). Best effort 1PC может использоваться в системах, которые способны надлежащим образом обрабатывать дубликаты.
Пример best effort 1PC в Spring.
<!--...--><bean id="nonTransactionalConnectionFactory" class="org.springframework.jms.connection.UserCredentialsConnectionFactoryAdapter"><property name="targetConnectionFactory" ref="hornetQConnectionFactory"/><property name="username" value="guest"/><property name="password" value="guest"/></bean><bean id="connectionFactory" class="org.springframework.jms.connection.TransactionAwareConnectionFactoryProxy"><property name="targetConnectionFactory" ref="nonTransactionalConnectionFactory"/><property name="synchedLocalTransactionAllowed" value="true"/></bean><!--...--><bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"><constructor-arg ref="dataSource"/></bean><tx:annotation-driven transaction-manager="transactionManager"/><!--...--><jms:listener-container connection-factory="connectionFactory" transaction-manager="transactionManager" concurrency="10"><jms:listener destination="exampleQueue" ref="myListener"/></jms:listener-container><!--...--><bean id="jmsTemplate" class="org.springframework.jms.core.JmsTemplate"><property name="connectionFactory" ref="connectionFactory"/><property name="sessionTransacted" ref="true"/></bean><!--...-->
Общепринятой практикой является полагаться на повторную доставку сообщений (redelivery), когда откатывается распределенная транзакция. Если для бизнес-процесса допустимо повторное выполнение, обработка исключений в коде может быть пропущена. Вся распределенная транзакция может быть откачена, сообщение вернется в очередь и будет доставлено получателям, которые доступны в данный момент, для повторной обработки.
В данной статье мы рассмотрели, какие механизмы, предоставляемые Java EE, могут использоваться для создания приложения с event-driven архитектурой. О технологиях, которые вы используете для создания приложений с event-driven архитектурой, о подходах работы с ними пишите в комментариях.