Spring Batch で Tasklet を使用してジョブがリスタートする挙動を確認する

## 概要

- Spring Batch で Tasklet を使用してジョブが再実行 (リスタート) する挙動を確認する
- 1度目は必ず失敗し、2度目以降は必ず成功するステップを用意する
- 失敗したステップからジョブが再実行 (リスタート) されることを確認する

## 動作確認環境

- Java 11 (AdoptOpenJDK 11.0.11)
- Spring Batch 4.3.3
- Spring Boot 2.5.3
- Spring Framework 5.3.9
- Gradle 7.1.1
- macOS Big Sur 11.4

## ソースコード

### ファイル一覧

```
├── build.gradle
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── steps
        │               ├── MyApplication.java
        │               ├── MyConfig.java
        │               ├── MyController.java
        │               └── MyGate.java
        └── resources
            └── application.properties
```

### build.gradle

```groovy
plugins {
  id 'org.springframework.boot' version '2.5.3'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}

group = 'com.example'
version = '0.0.1'
sourceCompatibility = '11'

repositories {
  mavenCentral()
}

dependencies {
  // Spring Batch
  implementation 'org.springframework.boot:spring-boot-starter-batch'

  // Spring Web MVC
  implementation 'org.springframework.boot:spring-boot-starter-web'

  // Spring Batch のメタデータを入れるデータベース
  runtimeOnly 'com.h2database:h2:1.4.200'
}
```

### MyApplication.java

```java
package com.example.steps;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// Spring Boot アプリケーションクラス
@SpringBootApplication
public class MyApplication {

	public static void main(String[] args) {
		SpringApplication.run(MyApplication.class, args);
	}
}
```

### MyConfig.java

```java
package com.example.steps;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.step.tasklet.MethodInvokingTaskletAdapter;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// バッチ構成クラス
@Configuration // Bean 定義クラスであることを示すアノテーション
@EnableBatchProcessing // Spring Batch を有効にする
public class MyConfig {

  private static final Logger log = LoggerFactory.getLogger(MyConfig.class);

  private final JobBuilderFactory jobBuilderFactory;
  private final StepBuilderFactory stepBuilderFactory;

  private MyGate firstGate = new MyGate("1番目の門");
  private MyGate secondGate = new MyGate("2番目の門");

  public MyConfig(JobBuilderFactory jobBuilderFactory,
                  StepBuilderFactory stepBuilderFactory) {
    this.jobBuilderFactory = jobBuilderFactory;
    this.stepBuilderFactory = stepBuilderFactory;
  }

  // ジョブ
  @Bean
  public Job myJob(@Qualifier("firstStep") Step firstStep, @Qualifier("secondStep") Step secondStep) {
    log.info("myJob メソッドを実行");
    return jobBuilderFactory.get("myJob") // 一意となる任意のジョブ名を指定
      .start(firstStep) // 最初に実行するステップを指定
      .next(secondStep) // 次に実行するステップを指定
      .build();
  }

  // ひとつめに実行するステップ
  @Bean("firstStep")
  public Step firstStep(@Qualifier("firstTasklet") Tasklet firstTasklet) {
    log.info("firstStep メソッドを実行");
    return stepBuilderFactory.get("firstStep") // 任意のステップ名を指定
      .tasklet(firstTasklet) // 実行するタスクレットを指定
      .build();
  }

  // ひとつめのステップで実行するタスクレット
  @Bean("firstTasklet")
  @StepScope // Step の実行ごとに新たなインスタンスを生成する
  public Tasklet firstTasklet(@Value("#{jobParameters['challenger']}") String challenger) {
    log.info("firstTasklet メソッドを実行");
    MethodInvokingTaskletAdapter tasklet = new MethodInvokingTaskletAdapter();
    tasklet.setTargetObject(firstGate); // 実行対象のオブジェクト
    tasklet.setTargetMethod("challenge"); // 実行するメソッド名
    tasklet.setArguments(new Object[]{challenger}); // 実行するメソッドに渡すパラメータ
    return tasklet;
  }

  // ふたつめに実行するステップ
  @Bean("secondStep")
  public Step secondStep(@Qualifier("secondTasklet") Tasklet secondTasklet) {
    log.info("secondStep メソッドを実行");
    return stepBuilderFactory.get("secondStep") // 任意のステップ名を指定
      .tasklet(secondTasklet) // 実行するタスクレットを指定
      .build();
  }

  // ふたつめのステップで実行するタスクレット
  @Bean("secondTasklet")
  @StepScope // Step の実行ごとに新たなインスタンスを生成する
  public Tasklet secondTasklet(@Value("#{jobParameters['challenger']}") String challenger) {
    log.info("secondTasklet メソッドを実行");
    MethodInvokingTaskletAdapter tasklet = new MethodInvokingTaskletAdapter();
    tasklet.setTargetObject(secondGate); // 実行対象のオブジェクト
    tasklet.setTargetMethod("challenge"); // 実行するメソッド名
    tasklet.setArguments(new Object[]{challenger}); // 実行するメソッドに渡すパラメータ
    return tasklet;
  }
}
```

