Webエンジニアのメモ帳

技術的な話を中心に書いています。

Spring Batchの使い方

Spring Bootを使った開発において、Webアプリケーションのような常駐するプロセスではなく、一連の処理を実行するだけのバッチ処理を書く時、Spring Batchというものが使えます。

この記事では、Spring Batchの使い方を解説します。

文字列を出力するだけの簡単なバッチ (Taskletを使用)

まずは、文字列を出力するだけの簡単なバッチを実装します。

まずは依存ライブラリの設定です。build.gradleのdependenciesのところにSpring Batchのライブラリを記述します。

build.gradle
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-batch'
}
Application.java

Spring Bootを使うので、@SpringBootApplicationアノテーションを付与したクラスが必要です。

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

@SpringBootApplication
public class Application {
  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}
HelloTasklet.java

処理の内容を記述するクラスです。

Taskletインターフェースを継承したクラスを実装します。

@Component
public class HelloTasklet implements Tasklet {
  // このメソッドの引数は、Taskletを使う際にはお決まりの引数、というように考えて良いです
  @Override
  public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    System.out.println("Hello!");
    return RepeatStatus.FINISHED;
  }
}
BatchConfig.java

バッチ処理を理解する上で最も重要なクラスです。

バッチ処理を動かすためには、@EnableBatchProcessingアノテーションを付与したクラスで、Jobクラスのインスタンスを@Bean登録します。

Jobは1つ以上のStepから構成されています。Stepには処理内容を記述するのですが、Taskletに処理の内容を記載し、そのTaskletをStepに登録する、というような記述方法をします。

import lombok.RequiredArgsConstructor;
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;

// Bean登録を行うため、このアノテーションが必要
@Configuration
// 上記の通り、バッチ処理を有効にするために必要
@EnableBatchProcessing
// コンストラクタを自動生成するLombokのアノテーション
@RequiredArgsConstructor
public class BatchConfig {
  private final JobBuilderFactory jobBuilderFactory;
  private final StepBuilderFactory stepBuilderFactory;
  private final HelloTasklet helloTasklet;

  @Bean
  public Job job() {
    return jobBuilderFactory
      // Job名を指定
      .get("job")
      .start(helloStep())
      .build();
  }

  @Bean
  public Step helloStep() {
    return stepBuilderFactory
      // Step名を指定
      .get("step")
      // この記述がないと、一度成功したステップを再度実行できない
      .allowStartIfComplete(true)
      // taskletを登録
      .tasklet(helloTasklet)
      .build();
  }
}

このコードを実行、すなわちApplicationクラスのmain()メソッドを実行すると、HelloTaskletクラスに記述した処理が実行され、Hello!が出力されます。

また、はまりやすいポイントとしては、allowStartIfCompleteのところでしょうか。

実はバッチ処理では、以前にその処理が成功したかどうかを記録しており、すでに成功したステップに関しては、この記述がないと実行することができません。

ちなみに、この記述を忘れると以下のようなエラーログが出力されます。

Job: [SimpleJob: [name=job]] launched with the following parameters: [{}]
Step already complete or not restartable, so no action to execute: StepExecution: id=4, version=3, name=step, status=COMPLETED, exitStatus=COMPLETED, readCount=0, filterCount=0, writeCount=0 readSkipCount=0, writeSkipCount=0, processSkipCount=0, commitCount=1, rollbackCount=0, exitDescription=
Job: [SimpleJob: [name=job]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 17ms

DBからデータを取得して処理を行うバッチ (Taskletを使用)

上記のコードは非常にシンプルな内容でしたが、もう少し実用的な処理を書いてみます。

今回は、動物園で飼育されている動物の情報をまとめたデータベースのデータを使い、パンダがいる動物園の名前を出力するバッチ処理を書いてみます。

このような処理は、チャンクモデルというものを使うとSpring Batchらしい書き方ができるのですが、この方法は複雑なので後述します。

まずはチャンクモデルを使わず、先ほどと同じくTaskletを使う方法で書いてみます。

DBの用意

DBはMySQLを使用します。

testデータベースを作成し、そこにzooテーブルを作成します。レコードを3件挿入しておきます。

create table zoo(zoo varchar(100), animal varchar(100));
insert into zoo values("上野動物園", "パンダ");
insert into zoo values("多摩動物公園", "コアラ");
insert into zoo values("アドベンチャーワールド", "パンダ");
build.gradle

build.gradleのdependenciesに、バッチの依存ライブラリに加え、DBの依存ライブラリを記述します。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-batch'
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  runtimeOnly 'mysql:mysql-connector-java'
}
Application.java

