Статьи

CDI & EJB: отправка асинхронной почты при успешной транзакции

И снова здравствуйте! 🙂

На этот раз я выбрал общую задачу, которая, по моему мнению, в большинстве случаев выполняется неправильно: отправка электронной почты. Не то чтобы люди не могли понять, как работают API электронной почты, такие как JavaMail или Apache Commons-Email . Обычно я вижу проблему в том, что они недооценивают необходимость сделать процедуру отправки почты асинхронной и что она также должна запускаться только при успешной фиксации базовой транзакции (большую часть времени).

Подумайте об общем случае использования, когда пользователь совершает покупки в Интернете. Когда он закончит, он, вероятно, захочет получить электронное письмо с подтверждением заказа. Процесс оформления заказа довольно сложен: мы обычно вставляем записи во множество различных таблиц, а также, возможно, удаляем записи для удаления товаров со склада и так далее. Все это, конечно, должно быть выполнено в одной атомарной транзакции:

01
02
03
04
05
06
07
08
09
10
11
12
13
//A sample EJB method
//(using CMT for transaction management)
public void saveOrder() {
    //saving some products
    entityManager.persist(product1);
    entityManager.persist(product2);
 
    //removing them from stock
    entityManager.remove(product1);
 
    //and at last, we have to send that email
    sendOrderConfirmationMail(); //the transaction has not yet been commited by this point
}

Как и в случае с псевдокодом выше, мы обычно стремимся не допустить логику транзакций в наш код. То есть мы используем CMT (транзакции, управляемые контейнером), чтобы контейнер делал все за нас и сохранял наш код чище. Таким образом, сразу после того, как наш вызов метода завершен, контейнер EJB фиксирует нашу транзакцию. Это проблема номер 1: когда вызывается метод sendOrderConfirmationMail () , мы не можем знать, будет ли транзакция успешной. Пользователь может получить подтверждение для несуществующего заказа.

Если это то, что вы еще не поняли, просто запустите тестирование в любом из ваших кодов. Эти вызовы entityManager.persist () не запускают никаких команд базы данных до тех пор, пока не завершится вызов метода. Просто поставьте точку останова и убедитесь сами. Я видел такие беспорядки много раз.

Поэтому в случае отката нам не нужно отправлять письма. Все может пойти не так по ряду причин: сбой системы, некоторые бизнес-правила могут отказать в покупке, проверка кредитной карты и т. Д.

Итак, мы уже знаем, что при использовании CMT нам может быть трудно узнать, успешна ли транзакция или нет. Следующая проблема — сделать процедуру рассылки асинхронной, полностью независимой от нашей процедуры заказа. Представьте себе, что, если все идет хорошо с процессом заказа, но возникает какое-то исключение при попытке отправить электронное письмо? Должны ли мы откатить все только потому, что наше письмо-подтверждение не может быть отправлено? Должны ли мы действительно запретить пользователю покупать в нашем магазине только потому, что у нашего почтового сервера плохой день?

Я знаю, что подобные бизнес-требования могут идти в любом направлении, но также следует помнить, что обычно желательно, чтобы внутренняя задержка отправки почты не мешала обработке заказа. В большинстве случаев обработка заказа является нашей главной целью. Задачи с низким приоритетом, такие как отправка электронных писем, могут даже быть отложены на время, когда нагрузка на сервер низкая.

Вот так

Чтобы решить эту проблему, я выбрал подход чистой Java EE. Не нужно использовать сторонние API. Наша среда включает в себя:

  • JDK 7 или выше.
  • Java EE 7 (JBoss Wildfly 8.1.0)
  • CDI 1.1
  • EJB 3.2
  • JavaMail 1.5

Я создал небольшой веб-проект, чтобы вы могли видеть, как все работает, скачайте его здесь, если хотите .

Прежде чем углубляться в код, просто кратко рассмотрим следующее: решение, показанное ниже, состоит в основном из событий CDI, смешанных с асинхронными вызовами EJB. Это потому, что спецификация CDI 1.1 не обеспечивает асинхронную обработку событий. Кажется, это что-то обсуждается для спецификации CDI 2.0, все еще в разработке. По этой причине чистый подход CDI может быть сложным. Я не говорю, что это невозможно, я просто даже не пытался.

Пример кода — это лишь пример для варианта использования «Зарегистрировать клиента». Куда мы отправим письмо для подтверждения регистрации пользователя. Общая архитектура выглядит примерно так:

Обзор-архитектура

Пример кода также представляет собой «тестовый случай неудачи», так что вы можете увидеть, что при откате письмо не отправляется. Я только показываю вам «счастливый путь», начиная с Managed Bean, вызывающего наш CustomerService EJB. Ничего интересного, только шаблонный

управляемый боб

Внутри нашего CustomerService EJB все становится интересным. Используя API-интерфейс CDI, мы запускаем событие MailEvent в конце метода saveSuccess () :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Stateless
public class CustomerService {
     
    @Inject
    private EntityManager em;
     
    @Inject
    private Event<MailEvent> eventProducer;
     
    public void saveSuccess() {
        Customer c1 = new Customer();
        c1.setId(1L);
        c1.setName("John Doe");
        em.persist(c1);
         
        sendEmail();
    }
 
    private void sendEmail() {
        MailEvent event = new MailEvent();
        event.setTo("[email protected]");
        event.setSubject("Async email testing");
        event.setMessage("Testing email");
 
        eventProducer.fire(event); //firing event!
    }
}

Класс MailEvent — это обычный POJO, представляющий наше событие. Он содержит информацию о сообщении электронной почты: получателе, теме, текстовом сообщении и т. Д .:

