※当サイトの記事には、広告・プロモーションが含まれます。

Spring BootでSpring BatchをMyBatisと一緒に使ってみる

nazology.net

⇧ amazing...

Spring Batchとは

公式のドキュメントによると、

spring.io

A lightweight, comprehensive batch framework designed to enable the development of robust batch applications vital for the daily operations of enterprise systems.

https://spring.io/projects/spring-batch

⇧ 軽量で包括的なバッチフレームワークですと。

GitHubで公開されてる説明だと、

github.com

Spring Batch is a lightweight, comprehensive batch framework designed to enable the development of robust batch applications vital for the daily operations of enterprise systems. Spring Batch builds upon the productivity, POJO-based development approach, and general ease of use capabilities people have come to know from the Spring Framework, while making it easy for developers to access and leverage more advanced enterprise services when necessary.

https://github.com/spring-projects/spring-batch

⇧ 使いやすさに基づいているらしい...本当かいな...

で、アーキテクチャはと言うと、

docs.spring.io

⇧ う~む、全く分からん...

で、Spring Batchの旧いバージョンのドキュメントだと、

docs.spring.io

⇧ ありがちなバッチの構成みたいなものが描かれてますと。

このうち、

⇧ Spring Batchが関係してそうなのが、上図のアイテムってことになるんかな?

Spring BootでSpring BatchをMyBatisと一緒に使ってみる

とりあえず、使ってみますか。

stackoverflow.com

⇧ 上記サイト様にプロジェクトの構造が紹介されてるけども、よく分からん...

camp.trainocate.co.jp

qiita.com

restartmylife35.com

www.codeusingjava.com

⇧ 上記サイト様を参考にSpring Batchを実装してみる。

Spring BatchでMyBatisを使う場合、

mybatis.org

MyBatis-Spring 1.1.0 以降では、 Spring Batch を構築するための Bean として MyBatisPagingItemReader 、 MyBatisCursorItemReader 、 MyBatisBatchItemWriter が用意されています。

また、2.0.0 以降では、Java Configuration をサポートするための Builder クラスとして MyBatisPagingItemReaderBuilder 、 MyBatisCursorItemReaderBuilder 、 MyBatisBatchItemWriterBuilder が用意されています。

https://mybatis.org/spring/ja/batch.html

⇧ 専用のクラスが用意されてる模様なんですが、

MyBatisCursorItemReader

This bean is an ItemReader that reads records from a database using a cursor.

https://mybatis.org/spring/ja/batch.html

⇧ 用途がデータベースに限定されてしまうのはいかがなものか...

入力がデータベース以外のファイルとかの場合は、

spring.pleiades.io

フラットファイルは、最大 2 次元(表)データを含む任意の型のファイルです。Spring Batch フレームワークでのフラットファイルの読み取りは、FlatFileItemReader と呼ばれるクラスによって促進されます。このクラスは、フラットファイルの読み取りと解析のための基本的な機能を提供します。

https://spring.pleiades.io/spring-batch/docs/current/reference/html/readersAndWriters.html#flatFileItemReader

⇧ Spring BatchのFlatFileItemReaderを使えってことらしい。

と思ったら、

formula97.cocolog-nifty.com

そういう時にはCSVを読み込むカスタムReaderを作るべしということだそうなので、実際にやってみた。

Spring Batchで改行がめちゃくちゃなCSVと戦う: der Raum Sieben

⇧ 何やら、CSVファイルの内容によっては、FlatFileItemReaderが上手く処理できないことが起こり得るらしく、

qiita.com

SpringBatchでファイルを読むと言えばまず選択肢に上がるのはFlatFileReaderかと思います。
DelimitedLineTokenizerを使ってこのクラスでcsvを読み込むサンプルもググるといくつか出て来ますが、実案件では改行や区切り文字を含む文字列にも対応しなければいけないこともあります。

そういった場合はcsv読み込みするカスタムReaderを作りましょう。

SpringBatchを実案件で活用するための10のプラクティス - Qiita

CSVファイルを扱う場合は、FlatFileItemReaderは使い物にならないことがあるそうな...

