Spring Web の例は前回行ったので、今回は Spring Batch での Hello World を実施する。

要件とやりたいこと

  • IntelliJ IDEA + Kotlin + Spring Boot 2.0.0 + Gradle
  • 複数の Job を作成しコマンドライン引数で実行を分ける
  • ユニットテストでの実行を行えるようにする

初期設定

IntelliJ IDEA の新規プロジェクトから Spring Initializr を選択。 Gradle Project で言語を Kotlin にしパッケージングは jar を選択。

依存関係のところでは勿論 Batch を入れるのだが Spring Batch はデータベースが使用できるようになっていないと実行できない。 その為対応する JDBC ドライバが必要なのでこれも導入する。 ここでは PostgreSQL がローカルに既にインストールされているものとする。 生成されたプロジェクトの build.gradle の依存関係は以下のようになる:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-batch')
    compile("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    compile("org.jetbrains.kotlin:kotlin-reflect")
    runtime('org.postgresql:postgresql')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.batch:spring-batch-test')
}

この時点で Application クラスを実行すると以下のようにエラーが出力される:

Failed to auto-configure a DataSource: 'spring.datasource.url' is not specified and no embedded datasource could be auto-configured.

DB の URL 設定がなされていないというわけで、プロパティファイルより YAML で書いたほうが便宜がいいということですでにある application.properties を削除し application.yaml として以下を定義する:

spring:
  datasource:
  url: jdbc:postgresql://localhost/postgres
  username: postgres
  password: postgres
  driverClassName: org.postgresql.Driver

この状態で同様に実行し、まだバッチの実装をしていないので何も起きないがとりあえず上記のエラーが出ずに正常にアプリケーションの実行が終了することを確認する。

Tasklet の実装

ネットの情報を見てみると CommandLineRunner で実装する方法と Tasklet 若しくは Reader, Processor, Writer で逐次処理をする方法があるようだが、ここでは Spring Batch 公式の Quick Start に記載してある通り Tasklet を使用する。 以下のような BatchConfiguration クラスを定義する:

@Configuration
@EnableBatchProcessing
class BatchConfiguration(val jobBuilderFactory: JobBuilderFactory, val stepBuilderFactory: StepBuilderFactory) {

    @Component
    class Tasklet1 : Tasklet {
        override fun execute(contribution: StepContribution?, chunkContext: ChunkContext?): RepeatStatus {
            System.out.println("Hello World!")
            return RepeatStatus.FINISHED
        }
    }

    @Component
    class Tasklet2 : Tasklet {
        override fun execute(contribution: StepContribution?, chunkContext: ChunkContext?): RepeatStatus {
            System.out.println("Hello World2!")
            return RepeatStatus.FINISHED
        }
    }

    @Bean
    fun step1(): Step? = stepBuilderFactory.get("step1")?.tasklet(Tasklet1())?.build()

    @Bean
    fun step2(): Step? = stepBuilderFactory.get("step2")?.tasklet(Tasklet2())?.build()

    @Bean
    fun job1(): Job? = jobBuilderFactory.get("job1")?.start(step1())?.build()

    @Bean
    fun job2(): Job? = jobBuilderFactory.get("job2")?.start(step2())?.build()
}

従来の Spring だと、この場合の JobBuilderFactoryStepBuilderFactory を DI する為に @Autowired アノテーションを付けていたようだが、最近のバージョンだと付けなくても注入してくれるようだ。 勿論 @Autowired を付けても良い。

この例だと job1()job2() という 2 つのジョブが定義されていることになる。 ジョブは更にステップという単位に分割され順々に実行することができるようだが、ここでは 1 ジョブ 1 ステップ構成ということでそれぞれ step1()step2() を定義している。 また Tasklet の定義はラムダ式でも良いのだが、後述するが Tasklet 単位でのユニットテストを行いたいのであえてクラスとして定義している。

この状態で実行してみると以下のエラーが出る:

java.lang.IllegalStateException: Failed to execute CommandLineRunner
...(中略)...
Caused by: org.postgresql.util.PSQLException: ERROR: relation "batch_job_instance" does not exist

