Spring Batch の Tasklet クラスに @StepScope アノテーションを付与して挙動を確認する

## 概要

- Spring Batch の Tasklet に @StepScope アノテーションを付与して挙動を確認する

## Step スコープと @StepScope アノテーション

Bean 定義でスコープを指定しない場合は Singleton スコープになる。

[Spring Framework 5\.3\.9 コアテクノロジー \- リファレンス](https://spring.pleiades.io/spring-framework/docs/5.3.9/reference/html/core.html#beans-factory-scopes)

> singleton
> （デフォルト）Spring IoC コンテナーごとに、単一の Bean 定義を単一のオブジェクトインスタンスにスコープします。

[Spring Framework 5\.3\.9 コアテクノロジー \- リファレンス](https://spring.pleiades.io/spring-framework/docs/5.3.9/reference/html/core.html#beans-factory-scopes-singleton)

> Bean 定義を定義し、シングルトンとしてスコープされている場合、Spring IoC コンテナーは、その Bean 定義によって定義されたオブジェクトのインスタンスを 1 つだけ作成します。

@StepScope アノテーションなどを付与することで Step スコープになる。

[TERASOLUNA Batch Framework for Java \(5\.x\) Development Guideline](https://terasoluna-batch.github.io/guideline/5.4.0.RELEASE/ja/single_index.html#Ch04_JobParameter_HowToUse_CLIArgs)

> StepスコープはSpring Batch独自のスコープであり、Stepの実行ごとに新たなインスタンスが生成される。

[StepScope \(Spring Batch 4\.3\.3 API\) \- Javadoc](https://spring.pleiades.io/spring-batch/docs/4.3.3/api/org/springframework/batch/core/configuration/annotation/StepScope.html)

> @Bean を @StepScope としてマークすることは、@Scope(value="step", proxyMode=TARGET_CLASS) としてマークすることと同じです。

## 動作確認環境

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

## ソースコード

### ファイル一覧

```
├── build.gradle
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── hello
        │               ├── HelloApplication.java
        │               ├── HelloConfig.java
        │               ├── HelloController.java
        │               ├── HelloService.java
        │               ├── SingletonScopeTasklet.java
        │               └── StepScopeTasklet.java
        └── resources
            └── application.properties
```

### build.gradle

```groovy
plugins {
  id 'org.springframework.boot' version '2.4.9'
  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 のメタデータを入れるデータベース
  implementation 'com.h2database:h2:1.4.200'

  // Lombok
  compileOnly 'org.projectlombok:lombok:1.18.20'
  annotationProcessor 'org.projectlombok:lombok:1.18.20'
}
```

### application.properties

```
# 起動時にジョブを実行しない設定
spring.batch.job.enabled=false
# WARN レベル以上のログを出力する設定 (INFO レベル以下を出力しない)
logging.level.root=WARN
```

### HelloApplication.java 

```java
package com.example.hello;

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

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

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

### HelloConfig.java

```java
package com.example.hello;

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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

  private final JobBuilderFactory jobBuilderFactory;
  private final StepBuilderFactory stepBuilderFactory;

  private final SingletonScopeTasklet singletonScopeTasklet;
  private final StepScopeTasklet stepScopeTasklet;

  public HelloConfig(JobBuilderFactory jobBuilderFactory,
                     StepBuilderFactory stepBuilderFactory,
                     SingletonScopeTasklet singletonScopeTasklet,
                     StepScopeTasklet stepScopeTasklet) {
    this.jobBuilderFactory = jobBuilderFactory;
    this.stepBuilderFactory = stepBuilderFactory;
    this.singletonScopeTasklet = singletonScopeTasklet;
    this.stepScopeTasklet = stepScopeTasklet;
  }

  // SingletonScopeTasklet を実行するための Job
  @Bean("singletonScopeJobBean")
  public Job singletonScopeJob() {
    System.out.println("singletonScopeJob メソッドを実行");
    return jobBuilderFactory.get("singletonScopeJobName") // 一意となる任意のジョブ名を指定
      .start(singletonScopeStep()) // 実行する Step を指定
      .build();
  }

  // SingletonScopeTasklet を実行するための Step
  @Bean
  public Step singletonScopeStep() {
    System.out.println("singletonScopeStep メソッドを実行");
    return stepBuilderFactory.get("singletonScopeStepName") // 任意のステップ名を指定
      .tasklet(singletonScopeTasklet) // 実行する Tasklet を指定
      .build();
  }

  // StepScopeTasklet を実行するための Job
  @Bean("stepScopeJobBean")
  public Job stepScopeJob() {
    System.out.println("stepScopeJob メソッドを実行");
    return jobBuilderFactory.get("stepScopeJobName") // 一意となる任意のジョブ名を指定
      .start(stepScopStep()) // 実行する Step を指定
      .build();
  }

  // StepScopeTasklet を実行するための Step
  @Bean
  public Step stepScopStep() {
    System.out.println("stepScopStep メソッドを実行");
    return stepBuilderFactory.get("stepScopStepName") // 任意のステップ名を指定
      .tasklet(stepScopeTasklet) // 実行する Tasklet を指定
      .build();
  }
}
```

### HelloController.java

```java
package com.example.hello;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.Random;

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

  private final JobLauncher jobLauncher;

  private final Job singletonScopeJob;
  private final Job stepScopeJob;

  public HelloController(JobLauncher jobLauncher,
                         @Qualifier("singletonScopeJobBean") Job singletonScopeJob,
                         @Qualifier("stepScopeJobBean") Job stepScopeJob) {
    this.jobLauncher = jobLauncher;
    this.singletonScopeJob = singletonScopeJob;
    this.stepScopeJob = stepScopeJob;
  }

  @GetMapping("/singleton")
  public Map singleton() throws Exception {
    // ランダムな3桁の数字を用意
    String randomValue = String.valueOf(new Random().nextInt(900) + 100);
    // singletonScopeJob ジョブを実行
    // myRandomValue と myRandomParameter には同じ値をセット
    jobLauncher.run(
      singletonScopeJob,
      new JobParametersBuilder()
        .addString("myRandomValue", randomValue)
        .addString("myRandomParameter", randomValue)
        .toJobParameters());
    return Map.of("method", "singleton", "randomValue", randomValue);
  }

  @GetMapping("/step")
  public Map step() throws Exception {
    // ランダムな3桁の数字を用意
    String randomValue = String.valueOf(new Random().nextInt(900) + 100);
    // stepScopeJob ジョブを実行
    // myRandomValue と myRandomParameter には同じ値をセット
    jobLauncher.run(
      stepScopeJob,
      new JobParametersBuilder()
        .addString("myRandomValue", randomValue)
        .addString("myRandomParameter", randomValue)
        .toJobParameters());
    return Map.of("method", "step", "randomValue", randomValue);
  }
}
```

### HelloService.java

Tasklet のインスタンス変数にインジェクションして挙動を確認するサービスクラス。

```java
package com.example.hello;

import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@Getter
@NoArgsConstructor
public class HelloService {

  private int value = 0;

  public int getValue() {
    return value++; // 値を返してからインクリメント
  }
}
```

### SingletonScopeTasklet.java

Singleton スコープで動かすタスクレットクラス。

```java
package com.example.hello;

import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;

import java.util.Map;

// Tasklet 実装クラス
@Component
@RequiredArgsConstructor // Lombok によるコンストラクタ自動生成
public class SingletonScopeTasklet implements Tasklet {

  // HelloService オブジェクトはインジェクションされる
  private final HelloService helloService;

  // この Tasklet では Bean スコープがデフォルトの Singleton scope なので、
  // このような遅延バインディングを使用することができない
  // @Value("#{jobParameters['myRandomValue']}")
  // private String myRandomValue;

  @Override
  public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
    Map<String, Object> params = chunkContext.getStepContext().getJobParameters();
    // 各種オブジェクトID(らしきもの)やジョブパラメータを出力する
    System.out.println(String.format(
      "%s: " +
        "TaskletObjectId=%010d, StepContributionObjectId=%010d, " +
        "ChunkContextObjectId=%010d, JobParametersObjectId=%010d, " +
        "HelloServiceObjectId=%010d, HelloService#getValue=%03d, " +
        "myRandomValue=%s, myRandomParameter=%s",
      chunkContext.getStepContext().getJobName(),
      System.identityHashCode(this),
      System.identityHashCode(contribution),
      System.identityHashCode(chunkContext),
      System.identityHashCode(params),
      System.identityHashCode(helloService),
      helloService.getValue(),
      params.get("myRandomValue"),
      params.get("myRandomParameter")));
    return RepeatStatus.FINISHED; // 処理が終了したことを示す値を返す
  }
}
```

### StepScopeTasklet.java

Step スコープで動かすタスクレットクラス。

```java
package com.example.hello;

import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Map;

// Tasklet 実装クラス
@Component
@StepScope // Step Scope を指定
@RequiredArgsConstructor // Lombok によるコンストラクタ自動生成
public class StepScopeTasklet implements Tasklet {

  // HelloService オブジェクトはインジェクションされる
  private final HelloService helloService;

  // Step Scope では遅延バインディング (late binding) にてここでパラメータを取得することが可能
  @Value("#{jobParameters['myRandomValue']}")
  private String myRandomValue;

  @Override
  public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
    Map<String, Object> params = chunkContext.getStepContext().getJobParameters();
    // 各種オブジェクトID(らしきもの)やジョブパラメータを出力する
    System.out.println(String.format(
      "%s: " +
        "TaskletObjectId=%010d, StepContributionObjectId=%010d, " +
        "ChunkContextObjectId=%010d, JobParametersObjectId=%010d, " +
        "HelloServiceObjectId=%010d, HelloService#getValue=%03d, " +
        "myRandomValue=%s, myRandomParameter=%s",
      chunkContext.getStepContext().getJobName(),
      System.identityHashCode(this),
      System.identityHashCode(contribution),
      System.identityHashCode(chunkContext),
      System.identityHashCode(params),
      System.identityHashCode(helloService),
      helloService.getValue(),
      myRandomValue,
      params.get("myRandomParameter")));
    return RepeatStatus.FINISHED; // 処理が終了したことを示す値を返す
  }
}
```

## 挙動を確認する

### Spring Boot を起動する

```
$ gradle bootrun
```

### ジョブを実行する

curl コマンドでジョブ実行のためのエンドポイントをコールする。
SingletonScopeTasklet 用と StepScopeTasklet 用ののエンドポイントを3回ずつコールしておく。

```
$ curl http://localhost:8080/singleton
{"method":"singleton","randomValue":"528"}

