ユーザー登録が完了したあとにウェルカムメールを送る、注文が確定したら在庫サービスを更新する。こういった処理を UserService の中から MailService.sendWelcome() を直接呼ぶと、気づいたときにはサービスクラス間の依存がスパゲッティになっていますよね。
かといってKafkaを導入するのは大げさ……そんなときに役立つのが Spring の ApplicationEvent です。JVMプロセス内で使えるシンプルなイベントバスで、数十行のコードでサービス間の依存を断ち切れます。
ApplicationEventとは
Springが提供するJVM内のイベントバス機能です。 発行側 (Publisher)は「こういうことが起きた」とイベントを投げるだけで、 購読側 (Listener)がそれを受け取って処理します。お互いを直接知らなくてよいのがポイントです。
Kafkaなど外部ブローカーとの使い分けはシンプルで、同一JVMプロセス内で完結するなら ApplicationEvent、別プロセスへの通知が必要なら外部ブローカーを使いましょう。
カスタムイベントクラスを作る
Spring 4.2以降はPOJOをそのままイベントとして使えます。ApplicationEvent を継承する必要はありません。
public class UserRegisteredEvent {
private final Long userId;
private final String email;
public UserRegisteredEvent(Long userId, String email) {
this.userId = userId;
this.email = email;
}
public Long getUserId() { return userId; }
public String getEmail() { return email; }
}
イベントクラスにはその時点で必要なデータを持たせておくと、リスナー側でDBを再引きする手間が省けます。
ApplicationEventPublisherでイベントを発行する
ApplicationEventPublisher をDIして publishEvent() を呼ぶだけです。
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void register(String email) {
User user = userRepository.save(new User(email));
eventPublisher.publishEvent(new UserRegisteredEvent(user.getId(), email));
}
}
発行側は誰がこのイベントを受け取っているか知りません。リスナーを追加・削除しても UserService には一切手を入れなくてよいのが疎結合の恩恵です。
@EventListenerで購読する
Beanのメソッドに @EventListener を付けるだけでリスナーになれます。
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {
private final MailService mailService;
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
mailService.sendWelcome(event.getEmail());
}
}
引数の型でどのイベントを受け取るか決まります。複数の型を受け取りたい場合は @EventListener(classes = {EventA.class, EventB.class}) のように指定できます。
@TransactionalEventListenerでコミット後に処理する
先ほどのコードには落とし穴があります。@Transactional メソッド内でイベントを発行しても、トランザクションがロールバックされた場合にリスナーが動いてしまいます。ユーザー登録が失敗したのにメールが飛ぶのは困りますよね。
@TransactionalEventListener を使うと、トランザクションのフェーズに紐づいてリスナーを実行できます。
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {
private final MailService mailService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserRegistered(UserRegisteredEvent event) {
mailService.sendWelcome(event.getEmail());
}
}
AFTER_COMMIT(デフォルト)はコミット成功後のみ実行されます。AFTER_ROLLBACK はロールバック後、AFTER_COMPLETION はコミット・ロールバックどちらの後でも実行されます。
ひとつ注意点があります。AFTER_COMMIT フェーズではトランザクションがすでに終了しているため、リスナー内でDBへの書き込みを行いたい場合は @Transactional(propagation = Propagation.REQUIRES_NEW) で新しいトランザクションを明示的に開始する必要があります。詳しくは Spring Bootのトランザクション管理 も参考にしてください。
@Asyncと組み合わせた非同期ハンドリング
メール送信など時間のかかる処理はメインスレッドをブロックしたくないですよね。@Async と組み合わせれば非同期化できます。
まず @EnableAsync を有効にします。
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}
}
あとはリスナーに @Async を追加するだけです。
@Async
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
mailService.sendWelcome(event.getEmail());
}
@TransactionalEventListener と @Async を組み合わせる場合、トランザクションコンテキストは非同期スレッドに引き継がれません。リスナー内でトランザクションが必要な場合は @Transactional(propagation = Propagation.REQUIRES_NEW) で新たに開始しましょう。
非同期処理の詳細は Spring Bootで非同期処理を実装する方法 も参考にしてください。
複数リスナーの実行順序を@Orderで制御する
同じイベントに複数のリスナーがある場合、@Order で実行順を指定できます。値が小さいほど先に実行されます。
@EventListener
@Order(1)
public void auditLog(UserRegisteredEvent event) { ... }
@EventListener
@Order(2)
public void sendNotification(UserRegisteredEvent event) { ... }
ただし、リスナー間に順序依存が生まれると疎結合のメリットが薄れます。各リスナーが独立して動けるよう設計するのが理想です。
テストで検証する
Spring 5.3以降では ApplicationEvents を使ってイベント発行を簡単に検証できます。
@SpringBootTest
@RecordApplicationEvents
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private ApplicationEvents applicationEvents;
@Test
void 登録時にUserRegisteredEventが発行される() {
userService.register("[email protected]");
assertThat(
applicationEvents.stream(UserRegisteredEvent.class).count()
).isEqualTo(1);
}
}
@RecordApplicationEvents を付けるとコンテキスト内で発行されたイベントが記録され、ApplicationEvents で検証できます。発行側と購読側を分離してテストしやすいのも ApplicationEvent の利点です。
まとめ
ApplicationEvent はKafkaを導入するほどではないけれど、サービス間の依存を整理したいというシーンにぴったりです。@EventListener で基本的な購読が書けて、@TransactionalEventListener でトランザクション安全な処理、@Async で非同期化と、段階的に機能を足していけるのが使い勝手がいいですよね。
向かないのは別プロセスへの通知やシステム再起動をまたいだ確実なメッセージ配信が必要なケースです。そのような要件では Spring BootでKafkaのProducer・Consumerを実装する を参照してください。