Spring Boot事務發件箱模式

banq發表於2024-07-08

如果您正在構建微服務架構,或者您只需要從整體式(單體)架構傳送電子郵件,則應該研究事務發件箱模式以確保服務之間的可靠通訊。這篇博文介紹了幾種使用 Spring Boot 實現此目的的方法。

什麼是事務發件箱模式?
克里斯·理查森(Chris Richardson)撰寫的《微服務模式》一書向我介紹了這個概念。

事務發件箱是一種確保兩個系統同步的方法,無需在這些系統之間使用分散式事務。一個簡單的例子是將客戶的訂單儲存在資料庫中,併傳送電子郵件來確認訂單。

如果我們簡單地實現這一點,我們可以這樣做:

@Component
@Transactional
public class CompleteOrder {
  private final OrderRepository orderRepository;
  private final MailSender mailSender;

  public CompleteOrder(OrderRepository orderRepository, MailSender mailSender) {
    this.orderRepository = orderRepository;
    this.mailSender = mailSender;
  }

  public void execute(CompleteOrderParameters parameters) {
    Order order = createOrder(parameters);
    this.orderRepository.save(order);
    this.mailSender.notifyOrderRegistered(order);
  }
}

該類CompleteOrder是儲存訂單併傳送電子郵件的用例。但是,如果出現問題怎麼辦?如果郵件提供商出現故障,則郵件永遠不會傳送給客戶。更糟糕的是,交易將回滾,使用者會收到錯誤。郵件伺服器不存在不是客戶的錯。我們應該在幾分鐘後當郵件伺服器恢復正常執行時重試傳送電子郵件。

使用事務發件箱模式,我們可以透過儲存我們應該先執行某些外部操作(傳送電子郵件、將訊息放入佇列等)的事實來避免此問題。然後,非同步程序可以檢視資料庫以瞭解還需要發生什麼,並且可以在有時間時執行這些操作。如果外部系統不可用,則可以稍後重試該任務,直到成功為止。

使用 Spring Integration
我們可以使用Spring Integration來實現發件箱模式。這可以透過設定一個整合流來實現,該整合流將電子郵件訊息作為輸入,並將其傳遞到 JDBC 支援的輸出,並使用輪詢處理程式傳送郵件。

專案設定
作為示例,讓我們在start.spring.io上建立一個 Spring Boot 專案,其配置如下:

專案:Maven
語言:Java
Spring Boot:3.3.0
Java:21
依賴項:

  • Spring Web
  • Spring Data JPA
  • Spring 整合
  • Docker Compose 支援
  • PostgreSQL 驅動程式

遷徙路線
在生成的中pom.xml,手動新增spring-integration-jdbc依賴項:

pom.xml
  <dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-jdbc</artifactId>
  </dependency>

Spring Integration 設定

首先,我們透過新增此配置來配置 Spring Integration 本身:

SpringIntegrationConfiguration.java
package com.wimdeblauwe.examples.transactional_outbox_spring_integration.infrastructure.integration;

import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.jdbc.store.JdbcChannelMessageStore;
import org.springframework.integration.jdbc.store.channel.PostgresChannelMessageStoreQueryProvider;

@Configuration
public class SpringIntegrationConfiguration {

  private static final String CONCURRENT_METADATA_STORE_PREFIX = <font>"_spring_integration_";

  @Bean
  JdbcChannelMessageStore jdbcChannelMessageStore(
      DataSource dataSource) {
    JdbcChannelMessageStore jdbcChannelMessageStore = new JdbcChannelMessageStore(dataSource);
    jdbcChannelMessageStore.setTablePrefix(CONCURRENT_METADATA_STORE_PREFIX);
    jdbcChannelMessageStore.setChannelMessageStoreQueryProvider(
        new PostgresChannelMessageStoreQueryProvider());
    return jdbcChannelMessageStore;
  }
}

這個 bean 將會把我們新增到發件箱 Spring Integration 通道的物件持久儲存在資料庫中。

為了建立適當的表,我們使用了 Flyway 指令碼,您可以在 GitHub上檢視。