### MyController.java

```java
package com.example.steps;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecutionException;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * ジョブを実行するための WebAPI を提供する。
 */
@RestController
public class MyController {

  private static final Logger log = LoggerFactory.getLogger(MyController.class);
  private final JobLauncher jobLauncher;
  private final Job myJob;

  public MyController(JobLauncher jobLauncher, Job myJob) {
    this.jobLauncher = jobLauncher;
    this.myJob = myJob;
  }

  @GetMapping("/execute/{challenger}")
  public Map execute(@PathVariable("challenger") String challenger) {
    try {
      log.info("===== ジョブを実行 =====");
      jobLauncher.run(myJob, new JobParametersBuilder()
        .addString("challenger", challenger)
        .toJobParameters());
      return Map.of("execute", "ok");
    } catch (JobExecutionException e) {
      log.info(e.toString());
      return Map.of("execute", "error");
    }
  }
}
```

### MyGate.java

```java
package com.example.steps;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashSet;
import java.util.Set;

/**
 * 挑戦者が通る門。
 */
public class MyGate {

  private static final Logger log = LoggerFactory.getLogger(MyGate.class);
  private final String gateName;
  private final Set<String> recordBook = new HashSet<>();

  /**
   * 挑戦者が通る門を生成する。
   * @param name 門の名前
   */
  public MyGate(String name) {
    this.gateName = name;
  }

  /**
   * 一度目は必ず失敗する。二度目以降は必ず成功する。
   * @param challenger 挑戦者
   */
  public void challenge(String challenger) {
    if (recordBook.contains(challenger)) { // 挑戦者はすでに来ていたか
      log.info(String.format("成功: %s は %s を突破した", challenger, gateName));
    } else {
      log.info(String.format("失敗: %s は %s に阻まれた", challenger, gateName));
      recordBook.add(challenger); // 挑戦者の名を記録する
      throw new IllegalStateException("失敗");
    }
  }
}
```

### application.properties

```
# 起動時にジョブを実行しない設定
spring.batch.job.enabled=false
# ログ出力設定
logging.level.root=INFO
logging.level.org.springframework.batch.core.step.AbstractStep=OFF
logging.pattern.console=%-20.20logger{0} %message%n
```

## 挙動を確認する

### Spring Boot を起動する

```
$ gradle bootrun
```

```
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.3)

(中略)
MyConfig             firstStep メソッドを実行
MyConfig             secondStep メソッドを実行
MyConfig             myJob メソッドを実行
```

### ジョブの実行と出力ログの確認

curl コマンドでジョブ実行のためのエンドポイントをコールし、Spring Boot + Spring Batch のログを確認する。

1度目の実行。

```
$ curl http://localhost:8080/execute/Alice
{"execute":"ok"}
```

ログを確認。ジョブが実行されて、1つめのステップが失敗する。