あと、「MyBatis Spring」を使う場合は依存関係の追加が必要みたいなんだけど、search.maven.org

⇧ 「MyBatis Spring」は「MyBatis Spring Boot Starter」の依存関係に含まれてるらしい...紛らわしい。

ちなみに、Spring Batchを使わずとも、

NOTE ここで扱うのは Spring Batch を使ったバッチ処理で、MyBatis の SqlSession を利用したバッチ処理ではありません。

https://mybatis.org/spring/ja/batch.html

⇧ MyBatisのみでもバッチ処理はできるらしく、

www.greptips.com

MyBatisでもExecutorType.BATCHを設定したSqlSessionを利用することでバッチ処理ができる。

Spring BootとMyBatisでアドホックにSQLをバッチ実行する方法 - grep Tips *

⇧ 上記サイト様でもSpring BatchなしでMyBatisのみを利用してバッチ処理をしてる模様。

By the way、ORM(Object Relational Mapping)が、MyBatisではなくJPAJava Persistence API)の場合は、Spring Batch用の専用クラスみたいなものは無いっぽい、多分。

話が脱線しましたが、

edge-labo.com

note.com

spring.pleiades.io

⇧ 上記サイト様を参考にSpring BatchをMyBatisと一緒に使ってみます。

プロジェクトは、

ts0818.hatenablog.com

⇧ 前回の記事で利用したものを使っていきます。

まずは、Spring Batchの依存関係と、CSVファイル読み込むためのライブラリをbuild.gradleに追加で。

■/mybatis-example/build.gradle

plugins {
	id 'org.springframework.boot' version '2.7.5'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
	id 'java'
}

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

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	// https://mvnrepository.com/artifact/org.mybatis.spring.boot/mybatis-spring-boot-starter
    implementation group: 'org.mybatis.spring.boot', name: 'mybatis-spring-boot-starter', version: '2.2.2'
    // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-batch
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-batch', version: '2.7.5'
    implementation 'com.opencsv:opencsv:5.7.1'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'org.postgresql:postgresql'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

そしたらば、Spring Batchでデータベースを利用する場合に、Spring Batch用のテーブルを作成する必要があるらしいので、

github.com

⇧ 上記サイト様から、自分が使用しているデータベース向けのSQLファイルを取得して実行することでテーブルを作成できるようです。

ただ、

isk666.hatenablog.com

Spring Batchのバージョンによって、テーブルのカラムが増えることがある模様。

terasoluna-batch Spring Batchのメタデータスキーマについて - 雑記

⇧ 上記サイト様の説明にあるように、ブランチがmainが最新ではない?っぽい。

自分の環境では、PostgreSQLを利用しているので、「schema-postgresql.sql」をダウンロードします。

ダウンロード先のフォルダを作成。

ダウンロード先が決まったら、curlコマンドでダウンロード。

curl --output [ダウンロード先] https://raw.githubusercontent.com/spring-projects/spring-batch/4.3.x/spring-batch-core/src/main/resources/org/springframework/batch/core/schema-postgresql.sql

ダウンロードできました。

A5:SQL Mk-2だと、外部ファイルの実行はできないようで、

szk302.dev

⇧ 「A5:SQL Mk-2 コマンドラインユーティリティ」ってのを別途ダウンロードする必要がある模様。

今回は、

programmer-life.work

⇧ 上記サイト様を参考に、PostgreSQLのコマンドでSQLファイルを実行しました。

Spring Batch用のテーブルが作成できてるのが確認できました。

で、Spring BatchによるCSVファイルの読み込みを試してみたのですが、読み込むCSVファイルが駄目なのか、

com.opencsv.exceptions.CsvMalformedLineException: Unterminated quoted field at end of CSV line. Beginning of lost text: [
]

⇧ ってエラーになって、CSVファイルの1つ目のデータしかINSERTされない...

一応、ファイルの構成は以下のようになってます。

今回、関係ありそうな部分を記載。EntityやRepository、XMLについては、MyBatis Generatorで作成したものを利用してるので、割愛。