接下來,我們定義郵件的整合流程:

MailConfiguration.java

import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.channel.QueueChannel;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.MessageChannels;
import org.springframework.integration.dsl.Pollers;
import org.springframework.integration.jdbc.store.JdbcChannelMessageStore;

@Configuration
public class MailConfigration {

  @Bean
  public DirectChannel mailInput() {
    return new DirectChannel();
  }

  @Bean
  public QueueChannel mailOutbox(JdbcChannelMessageStore jdbcChannelMessageStore) {
    return MessageChannels.queue(jdbcChannelMessageStore, <font>"mail-outbox").getObject();
  }

  @Bean
  public IntegrationFlow mailFlow(JdbcChannelMessageStore jdbcChannelMessageStore,
      MailSender mailSender) {
    return IntegrationFlow.from(mailInput())
        .channel(mailOutbox(jdbcChannelMessageStore))
        .handle(message -> {
          MailMessage mailMessage = (MailMessage) message.getPayload();
          mailSender.sendMail(mailMessage);
        }, e -> e.poller(Pollers.fixedDelay(Duration.ofSeconds(1))
            .transactional()))
        .get();
  }
}

該配置有3個bean:
  1. mailInputMailMessage:這是將接收要傳送的輸入通道。
  2. mailOutbox:這是訊息路由到的通道,將使用JdbcChannelMessageStore我們在SpringIntegrationConfiguration類中配置的儲存訊息。
  3. mailFlowmailInput:這定義了從到 的實際流程,mailOutbox並新增了一個handle()實際傳送電子郵件的方法。它mailOutput每秒輪詢一次 以檢視是否有郵件要傳送。由於 ,transactional()訊息將保留在 上,mailOutbox直到傳送成功。

該配置類使用了2個尚未解釋的類:MailMessage和MailSender。

該類MailMessage是包含傳送電子郵件所需資訊的記錄:

MailMessage.java

import java.io.Serial;
import java.io.Serializable;

public record MailMessage(String subject, String body, String to) implements Serializable {

  @Serial
  private static final long serialVersionUID = 1L;
}

請注意我們需要如何建立類Serializable以便 Spring Integration 可以將其儲存在資料庫中。

這MailSender是一個可以根據您想要傳送電子郵件的方式以多種方式實現的介面:

MailSender.java

package com.wimdeblauwe.examples.transactional_outbox_spring_integration.infrastructure.mail;

public interface MailSender {

  void sendMail(MailMessage mailMessage);
}

為了進行測試,我實現了一個不可靠的郵件傳送器,它會隨機記錄或丟擲異常。實際上,您可能會使用 Java Mail 連線到 SMTP 伺服器,或者使用 SendGrid 或 Amazon SES 等服務傳送電子郵件。

LoggingMailSender.java

import java.util.random.RandomGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
public class LoggingMailSender implements
    MailSender {

  private static final Logger LOGGER = LoggerFactory.getLogger(LoggingMailSender.class);
  private final RandomGenerator randomGenerator = RandomGenerator.getDefault();

  @Override
  public void sendMail(MailMessage mailMessage) {
    if (randomGenerator.nextBoolean()) {
      LOGGER.info(<font>"Sending email: {}", mailMessage);
    } else {
      throw new RuntimeException(
"Email server down");
    }
  }
}

從應用程式傳送電子郵件
為了利用 Spring Integration 流程,我們需要建立一個訊息閘道器。這可以透過帶有註釋的介面完成@MessagingGateway:


import org.springframework.integration.annotation.Gateway;
import org.springframework.integration.annotation.MessagingGateway;

@MessagingGateway
public interface MailGateway {

  @Gateway(requestChannel = <font>"mailInput")
  void sendMail(MailMessage mailMessage);
}

注意,名稱requestChannel必須與類中輸入通道的 bean 的名稱相匹配MailConfiguration。

我們不需要提供實現。Spring Integration 將在執行時為我們實現它。

使用此閘道器的示例用例可能如下所示:


import com.wimdeblauwe.examples.transactional_outbox_spring_integration.infrastructure.mail.MailGateway;
import com.wimdeblauwe.examples.transactional_outbox_spring_integration.infrastructure.mail.MailMessage;
import com.wimdeblauwe.examples.transactional_outbox_spring_integration.order.Order;
import com.wimdeblauwe.examples.transactional_outbox_spring_integration.order.repository.OrderRepository;
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@Transactional
public class CompleteOrder {

  private static final Logger LOGGER = LoggerFactory.getLogger(CompleteOrder.class);
  private final OrderRepository orderRepository;
  private final MailGateway mailGateway;

  public CompleteOrder(OrderRepository orderRepository, MailGateway mailGateway) {
    this.orderRepository = orderRepository;
    this.mailGateway = mailGateway;
  }

  public void execute(BigDecimal amount, String email) {
    LOGGER.info(<font>"Completing order for {}", email);
    Order order = new Order();
    order.setAmount(amount);
    order.setCustomerEmail(email);

    LOGGER.info(
"Save order in database");
    orderRepository.save(order); 

    MailMessage message = new MailMessage(
"Order %s completed".formatted(order.getId()),
       
"Your order is registered in our system and will be processed.",
        order.getCustomerEmail()); 
    LOGGER.info(
"Sending email for order");
    mailGateway.sendMail(message); 
  }
}

  1. 將其儲存Order在資料庫中。
  2. 編寫電子郵件訊息的資料。
  3. 將資料傳遞給MailGateway傳送電子郵件。

從用例方面來看,似乎我們同步傳送電子郵件,但實際上,與MailMessage儲存在同一個事務中,Order並且郵件本身幾分鐘後非同步傳送。

測試
為了測試一切是否正常,我們可以建立一個 REST 控制器來觸發用例:

import com.wimdeblauwe.examples.transactional_outbox_spring_integration.order.usecase.CompleteOrder;
import java.math.BigDecimal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(<font>"/orders")
public class OrderRestController {

  private final CompleteOrder completeOrder;

  public OrderRestController(CompleteOrder completeOrder) {
    this.completeOrder = completeOrder;
  }

  @PostMapping
  public void completeOrder(@RequestBody CompleteOrderRequest request) {
    completeOrder.execute(request.amount(), request.email());
  }

  public record CompleteOrderRequest(BigDecimal amount, String email) {

  }
}

使用 IntelliJ 或任何其他工具的 HTTP 客戶端傳送請求,我們可以新增一些命令:

POST http:<font>//localhost:8080/orders<i>
Content-Type: application/json

{
 
"amount": "100.0",
 
"email": "test@example.com"
}

如果您檢查應用程式的日誌記錄,您有時會看到無法傳送電子郵件的堆疊跟蹤,但不久之後您就會看到最有可能成功的重試。

我們這裡的示例使用 PostgreSQL,但如果您改用 MySQL,則需要進行一些更改。在底層,Spring Integration 使用SKIP LOCK,但 MySQL 不支援這一點。

您可以執行以下操作使其與 MySQL 一起工作:

1、定義TransactionInterceptor隔離READ_COMMITTED級別為SpringIntegrationConfiguration:

SpringIntegrationConfiguration.java
  @Bean
  public TransactionInterceptor springIntegrationTransactionInterceptor() {
    return new TransactionInterceptorBuilder()
        .isolation(Isolation.READ_COMMITTED)
        .build();
  }

2、更新mailFlowbean 來使用該攔截器:

  @Bean
  public IntegrationFlow mailFlow(JdbcChannelMessageStore jdbcChannelMessageStore,
      MailSender mailSender,
      @Qualifier(<font>"springIntegrationTransactionInterceptor") TransactionInterceptor transactionInterceptor) { 
    return IntegrationFlow.from(mailInput())
        .channel(mailOutbox(jdbcChannelMessageStore))
        .handle(message -> {
          MailMessage mailMessage = (MailMessage) message.getPayload();
          mailSender.sendMail(mailMessage);
        }, e -> e.poller(Pollers.fixedDelay(Duration.ofSeconds(1))
            .transactional(transactionInterceptor))) 
        .get();
  }

  • 將 TransactionInterceptor 宣告為引數,以便 Spring 能注入它。 我們需要使用限定符,以確保獲得我們在 SpringIntegrationConfiguration 中宣告的限定符。
  • 將攔截器interceptor 作為引數用於 Transactional() 方法。


