AWS eventbridgeとSpringBatchで作るPush通知バッチ

[KINTO Technologies Advent Calendar 2021 - Qiita](https://qiita.com/advent-calendar/2021/kinto-technologies) の8日目の記事です。

## 概要
AWS EventbridgeとECSで動くSpring Batchアプリケーションで通知処理を行うバッチシステムを構築しました。
細かい実装方法やAWSの設定箇所等は省いて、いくつかポイントとなる点をまとめます。
今回のアプリは定期的に対象となるユーザーデータをDBから抽出し、そのユーザーにモバイルPush通知を行います。
＊大量データの登録・参照や、高性能なパフォーマンスを求められるようなユースケースは想定しておりません。
### アーキテクチャ
![スクリーンショット 2021-12-06 14.04.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2338302/c138b5ba-a5b3-6eb6-d2bd-08b904ae7b7f.png)

- CICD: Github Action経由でECSタスク定義の更新、ECR Image登録、EventBridge schedule定義の更新。
- インフラ: AWS Fargate起動タイプのECS、DBはAuroraMySQLを使用。
- App: Spring Boot+Spring Batch
- Push通知: SNSとFCMによるPush通知

### 設計・実装のポイント
#### インフラ
- ECS Fargate起動タイプなのでアプリ起動時に数秒-1分ほどタイムラグが発生します。
- 稀に二重起動する可能性もありますので排他制御の実装も必要です。
- EventBridgeではバッチのリトライ処理などは設定できないのでアプリ側で実装が必要です。
- EventBridgeでスケジュールされたイベントはUTCタイムゾーンで、スケジュールの最小精度は1分となります。

#### アプリケーション
今回は、管理の手間を省くため一つのECRイメージを使用して二つのタスク定義に分けました。
Spring Boot×Spring Batchではapplication.ymlで起動したいJobを設定できるため、パラメータストアからそれぞれのタスク定義を引っ張ってきて、タスク毎に異なるJobを実行させるようにしました。

```java:application.yml
spring:
  batch:
    job:
      names: // <-- ここに実行したいJob名を書く
  messages:
    basename: messages
    encoding: UTF-8
  profiles:
    active: local
```
##### Spring Batch: JobとStep
Spring BatchのTaskletモデルを採用し、下記のようなJobとStepで構成しました。

<img width="400" alt="スクリーンショット 2021-12-06 13.00.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2338302/b4d2a1f0-0356-3e54-b45d-ec67aa9b9d54.png">

```java:BatchConfig.java
public BatchConfig(JobBuilderFactory jobBuilderFactory,
                       StepBuilderFactory stepBuilderFactory,
                       JobATaskLet jobATaskLet,
                       JobBTaskLet jobBTaskLet,
                       JobCTaskLet jobCTaskLet,
                       BatchListener batchListener) {
        this.jobBuilderFactory = jobBuilderFactory;
        this.stepBuilderFactory = stepBuilderFactory;
        this.jobATaskLet = jobATaskLet;
        this.jobBTaskLet = jobBTaskLet;
        this.jobCTaskLet = jobCTaskLet;
        this.batchListener = batchListener;
    }

    @Bean
    public Job jobATaskLet(Step jobAStep) {
        return this.jobBuilderFactory.get(JobTaskType.JOB_A.getKey())
                .incrementer(new RunIdIncrementer())
                .listener(batchListener)
                .flow(jobAStep)
                .end()
                .build();
    }

    @Bean
    @JobScope
    public Step jobAStep() {
        return stepBuilderFactory.get("jobA")
                .tasklet(jobATaskLet)
                .build();
    }

    // jobBとCも同様
}
```
`jobBuilderFactory.get`の中で指定のJobTaskTypeに一致しない場合はそのJobは自動的にスキップされます。
##### Spring Batch: RepeatStatus
Taskletの中で、対象となるユーザーデータをフェッチし、`RepeatStatus`を用いて処理の完了ステータスを検知します。

```java:RepeatStatusの例
    @Component
    public class JobATaskLet implements Tasklet {
        
        public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
          try {
              fetchedData.forEach(data -> {
                  // push通知 処理
              });
              return RepeatStatus.FINISHED;
          } catch (Exception ex) { 
              LOGGER.error("Batch job failed!!");
              return RepeatStatus.FINISHED;
          }
        }
    }
```
##### Spring Batch: Retryable
`Retryable`アノテーションを使えば処理のリトライ設定も可能です。

```java:Retryableの例
    @Retryable(value = {Exception.class} maxAttempts = 5)
    public void sendPushMessage(String message) throws Exception {
        // push通知の処理
        Client.sendMessage(message);
    }
```

##### Push通知
このバッチはAWS SNS経由でFCMを使用したPush通知を実装しました。
FCMで各端末のdevice tokenを取得し、それを元に各端末へPush通知を送ります。
モバイルアプリ側にもFCM通知を受信するためには実装が必要ですがそちらの実装内容は省略します:bow:

1.FireBaseコンソールでFCMのサーバーキーを取得
<img width="600" alt="image-20211020-034116.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2338302/472b7f5d-3343-977f-6bc7-b4d3b7cf1253.png">

2.AWS SNSのプッシュ通知のプラットフォームアプリケーションにFCMサーバーキーを登録
<img width="600" alt="image-20211020-034019.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2338302/5d72cb84-9c01-61d2-88a4-92b004988862.png">

3.Spring BatchでSNSの呼び出しを実装

```java:SNSClient
    public void buildClient() {
         ~~~ 略 ~~~   
    }

    public void sendFCMMessage(String deviceId, String messageBody) {
        String userDeviceArn = createEndpointArn(deviceId);
        PublishRequest publishRequest = new PublishRequest();
        publishRequest.setMessage(messageBody);
        publishRequest.setMessageStructure("json");
        publishRequest.setTargetArn(userDeviceArn);
        // Send message
        PublishResult result = client.publish(publishRequest);
    }

    private String createEndpointArn(String deviceId) {
        String endpointArn;
        try {
            CreatePlatformEndpointRequest cpeReq =
                new CreatePlatformEndpointRequest()
                    .withPlatformApplicationArn(platformApplication)
                    .withToken(deviceId);
            CreatePlatformEndpointResult cpeRes = client
                .createPlatformEndpoint(cpeReq);
            endpointArn = cpeRes.getEndpointArn();
        } catch (InvalidParameterException ipe) {
            String message = ipe.getErrorMessage();
            Pattern p = Pattern
                .compile(".*Endpoint (arn:aws:sns[^ ]+) already exists "
                    + "with the same token.*");
            Matcher m = p.matcher(message);
            if (m.matches()) {
                // The platform endpoint already exists for this token, but with
                // additional custom data that
                // createEndpoint doesn't want to overwrite. Just use the
                // existing platform endpoint.
                endpointArn = m.group(1);
            } else {
                // Rethrow the exception, the input is actually bad.
                throw ipe;
            }
        }
        return endpointArn;
    }
```

AWSサービスやSpring Frameworkを使えば様々なバッチのパターンが作成できます:tada:
用途に合ったバッチ構成を検討する必要があるので、今回の例は参考に見ていただけると幸いです。


当社では、トヨタ車のサブスク「KINTO」等の企画/開発を行っており、エンジニアを募集中です。
[KINTO Technologies コーポレートサイト](https://www.kinto-technologies.com/)


## （参考）
1. [チャンクモデルとタスクレットモデルの使い分け
](https://terasoluna-batch.github.io/guideline/5.0.0.RELEASE/ja/Ch03_ChunkOrTasklet.html)
2. [Spring Batch Official Document 再試行](https://spring.pleiades.io/spring-batch/docs/current/reference/html/retry.html)
3. [プラットフォームエンドポイントを作成する](https://docs.aws.amazon.com/ja_jp/sns/latest/dg/mobile-platform-endpoint.html)
4. [sns-publish-message-topic](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-simple-notification-service.html#sns-publish-message-topic)
5. [ルールのスケジュール式](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/ScheduledEvents.html)