この batch_job_instance というテーブルは何なのだろうか。 調べてみると、どうも Spring Batch がジョブの実行状態を管理するために内部的に生成するメタデータらしい。 このメタデータのテーブルがまだ DB に無いため does not exist と言われてしまうわけだ。

このメタデータを生成したい場合は application.yaml に以下のように書けば良い:

spring:
  batch:
    initialize-schema: always

これで先ほどのように実行すれば問題なく動作するのだが、このメタデータで管理されているのが逆に煩わしく感じる。 多くのプロジェクトの場合ジョブの管理など不要で、実行したい時に実行できればそれでいいはずだ。 以下、このメタデータを使わなくてもバッチを実行できるようにする。

メタデータを使用しないでいい方法

これに関しては Spring Batchのメタデータテーブルを作らせない/使わせないが大変参考になった。 記事を参考に同様の実装を Kotlin で行う。 以下のような MyBatchConfigurer を定義する:

@Component
class MyBatchConfigurer : BatchConfigurer {

    private val transactionManager = ResourcelessTransactionManager()

    private val mapJobRepositoryFactoryBean = MapJobRepositoryFactoryBean(transactionManager).also { it.afterPropertiesSet() }

    private val jobRepository = mapJobRepositoryFactoryBean.`object`!!

    private val jobExplorer = MapJobExplorerFactoryBean(mapJobRepositoryFactoryBean).also { it.afterPropertiesSet() }.`object`!!

    private val jobLauncher = SimpleJobLauncher().also {
        it.setJobRepository(jobRepository)
        it.afterPropertiesSet()
    }

    override fun getJobRepository(): JobRepository = jobRepository

    override fun getJobLauncher(): JobLauncher = jobLauncher

    override fun getJobExplorer(): JobExplorer = jobExplorer

    override fun getTransactionManager(): PlatformTransactionManager = transactionManager
}

この状態で再度実行すると Tasklet1Tasklet2 の実装が呼び出され Hello World! と Hello World2! が出力される。

Tasklet のユニットテストを行う

以下のように @Autowired を使用して注入した Tasklet インスタンスに対して普通に実行してみる:

@RunWith(SpringRunner::class)
@SpringBootTest
class TestApplicationTests() {

    @Autowired
    lateinit var tasklet1: BatchConfiguration.Tasklet1

    @Autowired
    lateinit var tasklet2: BatchConfiguration.Tasklet2

    @Test
    fun tasklet1() {
        tasklet1.execute(null, null)
    }

    @Test
    fun tasklet2() {
        tasklet2.execute(null, null)
    }
}

こうすると以下のように 2 重に実行されてしまう:

Hello World!
Hello World2!
Hello World!
Hello World2!

Spring Boot Batch のデフォルトの挙動としてアプリケーションの main() が実行された時点ですべてのジョブを実行するので、その実行の後にこのテストケースでの各 Tasklet が実行されてしまう。 やりたいのは各 Tasklet の実行だけであり、アプリケーション起動時の全ジョブの実行は不要だ。 この実行を無効化するには application.yaml に以下のように書く:

spring:
  batch:
    job:
      enabled: false  # main() での全ジョブ実行を行わない

再度ユニットテストを実行し Hello World! が 2 重に表示されないことを確認する。

JAR から実行するジョブを分けたい

このアプリを JAR ファイルにする。 IntelliJ IDEA のサイドメニューの Gradle から Tasks -> build -> bootJar を選択すると JAR ファイルがビルドされプロジェクトの build/libs 下に置かれる。 これをコマンドラインから job1() だけ実行したい場合は以下のように呼び出す:

java -jar (JAR ファイルパス) --spring.batch.job.names=job1 --spring.batch.job.enabled=true

コマンドライン引数で application.yaml の設定を一時的に上書きすることができるので、もし先ほどの設定で spring.batch.job.enabled=false が指定されている場合は JAR ファイルを実行してもバッチが実行されないので spring.batch.job.enabled=true を明示的に渡すことで実行できる。