こちらの内容は先ほどと全く同じで大丈夫です。

application.yml

DBの接続情報を記述します。

spring:
  batch:
    initialize-schema: ALWAYS
  datasource:
    url: "jdbc:mysql://localhost:3306/test?sslMode=DISABLED&allowPublicKeyRetrieval=true&socketTimeout=10000"
    username: "user" #DBのユーザー名
    password: "password" # DBのパスワード
    driverClassName: "com.mysql.cj.jdbc.Driver"
  jpa:
    database: MYSQL

注意点として、Spring Batchを使う場合はspring.batch.initialize-schemaを書かないとエラーになります。

Zoo.java

DBのレコードに対応するエンティティクラスです。

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Entity
@Table(name = "zoo")
@NoArgsConstructor
@AllArgsConstructor
public class Zoo {
  @Id
  private String zoo;
  
  private String animal;
}
ZooRepository.java

レポジトリクラスです。正確にはクラスではなくインターフェースです。JpaRepositoryを使うので、特にメソッドなどは書く必要がありません。

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ZooRepository extends JpaRepository<Zoo, String> {
}
PandaTasklet.java

先ほどのHelloTaskletクラスと同じ要領で、Taskletを継承したクラスを作成して処理を記述します。

import java.util.List;
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;

@Component
@RequiredArgsConstructor
public class PandaTasklet implements Tasklet {
  private final ZooRepository zooRepository;

  @Override
  public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    List<Zoo> zooList = zooRepository.findAll();
    for (Zoo zoo : zooList) {
      if (zoo.getAnimal().equals("パンダ")) {
        System.out.println(zoo.getZoo() + "にはパンダがいます。");
      }
    }
    return RepeatStatus.FINISHED;
  }
}
BatchConfig.java

先ほど説明した「Hello!」を出力する際のバッチ処理と、ほぼ全く同じです。(taskletの種類が変わっただけです。)

@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class BatchConfig {
  private final JobBuilderFactory jobBuilderFactory;
  private final StepBuilderFactory stepBuilderFactory;
  private final PandaTasklet pandaTasklet;

  @Bean
  public Job job() {
    return jobBuilderFactory
      .get("job")
      .start(pandaStep())
      .build();
  }

  @Bean
  public Step pandaStep() {
    return stepBuilderFactory
      .get("step")
      .allowStartIfComplete(true)
      .tasklet(pandaTasklet)
      .build();
  }
}

このコードを実行すると、以下のように出力されます。

上野動物園にはパンダがいます。
アドベンチャーワールドにはパンダがいます。

同じ処理を、チャンクモデルを使って実装してみる

バッチ処理の多くは、以下のような流れになっています。

  1. DBなどからデータを取得し、
  2. データのうち必要なデータだけを抽出したり、データの加工などをしたりしてから、
  3. DBなどに書き込んだりする

今回のコードであれば、それぞれ以下に相当しますね。

  1. DBから動物園と、そこにいる動物のデータを取得し
  2. パンダがいる動物園のデータだけ抽出し
  3. 動物園の名前を出力する

多くのバッチ処理がこのような流れになっていることから、Spring Batchではこのような処理を簡単に書けるようになっており、そのような書き方は「チャンクモデル」と呼ばれます。