$ curl http://localhost:8080/singleton
{"method":"singleton","randomValue":"670"}

$ curl http://localhost:8080/singleton
{"method":"singleton","randomValue":"799"}

$ curl http://localhost:8080/step
{"method":"step","randomValue":"623"}

$ curl http://localhost:8080/step
{"method":"step","randomValue":"767"}

$ curl http://localhost:8080/step
{"method":"step","randomValue":"254"}
```

### Spring Boot のログを確認する

#### SingletonScopeTasklet の出力したログを確認する

TaskletObjectId が3回とも同じ値になっているので、SingletonScopeTasklet オブジェクトは同じものが使われていることがわかる。
インジェクションした HelloService オブジェクトは3回とも同じオブジェクトが使われている。

execute メソッドに渡される StepContribution と ChunkContext は3回とも別のオブジェクトになっている。
ChunkContext オブジェクトから getJobParameters メソッドで取り出した Map オブジェクトも3回とも別のオブジェクトになっている。

```
singletonScopeJobName: TaskletObjectId=0258186472, StepContributionObjectId=0190292214, ChunkContextObjectId=1466220262, JobParametersObjectId=1037864044, HelloServiceObjectId=0785109987, HelloService#getValue=000, myRandomValue=528, myRandomParameter=528
singletonScopeJobName: TaskletObjectId=0258186472, StepContributionObjectId=0415243710, ChunkContextObjectId=1118744664, JobParametersObjectId=1736570925, HelloServiceObjectId=0785109987, HelloService#getValue=001, myRandomValue=670, myRandomParameter=670
singletonScopeJobName: TaskletObjectId=0258186472, StepContributionObjectId=1823694617, ChunkContextObjectId=1382467871, JobParametersObjectId=1034468862, HelloServiceObjectId=0785109987, HelloService#getValue=002, myRandomValue=799, myRandomParameter=799
```

#### StepScopeTasklet の出力したログを確認する

TaskletObjectId が3回とも別の値になっているので、StepScopeTasklet オブジェクトは異なるものが使われていることがわかる。
インジェクションした HelloService オブジェクトは3回とも同じオブジェクトが使われている。

execute メソッドに渡される StepContribution と ChunkContext は3回とも別のオブジェクトになっている。
ChunkContext オブジェクトから getJobParameters メソッドで取り出した Map オブジェクトも3回とも別のオブジェクトになっている。

@Value アノテーションを使用して遅延バインディング (late binding) で取得した myRandomValue は3回とも正常な値が入っている。

```
stepScopeJobName: TaskletObjectId=1677218930, StepContributionObjectId=0907866881, ChunkContextObjectId=1440660273, JobParametersObjectId=1279299380, HelloServiceObjectId=0785109987, HelloService#getValue=003, myRandomValue=623, myRandomParameter=623
stepScopeJobName: TaskletObjectId=0214715158, StepContributionObjectId=1495567874, ChunkContextObjectId=0266134826, JobParametersObjectId=0313498540, HelloServiceObjectId=0785109987, HelloService#getValue=004, myRandomValue=767, myRandomParameter=767
stepScopeJobName: TaskletObjectId=2067011333, StepContributionObjectId=1470272491, ChunkContextObjectId=0638440812, JobParametersObjectId=1725582942, HelloServiceObjectId=0785109987, HelloService#getValue=005, myRandomValue=254, myRandomParameter=254
```

## Singleton スコープで遅延バインディング (late binding) を使おうとして発生したエラー

Singleton スコープのタスクレットクラスで遅延バインディング (late binding) を使おうと @Value アノテーションを記述した場合に Spring Boot 起動時に発生したエラー。

```
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'helloConfig' defined in file [/Users/alice/spring-batch-stepscope/build/classes/java/main/com/example/hello/HelloConfig.class]: Unsatisfied dependency expressed through constructor parameter 2;
nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'singletonScopeTasklet': Unsatisfied dependency expressed through field 'myRandomValue';
nested exception is org.springframework.beans.factory.BeanExpressionException: Expression parsing failed;
nested exception is org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'jobParameters' cannot be found on object of type 'org.springframework.beans.factory.config.BeanExpressionContext' - maybe not public or not valid?
```

## 結果まとめ

- Singleton スコープでは同じ Tasklet オブジェクトが使われる。Step スコープでは実行毎に異なる Tasklet オブジェクトが使われる
- Singleton スコープでは @Value アノテーションを使用して遅延バインディング (late binding) が使えない
- Singleton スコープでも execute メソッドに渡されるオブジェクトは使い回されているわけではない

## 参考資料

- [Spring Batch 4\.3\.3 ステップの構成 \- リファレンス](https://spring.pleiades.io/spring-batch/docs/4.3.3/reference/html/step.html)
- [Spring Framework 5\.3\.9 コアテクノロジー \- Bean スコープ \- リファレンス](https://spring.pleiades.io/spring-framework/docs/5.3.9/reference/html/core.html#beans-factory-scopes)
- [TERASOLUNA Batch Framework for Java \(5\.x\) Development Guideline](https://terasoluna-batch.github.io/guideline/5.4.0.RELEASE/ja/single_index.html#Ch04_JobParameter_HowToUse_CLIArgs)
- [StepScope \(Spring Batch 4\.3\.3 API\) \- Javadoc](https://spring.pleiades.io/spring-batch/docs/4.3.3/api/org/springframework/batch/core/configuration/annotation/StepScope.html)
- [Scope \(Spring Framework 5\.3\.9 API\) \- Javadoc](https://spring.pleiades.io/spring-framework/docs/5.3.9/javadoc-api/org/springframework/context/annotation/Scope.html)