■/mybatis-example/src/main/resources/userdata.csv

first_name,last_name,rome_first_name,rome_last_name,ohenro_ids
"鈴木","二郎","suzuki","jiro","1,2,3,4,5,6"
"鈴木","三郎","suzuki",saburo","24,25,26,27,28"
"鈴木","四郎","suzuki","shiro","40,41,42,43,44,45,46"
"鈴木","五郎","suzuki","goro","81,82,83,84,85,86"
"鈴木","六郎","suzuki","rokuro","1,2,3,4,5,6"
"鈴木","七郎","suzuki","shichiro","24,25,26,27,28,29"
"鈴木","八郎","suzuki","hachiro","40,41,42,43,44,45,46,47"
"鈴木","九郎","suzuki","kyuro","81,82,83,84,85,86"
"鈴木","十郎","suzuki","jyuro","1,2,3,4,5,6"

■/mybatis-example/src/main/java/com/example/demo/dto/CsvUser.java

package com.example.demo.dto;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class CsvUser {

	private String last_name;
	
	private String first_name;
	
	private String rome_last_name;
	
	private String rome_first_name;
	
	private String ohenro_ids;
}

■/mybatis-example/src/main/java/com/example/demo/batch/user/UserReader.java

package com.example.demo.batch.user;

import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Objects;

import org.springframework.batch.item.ReaderNotOpenException;
import org.springframework.batch.item.file.ResourceAwareItemReaderItemStream;
import org.springframework.batch.item.file.mapping.FieldSetMapper;
import org.springframework.batch.item.file.transform.DefaultFieldSet;
import org.springframework.batch.item.file.transform.FieldSet;
import org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
import org.springframework.validation.BindException;

import com.opencsv.CSVParserBuilder;
import com.opencsv.CSVReader;
import com.opencsv.CSVReaderBuilder;
import com.opencsv.exceptions.CsvValidationException;

import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Setter
public class UserReader<T> extends AbstractItemCountingItemStreamItemReader<T> implements ResourceAwareItemReaderItemStream<T>, InitializingBean {
    public static final Charset DEFAULT_CHARSET = Charset.defaultCharset();

    private Resource resource;

    private boolean noInput = false;

    private Charset charset = DEFAULT_CHARSET;

    private int linesToSkip = 0;

    private boolean strict = true;

    private char delimiter = ',';

    private char quotedChar = '"';
    
    private char escapeChar = '"';

    private String[] headers;

    private FieldSetMapper<T> fieldSetMapper;

    private CSVReader csvReader;
    
    private Resource resourceToRead;
    
	@Override
	public void afterPropertiesSet() throws Exception {
		Assert.notNull(this.headers, "header is required");
		Assert.notNull(this.fieldSetMapper, "FieldSetMapper is required");
	}

	@Override
	public void setResource(Resource resource) {
		this.resourceToRead = resource;
	}

	@Override
	protected T doRead() {
	     if (noInput) {
	        return null;
	    }
	   
	    if (csvReader == null) {
	        throw new ReaderNotOpenException("CSVReader is not initialized");
	    }
	        
	    String[] line;
	    T mapField = null;
		try {
			line = csvReader.readNext();
		   if (Objects.isNull(line)) {
		      return null;
		   }
		   FieldSet fs = new DefaultFieldSet(line, headers);
		   mapField = fieldSetMapper.mapFieldSet(fs);
		} catch (CsvValidationException | IOException | BindException e) {
			// 
			log.error("error", e);
		}
		return mapField;
	}