Spring Modulith
Spring Modulith 是 Spring 產品組合中的一個新專案。它由 Oliver Drotbohm 領導,旨在讓使用 Spring 構建模組化單體應用程式變得更加容易。

模組之間的通訊可以透過使用 Spring 核心非同步完成ApplicationEventPublisher。Spring Modulith 具有額外的基礎架構,透過首先將其儲存在資料庫中來確保此類事件永遠不會丟失。我們可以利用這一點來構建我們的發件箱模式。

專案設定
在start.spring.io上建立一個 Spring Boot 專案,配置如下:

專案:Maven
語言:Java
Spring Boot:3.3.0
Java:21
依賴項:

  • Spring Web
  • Spring Data JPA
  • Spring Modulith
  • Docker Compose Support
  • PostgreSQL Driver
  • Flyway Migration

替換spring-modulith-starter-jpa為spring-modulith-starter-jdbc:

pom.xml

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>

在此示例中,我們將從用例中釋出一個OrderCompleted事件。事件本身是一個簡單的記錄,其中包含對訂單 ID 的引用:

public record OrderCompleted(Long orderId) {

}

用例釋出事件,釋出OrderCompleted事件:


@Component
@Transactional
public class CompleteOrder {

  private static final Logger LOGGER = LoggerFactory.getLogger(CompleteOrder.class);
  private final OrderRepository orderRepository;
  private final ApplicationEventPublisher eventPublisher;

  public CompleteOrder(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
    this.orderRepository = orderRepository;
    this.eventPublisher = eventPublisher;
  }

  public void execute(BigDecimal amount, String email) {
    LOGGER.info(<font>"Completing order for {}", email);
    Order order = new Order();
    order.setAmount(amount);
    order.setCustomerEmail(email);

    LOGGER.info(
"Save order in database");
    orderRepository.save(order);

    eventPublisher.publishEvent(new OrderCompleted(order.getId())); 
  }
}

現在我們可以建立一個 Spring 元件來監聽事件併傳送郵件通知:

@Component
public class MailNotifier {

  private static final Logger LOGGER = LoggerFactory.getLogger(MailNotifier.class);
  private final MailSender mailSender;
  private final OrderRepository orderRepository;

  public MailNotifier(MailSender mailSender, OrderRepository orderRepository) {
    this.mailSender = mailSender;
    this.orderRepository = orderRepository;
  }

  @ApplicationModuleListener 
  public void onOrderCompleted(OrderCompleted orderCompleted) {
    Order order = orderRepository.findById(orderCompleted.orderId())
        .orElseThrow(() -> new RuntimeException(<font>"Order not found"));

    MailMessage message = new MailMessage(
"Order %s completed".formatted(order.getId()),
       
"Your order is registered in our system and will be processed.",
        order.getCustomerEmail());
    LOGGER.info(
"Sending email for order {}", orderCompleted.orderId());
    mailSender.sendMail(message);
  }
}

將該方法標記為 @ApplicationModuleListener 方法。 這是 Spring Modulith 提供的一個註解,由以下內容組合而成:
  • @Async:因為我們希望以非同步方式傳送郵件。 我們不希望 CompleteOrder 用例的處理受到電子郵件傳送的影響。
  • @Transactional: 由於我們的監聽器是在單獨的執行緒中執行的,所以我們應該啟動一個新事務來從儲存庫中獲取訂單的狀態。
  • @TransactionalEventListener: 這樣可以確保在包含傳送事件的事務完成時呼叫此方法。 如果事務回滾,我們的監聽器就不會被呼叫。