「チャンクモデル」では1つのStepの中で連続して、上記の1番の処理(データ取得)をItemReaderクラスで、2番の処理(データ抽出)をItemProcessorクラスで、3番の処理(データ出力)をItemWriterクラスで行います。

また、これらのクラスはインターフェースなので、ItemReaderインターフェースの実装クラスで処理を行う、という言い方が正確です。(ItemProcessorItemWriterについても同様)

ではコードを見てみましょう。先ほどと変わる部分はBatchConfigクラスのみとなり、この書き方ではTaskletクラス(先ほどのコードではPandaTaskletクラス)は使用しません。

BatchConfig.java
import java.util.List;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
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.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.BeanPropertyRowMapper;

@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class BatchConfig {
  private final JobBuilderFactory jobBuilderFactory;
  private final StepBuilderFactory stepBuilderFactory;
  private final DataSource dataSource;

  @Bean
  public Job job() {
    return jobBuilderFactory
      .get("job")
      .start(pandaStep())
      .build();
  }

  @Bean
  public Step pandaStep() {
    return stepBuilderFactory
      .get("step")
      .allowStartIfComplete(true)
      // チャンクモデルを使用するための設定
      .<Zoo, Zoo>chunk(1)
      .reader(zooReader())
      .processor(zooProcessor())
      .writer(zooWriter())
      .build();
  }

  // ItemReader DBからデータを読み込む
  @Bean
  public ItemReader<Zoo> zooReader() {
    return new JdbcCursorItemReaderBuilder<Zoo>()
      .dataSource(dataSource)
      .name("zooReader")
      .sql("SELECT * FROM zoo")
      .rowMapper(new BeanPropertyRowMapper<>(Zoo.class))
      .build();
  }

  // ItemProcessor パンダがいる動物園のデータのみItemWriterに渡す
  @Bean
  public ItemProcessor<Zoo, Zoo> zooProcessor() {
    return new ItemProcessor<Zoo, Zoo>() {
      @Override
      public Zoo process(Zoo zoo) throws Exception {
        if (zoo.getAnimal().equals("パンダ")) {
          return zoo;
        } else {
          return null;
        }
      }
    };
  }

  // ItemWriter 出力を行う
  @Bean
  public ItemWriter<Zoo> zooWriter() {
    return new ItemWriter<Zoo>() {
      @Override
      public void write(List<? extends Zoo> zooList) throws Exception {
        for (Zoo zoo : zooList) {
          System.out.println(zoo.getZoo() + "にはパンダがいます。");
        }
      }
    };
  }
}

このコードを実行すると、先ほどと同じく以下のような出力がされます。

上野動物園にはパンダがいます。
アドベンチャーワールドにはパンダがいます。

また、zooProcessorzooWriterに関しては、ともにメソッドが1つしかないインターフェースを実装したクラス(無名クラス)なので、以下のようにラムダ式を使って簡潔に書くことも可能です。

@Bean
public ItemProcessor<Zoo, Zoo> zooProcessor() {
  return (Zoo zoo) -> {
    if (zoo.getAnimal().equals("パンダ")) {
      return zoo;
    } else {
      return null;
    }
  };
}
@Bean
public ItemWriter<Zoo> zooWriter() {
  return (List<? extends Zoo> zooList) -> {
    for (Zoo zoo : zooList) {
      System.out.println(zoo.getZoo() + "にはパンダがいます。");
    }
  };
}

まとめ

以上のように、バッチ処理ではJobに登録されたStepが順番に実行されていきます。

StepにはTaskletを登録でき、Taskletに処理内容を記述します。

あるいは、データの読み出し→データの抽出や加工→書き込みや出力といった処理を行う場合には、Taskletを使わずにチャンクモデルを使うと簡単に書くことができます。

Taskletとチャンクモデルを上手く使い分けることで、バッチ処理を読みやすく書くことができます。