	@Override
	protected void doOpen() throws Exception {
        //Assert.notNull(resource, "Input resource must be set");

        noInput = true;
        if(Objects.nonNull(resource) && !resource.exists()){
            if(strict) {
                throw new IllegalStateException("Input resource must exist (reader is in 'strict' mode): " + resource);
            }
            log.warn("Input resource does not exist " + resource.getDescription());
            return;
        }

        if(Objects.nonNull(resource) && !resource.isReadable()){
            if(strict){
                throw new IllegalStateException("Input resource must be readable (reader is in 'strict' mode): " + resource);
            }
            log.warn("Input resource is not readable " + resource.getDescription());
        }
		
        var csvParserBuilder = new CSVParserBuilder().withSeparator(delimiter)
                .withQuoteChar(quotedChar)
                .withStrictQuotes(true);
        if (quotedChar != escapeChar) {
        	csvParserBuilder.withEscapeChar(escapeChar);
        }
        csvReader = new CSVReaderBuilder(new FileReader(resourceToRead.getFile(), charset))
                .withCSVParser(csvParserBuilder.build())
                .withSkipLines(linesToSkip)
                .build();
        noInput = false;
	}

	@Override
	protected void doClose() throws Exception {
		if (Objects.nonNull(csvReader)) {
			csvReader.close();
		}
	}

}

■/mybatis-example/src/main/java/com/example/demo/batch/user/CsvUserMapper.java

package com.example.demo.batch.user;

import org.springframework.batch.item.file.mapping.FieldSetMapper;
import org.springframework.batch.item.file.transform.FieldSet;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindException;

import com.example.demo.dto.CsvUser;

@Component
public class CsvUserMapper implements FieldSetMapper<CsvUser> {

	@Override
	public CsvUser mapFieldSet(FieldSet fieldSet) throws BindException {
		return CsvUser.builder()
				.first_name(fieldSet.readString(0))
				.last_name(fieldSet.readString(1))
				.rome_first_name(fieldSet.readString(2))
				.rome_last_name(fieldSet.readString(3))
				.ohenro_ids(fieldSet.readString(4))
				.build();
	}
}
    

■/mybatis-example/src/main/java/com/example/demo/batch/user/UserProcessor.java

package com.example.demo.batch.user;

import org.springframework.batch.item.ItemProcessor;
import org.springframework.stereotype.Component;

import com.example.demo.dto.CsvUser;
import com.example.demo.entity.ShikokuOhenroUser;

@Component
public class UserProcessor implements ItemProcessor<CsvUser, ShikokuOhenroUser> {

	@Override
	public ShikokuOhenroUser process(CsvUser item) throws Exception {
		// 
		return ShikokuOhenroUser.builder()
				.last_name(fieldSet.readString(0))
				.first_name(fieldSet.readString(1))
				.rome_last_name(fieldSet.readString(2))
				.rome_first_name(fieldSet.readString(3))
				.ohenro_ids(fieldSet.readString(4))
				.build();
	}
}

■/mybatis-example/src/main/java/com/example/demo/batch/user/UserWriter.java

package com.example.demo.batch.user;

import java.util.List;

import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.example.demo.entity.ShikokuOhenroUser;
import com.example.demo.repository.ShikokuOhenroUserMapper;

@Component
public class UserWriter implements ItemWriter<ShikokuOhenroUser> {

	@Autowired
	private ShikokuOhenroUserMapper shikokuOhenroUserMapper;

	@Override
	public void write(List<? extends ShikokuOhenroUser> items) throws Exception {
		// 
		for (ShikokuOhenroUser user: items) {
			shikokuOhenroUserMapper.insert(user);
		}
	}

}    

■/mybatis-example/src/main/java/com/example/demo/batch/user/UserBatchConfig.java

package com.example.demo.batch.user;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

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.launch.support.RunIdIncrementer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

import com.example.demo.dto.CsvUser;
import com.example.demo.entity.ShikokuOhenroUser;

@Configuration
@EnableBatchProcessing
public class UserBatchConfig {

	@Autowired
	private JobBuilderFactory jobBuilderFactory;
	
	@Autowired
	private StepBuilderFactory stepBuilderFactory;
	
	@Bean
	@StepScope
	public Resource userCsvResource(@Value("#{jobParameters['filePath']}") String filePath) {
		return new FileSystemResource(filePath);
	}
	