我們可以再次使用 IntelliJ HTTP 客戶端進行測試,並注意到有時郵件傳送正確,有時傳送失敗(因為我們的郵件傳送器有隨機失敗程式碼)。如果我們檢查資料庫,我們可以看到事件是否已儲存並標記為已釋出:

6fcaa30a-2b36-4f10-a091-4ce10ab520ea

MailNotifier.onOrderCompleted(OrderCompleted)

OrderCompleted

{<font>"orderId":1}

2024-06-13 05:50:43.090615 +00:00

2024-06-13 05:50:43.148320 +00:00

這裡的優點是事件被序列化為 JSON,因此資料庫中可以讀取它所包含的內容。使用 Spring Integration,它使用 Java 序列化,因此您只能獲得毫無意義的位元組。

重試失敗事件
與 Spring Integration 不同,沒有自動重試,但我們可以輕鬆新增它。

第一種方法是設定一個屬性,在應用程式啟動時重試事件:

application.properties
spring.modulith.republish-outstanding-events-on-restart=true

如果您有失敗的事件並重新啟動 Spring Boot 應用程式,您會注意到事情會重試。但是,我懷疑這是否真的有用,因為通常您不會重新啟動應用程式那麼多。

更好的方法是時不時地查詢未釋出的事件並重新發布它們。為了實現這一點,我們可以MailNotifier像這樣更新:

@Component
public class MailNotifier {

  private static final Logger LOGGER = LoggerFactory.getLogger(MailNotifier.class);
  private final MailSender mailSender;
  private final OrderRepository orderRepository;
  private final IncompleteEventPublications incompleteEventPublications;

  public MailNotifier(MailSender mailSender, OrderRepository orderRepository, IncompleteEventPublications incompleteEventPublications)  { 
    this.mailSender = mailSender;
    this.orderRepository = orderRepository;
    this.incompleteEventPublications = incompleteEventPublications;
  }

  @Scheduled(fixedRate = 5, timeUnit = TimeUnit.SECONDS) 
  public void retries() {
    this.incompleteEventPublications.resubmitIncompletePublicationsOlderThan(Duration.ofSeconds(5)); 
  }

  <font>// ... other code below<i>
}

  •  注入 Spring Modulith 的 IncompleteEventPublication 介面。
  • 在公共方法中新增 @Scheduled 並設定一定的輪詢頻率。 在我們的示例中,Spring 將每 5 秒鐘呼叫一次該方法。
  • 重新發布任何超過 5 秒鐘的未完成事件。


透過此設定,應用程式執行時將重試失敗的事件。

訊息排序
Spring Integration 解決方案與 Spring Modulith 解決方案的一個重要區別是,使用 Spring Integration 時,順序會保留,一條訊息失敗將阻止處理下一條訊息。使用 Spring Modulith 時,由於應用程式模組偵聽器是非同步呼叫的,因此將同時執行對各個事件釋出的重試。因此,無法保證它們最終出現在電子郵件伺服器中的順序。

在我們傳送電子郵件的示例中,上一條訊息失敗時無需停止傳送下一條訊息。但在其他場景中(例如將訊息放在 Kafka 上),您可能確實關心訊息順序。

執行多個例項
另一個重要的區別是當您執行應用程式的多個例項時。

使用 Spring Integration,電子郵件將從其中一個例項傳送。因此不會出現重複電子郵件,並且如果執行重試的那個例項失敗,另一個例項將自動接管。

使用 Spring Modulith,如果一切順利,我們也不會傳送重複的電子郵件。但是@Scheduled註釋是由兩個例項完成的,如果有兩個例項在執行,則會導致傳送重複的電子郵件。我們可以透過使用ShedLock來解決這個問題,例如,只有一個例項執行事件重試。

結論
Spring Integration 和 Spring Modulith 都可用於構建事務發件箱,以更確定您的主資料庫操作和對外部系統的任何通知是否同步且不會丟失。然而,Spring Integration 解決方案似乎確實比 Spring Modulith 解決方案有一些優勢。

請參閱GitHub 上的transactional-outbox-spring-integration 和 transactional-outbox-spring-modulith 以獲取這些示例的完整原始碼。

相關文章