1
2
3
4
5
6
7
public class MailEvent {
    private String to; //recipient address
    private String message;
    private String subject;
 
    //getters and setters
}

Если вы новичок в CDI и все еще немного озадачены этим событием, просто прочитайте документы . Это должно дать вам идею.

Затем пришло время для наблюдателя событий, EJB MailService . Это простой EJB с некоторой магией JavaMail и парочкой аннотаций, на которые вы должны обратить внимание:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Singleton
public class MailService {
     
    @Inject
    private Session mailSession; //more on this later
     
    @Asynchronous
    @Lock(LockType.READ)
    public void sendMail(@Observes(during = TransactionPhase.AFTER_SUCCESS) MailEvent event) {
        try {
            MimeMessage m = new MimeMessage(mailSession);
            Address[] to = new InternetAddress[] {new InternetAddress(event.getTo())};
 
            m.setRecipients(Message.RecipientType.TO, to);
            m.setSubject(event.getSubject());
            m.setSentDate(new java.util.Date());
            m.setContent(event.getMessage(),"text/plain");
             
            Transport.send(m);
        } catch (MessagingException e) {
            throw new RuntimeException(e);
        }
   }
}

Как я уже сказал, это обычный EJB. Что делает этот класс наблюдателем событий, точнее, методом sendMail () , так это аннотацией @Observe в строке 9. Одна только эта аннотация заставит этот метод работать после запуска события.

Но нам нужно, чтобы это событие было запущено только при совершении транзакции ! Откат не должен вызывать электронную почту. Вот тут и вступает атрибут «while». Указывая значение TransactionPhase.AFTER_SUCCESS, мы гарантируем, что событие инициируется только в случае успешной фиксации транзакции.

И последнее, но не менее важное: нам также нужно заставить эту логику работать в отдельном потоке от нашей основной логики. Он должен работать асинхронно. И для этого мы просто использовали две аннотации EJB, @Asynchronous и @Lock (LockType.READ) . Последний, @Lock (LockType.READ) не требуется, но настоятельно рекомендуется. Это гарантирует, что блокировки не используются, и несколько потоков могут использовать метод одновременно.

Настройка почтового сеанса в JBoss Wildfly 8.1.0

В качестве бонуса я покажу, как мы можем правильно настроить «источник» почты в JBoss WildFly. Почтовые источники во многом похожи на источники данных, за исключением того, что они предназначены для отправки электронной почты, а не для базы данных :). Это способ отделить код от соединения с почтовым сервером. Я использовал соединение с моей учетной записью Gmail, но вы можете переключиться на что угодно, не трогая код внутри класса MailService .

Объект javax.mail.Session можно получить по его имени JNDI с помощью аннотации @Resource :

1
2
@Resource(mappedName = "java:jboss/mail/Gmail")
private Session mailSession;

Вы, наверное, заметили, что в моих предыдущих фрагментах кода я не использовал аннотацию @Resource , я использовал только @Inject CDI. Ну, если вам интересно, как я это сделал, просто скачайте исходный код и посмотрите. ( подсказка: я использовал класс помощника продюсера .)

Двигаясь дальше, просто откройте standalone.xml (или domain.xml, если вы находитесь в режиме домена) и сначала найдите «почтовую подсистему». Это должно выглядеть так:

1
2
3
4
5
<subsystem xmlns="urn:jboss:domain:mail:2.0">
    <mail-session name="default" jndi-name="java:jboss/mail/Default">
        <smtp-server outbound-socket-binding-ref="mail-smtp"/>
    </mail-session>
</subsystem>

По умолчанию почтовый сеанс уже запущен на localhost. Поскольку на ваших машинах разработки у нас, вероятно, нет почтовых серверов, мы добавим новый, указывающий на gmail:

1
2
3
4
5
6
7
8
<subsystem xmlns="urn:jboss:domain:mail:2.0">
    <mail-session name="default" jndi-name="java:jboss/mail/Default">
        <smtp-server outbound-socket-binding-ref="mail-smtp"/>
    </mail-session>
    <mail-session name="gmail" jndi-name="java:jboss/mail/Gmail" from="[email protected]">
        <smtp-server outbound-socket-binding-ref="mail-gmail" ssl="true" username="[email protected]" password="your-password"/>
    </mail-session>
</subsystem>

Посмотрите, как строки 5, 6 и 7 выделены. Это наш новый почтовый сеанс. Но это не все. Нам все еще нужно создать привязку сокета к нашему новому почтовому сеансу. Поэтому внутри standalone.xml ищите элемент с именем socket-binding-group :

1
2
3
4
5
6
7
8
9
<socket-binding-group name="standard-sockets" default-interface="public" port-offset="${jboss.socket.binding.port-offset:0}">
 
    <!-- a bunch of stuff here -->
 
    <outbound-socket-binding name="mail-smtp">
        <remote-destination host="localhost" port="25"/>
    </outbound-socket-binding>
         
</socket-binding-group>

Теперь мы добавляем наш порт gmail к существующим, создавая новый элемент привязки исходящих сокетов :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
<socket-binding-group name="standard-sockets" default-interface="public" port-offset="${jboss.socket.binding.port-offset:0}">
 
    <!-- a bunch of stuff here -->
 
    <outbound-socket-binding name="mail-smtp">
        <remote-destination host="localhost" port="25"/>
    </outbound-socket-binding>
 
    <!-- "mail-gmail" is the same name we used in the mail-session config -->
    <outbound-socket-binding name="mail-gmail">
        <remote-destination host="smtp.gmail.com" port="465"/>
    </outbound-socket-binding>
         
</socket-binding-group>

Это оно. Пожалуйста, оставьте комментарий, если у вас есть какие-либо вопросы :). Позже!