    @Bean
    public UserReader<CsvUser> csvUserReader(CsvUserMapper csvUserMapper) throws IOException {
        UserReader<CsvUser> reader = new UserReader<>();
        reader.setCharset(StandardCharsets.UTF_8);
        reader.setStrict(true);
        reader.setResource(new ClassPathResource("userdata.csv"));
        reader.setLinesToSkip(1);
        reader.setHeaders(new String[] {"first_name", "last_name", "rome_first_name", "rome_last_name", "ohenro_ids"});
        reader.setFieldSetMapper(csvUserMapper);
        reader.setDelimiter(',');
        reader.setQuotedChar('"');
        reader.setName("csvReader");
        return reader;
    }
    
    @Bean
    public Step step1(UserReader<CsvUser> userReader, UserWriter userWriter, UserProcessor userProcessor) {
        return stepBuilderFactory.get("csvItemReaderStep")
                .<CsvUser, ShikokuOhenroUser> chunk(10)
                .reader(userReader)
                .processor(userProcessor)
                .writer(userWriter)
                .build();
    }
    
    @Bean
    public Job importUserJob(Step step1) {
    	return jobBuilderFactory.get("importUserJob")
    			.incrementer(new RunIdIncrementer())
    			.start(step1)
    			.build();
    }
}

で、実行すると、CSVファイルの1つ目のデータだけINSERTされて、エラーになってしまう...

う~む、参考サイト様と同じ様に実装してるように思うのだけど、CSVファイルの1つ目のデータしか読み込めない原因が分からずです...

OpenCSVの闇なのか...

2022年11月13日(日)追記:↓ ここから

CSVライブラリの挙動の比較をしてらっしゃる方がおり、

hhelibex.hatenablog.jp

hhelibex.hatenablog.jp

⇧ 上記サイト様の情報によると、OpenCSVで読み取れない文字列の組み合わせがあるはあるみたいなんだけど、CSVファイルに依存してしまうということですかね...

で、CSVファイルをよく見たら、

⇧ 値にダブルクォーテーションが含まれてしまっていたので、データがダブルクォーテーションで閉じられてない箇所があったので

■/mybatis-example/src/main/resources/userdata.csv

first_name,last_name,rome_first_name,rome_last_name,ohenro_ids
"鈴木","二郎","suzuki","jiro","1,2,3,4,5,6"
"鈴木","三郎","suzuki",saburo","24,25,26,27,28"
"鈴木","四郎","suzuki","shiro","40,41,42,43,44,45,46"
"鈴木","五郎","suzuki","goro","81,82,83,84,85,86"
"鈴木","六郎","suzuki","rokuro","1,2,3,4,5,6"
"鈴木","七郎","suzuki","shichiro","24,25,26,27,28,29"
"鈴木","八郎","suzuki","hachiro","40,41,42,43,44,45,46,47"
"鈴木","九郎","suzuki","kyuro","81,82,83,84,85,86"
"鈴木","十郎","suzuki","jyuro","1,2,3,4,5,6"

修正して、

■/mybatis-example/src/main/resources/userdata.csv

first_name,last_name,rome_first_name,rome_last_name,ohenro_ids
"鈴木","二郎","suzuki","jiro","1,2,3,4,5,6"
"鈴木","三郎","suzuki","saburo","24,25,26,27,28"
"鈴木","四郎","suzuki","shiro","40,41,42,43,44,45,46"
"鈴木","五郎","suzuki","goro","81,82,83,84,85,86"
"鈴木","六郎","suzuki","rokuro","1,2,3,4,5,6"
"鈴木","七郎","suzuki","shichiro","24,25,26,27,28,29"
"鈴木","八郎","suzuki","hachiro","40,41,42,43,44,45,46,47"
"鈴木","九郎","suzuki","kyuro","81,82,83,84,85,86"
"鈴木","十郎","suzuki","jyuro","1,2,3,4,5,6"

アプリケーションを実行したところ、

CSVファイルの全てのデータがINSERTされました。

CSVファイルのフォーマットがしっかりしている必要があるんですね、CSVファイル扱いたくないな~...

2022年11月13日(日)追記:↑ ここまで

とりあえず、分かったことは、Spring Batchめちゃくちゃ使い辛い...

毎度モヤモヤ感が半端ない...

今回はこのへんで。