```
MyController         ===== ジョブを実行 =====
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] launched with the following parameters: [{challenger=Alice}]
SimpleStepHandler    Executing step: [firstStep]
MyConfig             firstTasklet メソッドを実行
MyGate               失敗: Alice は 1番目の門 に阻まれた
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] completed with the following parameters: [{challenger=Alice}] and the following status: [FAILED] in 80ms
```

2度目の実行。同じパラメータを指定してジョブを実行することで、失敗した箇所からジョブを再実行 (リスタート) することができる。

```
$ curl http://localhost:8080/execute/Alice
{"execute":"ok"}
```

ログを確認。ジョブが実行されて、1つめのステップが成功し、2つめのステップが失敗する。

```
MyController         ===== ジョブを実行 =====
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] launched with the following parameters: [{challenger=Alice}]
SimpleStepHandler    Executing step: [firstStep]
MyConfig             firstTasklet メソッドを実行
MyGate               成功: Alice は 1番目の門 を突破した
SimpleStepHandler    Executing step: [secondStep]
MyConfig             secondTasklet メソッドを実行
MyGate               失敗: Alice は 2番目の門 に阻まれた
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] completed with the following parameters: [{challenger=Alice}] and the following status: [FAILED] in 34ms
```

3度目の実行。

```
$ curl http://localhost:8080/execute/Alice
{"execute":"ok"}
```

ログを確認。1つめのステップはすでに成功しているので何もしない。2つめのステップが成功する。ジョブが正常に完了する。

```
MyController         ===== ジョブを実行 =====
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] launched with the following parameters: [{challenger=Alice}]
SimpleStepHandler    Step already complete or not restartable, so no action to execute: StepExecution: id=2, version=3, name=firstStep, status=COMPLETED, exitStatus=COMPLETED, readCount=0, filterCount=0, writeCount=0 readSkipCount=0, writeSkipCount=0, processSkipCount=0, commitCount=1, rollbackCount=0, exitDescription=
SimpleStepHandler    Executing step: [secondStep]
MyConfig             secondTasklet メソッドを実行
MyGate               成功: Alice は 2番目の門 を突破した
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] completed with the following parameters: [{challenger=Alice}] and the following status: [COMPLETED] in 14ms
```

4度目の実行。

```
$ curl http://localhost:8080/execute/Alice
{"execute":"error"}
```

ログを確認。すでに正常に完了したジョブを再実行しようとして JobInstanceAlreadyCompleteException 例外が発生している。

```
MyController         ===== ジョブを実行 =====
MyController         org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException: A job instance already exists and is complete for parameters={challenger=Alice}.  If you want to run this job again, change the parameters.
```

パラメータを変更すれば別のジョブとして実行できる。

```
$ curl http://localhost:8080/execute/Bob
{"execute":"ok"}
```

```
MyController         ===== ジョブを実行 =====
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] launched with the following parameters: [{challenger=Bob}]
SimpleStepHandler    Executing step: [firstStep]
MyConfig             firstTasklet メソッドを実行
MyGate               失敗: Bob は 1番目の門 に阻まれた
SimpleJobLauncher    Job: [SimpleJob: [name=myJob]] completed with the following parameters: [{challenger=Bob}] and the following status: [FAILED] in 13ms
```

## 参考資料

- [Spring Batch 4\.3\.3 ジョブの構成と実行 \- リファレンス](https://spring.pleiades.io/spring-batch/docs/4.3.3/reference/html/job.html)
- [Spring Batch 4\.3\.3 ステップの構成 \- リファレンス](https://spring.pleiades.io/spring-batch/docs/4.3.3/reference/html/step.html)
- [Spring Batch 4\.3\.3 API ドキュメント \- Javadoc](https://spring.pleiades.io/spring-batch/docs/4.3.3/api/)
- [処理の再実行 \- TERASOLUNA Batch Framework for Java \(5\.x\) Development Guideline](https://terasoluna-batch.github.io/guideline/5.4.0.RELEASE/ja/single_index.html#Ch06_RerunRestart)
- [SpringBatchで簡単なオンデマンドバッチを作成 \- Qiita](https://qiita.com/kurukuruz/items/44743a6cd45037061548)