CLOVER🍀

That was when it all began.

Spring Batchで、Bean ValidationとItemのスキップの動作を確認する

これは、なにをしたくて書いたもの?

Spring BatchとBean Validationを組み合わせて、Itemのバリデーションをしたり、NGになったItemをスキップできたりするというので
ちょっと試してみようかなと。

Spring BatchとBean Validation

Spring BatchでBean Validationが使えることはドキュメントに書かれているわけですが、ItemProcessorの用途のひとつとして登場します。

Item processing / Validating Input

BeanValidatingItemProcessorというItemProcessorが提供されているので、こちらを使って実現するようですね。

BeanValidatingItemProcessor (Spring Batch 4.3.5 API)

Springのバリデーションを使う場合は、こちらのようです。

ValidatingItemProcessor (Spring Batch 4.3.5 API)

バリデーションでNGになると例外がスローされるので、そのままだとアプリケーションが停止するようです。

原因となったItemをスキップするには、Stepで設定するようです。

Configuring a Step / Chunk-oriented Processing / Configuring Skip Logic

今回は、このあたりを試してみます。

お題

今回のお題は、以下とします。

  • 書籍データのCSVを読み込み、読み込んだデータを標準出力に書き出すChunk形式のJobおよびStepを作成する
  • Bean Validationを行うitemProcessorを追加し、エラーになるItemを含んだCSVを入力する
  • まずはバリデーションNGになって停止するところを確認し、次にエラーになったItemをスキップする設定を行う

こんな感じでやっていきます。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.3 2022-04-19
OpenJDK Runtime Environment (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1)
OpenJDK 64-Bit Server VM (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.5 (3599d3414f046de2324203b78ddcf9b5e4388aa0)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.3, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-109-generic", arch: "amd64", family: "unix"

JobRepositoryのデータは、MySQLに格納することにします。バージョンは以下で、172.17.0.2で動作しているものとします。

$ mysql --version
mysql  Ver 8.0.28 for Linux on x86_64 (MySQL Community Server - GPL)

準備

では、Spring Bootプロジェクトを作成していきます。

依存関係にbatchvalidationmysqlを指定して、作成。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=2.6.7 \
  -d javaVersion=17 \
  -d name=batch-beanvalidation \
  -d groupId=org.littlewings \
  -d artifactId=batch-beanvalidation \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.batch \
  -d dependencies=batch,validation,mysql \
  -d baseDir=batch-beanvalidation | tar zxvf -

プロジェクト内に移動。

$ cd batch-beanvalidation

自動生成されたソースコードは、削除しておきます。

$ rm src/main/java/org/littlewings/spring/batch/BatchBeanvalidationApplication.java src/test/java/org/littlewings/spring/batch/BatchBeanvalidationApplicationTests.java

Mavenの依存関係およびプラグインの設定は、こちら。

 <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-batch</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.batch</groupId>
            <artifactId>spring-batch-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

続いて、ソースコードを作成していきます。

Item相当のクラス。

src/main/java/org/littlewings/spring/batch/Book.java

package org.littlewings.spring.batch;

import javax.validation.constraints.Min;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

public class Book {
    @Size(min = 14, max = 14)
    @Pattern(regexp = "\\d{3}-\\d{10}")
    String isbn;

    @Size(max = 200)
    String title;

    @Min(1000)
    Integer price;

    // getter/setterは省略
}

謎に、1,000円以上の本しか受け付けないようになっています。

Jobの定義。

src/main/java/org/littlewings/spring/batch/BeanValidationJobConfig.java

package org.littlewings.spring.batch;

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.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.batch.core.step.skip.AlwaysSkipItemSkipPolicy;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.item.validator.BeanValidatingItemProcessor;
import org.springframework.batch.item.validator.ValidationException;
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.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class BeanValidationJobConfig {
    @Autowired
    JobBuilderFactory jobBuilderFactory;

    @Autowired
    StepBuilderFactory stepBuilderFactory;

    @Autowired
    LocalValidatorFactoryBean localValidatorFactoryBean;

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                // 後で
                .build();
    }

    // 後で

    @StepScope
    @Bean
    public FlatFileItemReader<Book> flatFileItemReader(@Value("#{jobParameters['filePath']}") String filePath) {
        Resource fileResource = new FileSystemResource(filePath);

        return new FlatFileItemReaderBuilder<Book>()
                .name("flatFileItemReader")
                .resource(fileResource)
                .encoding("UTF-8")
                .delimited()
                .names(new String[]{"isbn", "title", "price"})
                .linesToSkip(1)
                .targetType(Book.class)
                .saveState(false)
                .build();
    }

    // 後で

    @StepScope
    @Bean
    public ItemWriter<Book> consoleItemWriter() {
        Logger logger = LoggerFactory.getLogger("consoleItemWriter");

        return books -> books.forEach(book ->
                logger.info("[writer] isbn = {}, title = {}, price = {}", book.getIsbn(), book.getTitle(), book.getPrice())
        );
    }
}

Jobに含まれるStepの構成や

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                // 後で
                .build();
    }

    // 後で

Bean Validationを使うItemProcessorの記述は後回しにしています。

    // 後で

なお、ファイルの読み込みはFlatFileItemReaderBuilderで行い、

    @StepScope
    @Bean
    public FlatFileItemReader<Book> flatFileItemReader(@Value("#{jobParameters['filePath']}") String filePath) {
        Resource fileResource = new FileSystemResource(filePath);

        return new FlatFileItemReaderBuilder<Book>()
                .name("flatFileItemReader")
                .resource(fileResource)
                .encoding("UTF-8")
                .delimited()
                .names(new String[]{"isbn", "title", "price"})
                .linesToSkip(1)
                .targetType(Book.class)
                .saveState(false)
                .build();
    }

ItemWriterは渡ってきたチャンクを標準出力に書き出すように作成。

    @StepScope
    @Bean
    public ItemWriter<Book> consoleItemWriter() {
        Logger logger = LoggerFactory.getLogger("consoleItemWriter");

        return books -> books.forEach(book ->
                logger.info("[writer] isbn = {}, title = {}, price = {}", book.getIsbn(), book.getTitle(), book.getPrice())
        );
    }

mainクラス。

src/main/java/org/littlewings/spring/batch/App.java

package org.littlewings.spring.batch;

import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableBatchProcessing
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

設定は、こんな感じにしておきます。

src/main/resources/application.properties

spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=UTF-8
spring.datasource.username=kazuhira
spring.datasource.password=password

spring.batch.jdbc.initialize-schema=always

CSVも作成しましょう。

まずは、全Itemが問題ないデータのCSV

src/main/resources/book.csv

isbn,title,price
978-4798142470,Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発,4400
978-4774182179,[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ,4180
978-1492076988,Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications,6265
978-4295000198,やさしく学べるMySQL運用・管理入門【5.7対応】,2860
978-1484237236,The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud,7361
978-4798147406,詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド (NEXT ONE),3960
978-4798161488,MySQL徹底入門 第4版 MySQL 8.0対応,4180
978-4797393118,基礎からのMySQL 第3版 (基礎からシリーズ),6038
978-4873116389,実践ハイパフォーマンスMySQL 第3版,5280
978-4774170206,MariaDB&MySQL全機能バイブル,3850

全10件あり、ヘッダー入りです。

次に、ところどころおかしなデータが入ったCSV

src/main/resources/book_invalid.csv

isbn,title,price
978-4798142470,Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発,4400
xx-xxxxxxx,[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ,4180
978-1492076988,Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications,6265
978-4295000198,やさしく学べるMySQL運用・管理入門【5.7対応】,860
978-1484237236,The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud,7361
978-4798147406,詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド (NEXT ONE),960
978-4798161488,MySQL徹底入門 第4版 MySQL 8.0対応,4180
978-4797393118,基礎からのMySQL 第3版 (基礎からシリーズ),6038
978-4873116389,実践ハイパフォーマンスMySQL 第3版,5280
978-4774170206,MariaDB&MySQL全機能バイブル,850

4件、エラーになるデータ(ISBNの形式誤り、価格が1,000円未満)が入っています。

ここまでで、準備は完了です。

BeanValidatingItemProcessorを使う

では、作成したJobにBean Validationを行うようにStepを構成していきましょう。

ここからは、先ほど「// 後で」とコメントを書いていた部分を埋めたり、変更していったりします。

最初は、バリデーションなしで

まずは、Bean Validationを行わないようにStepを構成します。

Stepの定義。

    @Bean
    public Step noBeanValidationStep() {
        return stepBuilderFactory
                .get("withBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .writer(consoleItemWriter())
                .build();
    }

Jobの定義。

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                .start(noBeanValidationStep())
                .build();
    }

これでパッケージングして

$ mvn package

実行。まずは、正しいデータが入ったCSVを読み込せてみます。

$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book.csv

結果。

2022-04-28 01:37:52.114  INFO 17646 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book.csv]
2022-04-28 01:37:52.291  INFO 17646 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=5, filePath=src/main/resources/book.csv}]
2022-04-28 01:37:52.394  INFO 17646 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [withBeanValidationStep]
2022-04-28 01:37:52.510  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798142470, title = Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発, price = 4400
2022-04-28 01:37:52.511  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4774182179, title = [改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ, price = 4180
2022-04-28 01:37:52.511  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1492076988, title = Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications, price = 6265
2022-04-28 01:37:52.540  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4295000198, title = やさしく学べるMySQL運用・管理入門【5.7対応】, price = 2860
2022-04-28 01:37:52.540  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1484237236, title = The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud, price = 7361
2022-04-28 01:37:52.540  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798147406, title = 詳解MySQL 5.7 止まらぬ進化に乗り遅 れないためのテクニカルガイド (NEXT ONE), price = 3960
2022-04-28 01:37:52.566  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798161488, title = MySQL徹底入門 第4版 MySQL 8.0対応, price = 4180
2022-04-28 01:37:52.566  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4797393118, title = 基礎からのMySQL 第3版 (基礎からシリーズ), price = 6038
2022-04-28 01:37:52.567  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4873116389, title = 実践ハイパフォーマンスMySQL 第3版, price = 5280
2022-04-28 01:37:52.594  INFO 17646 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4774170206, title = MariaDB&MySQL全機能バイブル, price = 3850
2022-04-28 01:37:52.621  INFO 17646 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [withBeanValidationStep] executed in 226ms
2022-04-28 01:37:52.675  INFO 17646 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=5, filePath=src/main/resources/book.csv}] and the following status: [COMPLETED] in 339ms

次に、エラーになるCSVを読み込ませてみます。

$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book_invalid.csv

ですが、そもそもBean Validationをまだ入れていないので全件通過します。

2022-04-28 01:38:43.458  INFO 17727 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book_invali
d.csv]
2022-04-28 01:38:43.638  INFO 17727 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=6, filePath=src/main/resources/book_invalid.csv}]
2022-04-28 01:38:43.768  INFO 17727 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [withBeanValidationStep]
2022-04-28 01:38:43.890  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798142470, title = Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発, price = 4400
2022-04-28 01:38:43.890  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = xx-xxxxxxx, title = [改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ, price = 4180
2022-04-28 01:38:43.890  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1492076988, title = Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications, price = 6265
2022-04-28 01:38:43.920  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4295000198, title = やさしく学べるMySQL運用・管理入門【5.7対応】, price = 860
2022-04-28 01:38:43.921  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1484237236, title = The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud, price = 7361
2022-04-28 01:38:43.921  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798147406, title = 詳解MySQL 5.7 止まらぬ進化に乗り遅 れないためのテクニカルガイド (NEXT ONE), price = 960
2022-04-28 01:38:43.948  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798161488, title = MySQL徹底入門 第4版 MySQL 8.0対応, price = 4180
2022-04-28 01:38:43.948  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4797393118, title = 基礎からのMySQL 第3版 (基礎からシリーズ), price = 6038
2022-04-28 01:38:43.948  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4873116389, title = 実践ハイパフォーマンスMySQL 第3版, price = 5280
2022-04-28 01:38:43.975  INFO 17727 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4774170206, title = MariaDB&MySQL全機能バイブル, price = 850
2022-04-28 01:38:44.002  INFO 17727 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [withBeanValidationStep] executed in 234ms
2022-04-28 01:38:44.061  INFO 17727 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=6, filePath=src/main/resources/book_invalid.csv}] and the following status: [COMPLETED] in 373ms
BeanValidatingItemProcessorを追加する

では、BeanValidatingItemProcessorを追加してみましょう。

Item processing / Validating Input

以下の定義を追加します。

    @StepScope
    @Bean
    public BeanValidatingItemProcessor<Book> beanValidatingItemProcessor() {
        return new BeanValidatingItemProcessor<>(localValidatorFactoryBean);
    }

コンストラクタで指定しているLocalValidatorFactoryBeanは、Spring BootのAuto Configurationでセットアップされたものです。
指定しなくても内部的にLocalValidatorFactoryBeanを生成するのですが、せっかくならSpring Bootでセットアップされたものを
使った方がよいかなと思います。

Step定義は以下のようにItemProcessorを追加して

    @Bean
    public Step withBeanValidationStep() {
        return stepBuilderFactory
                .get("withBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .build();
    }

Jobを変更。

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                .start(withBeanValidationStep())
                .build();
    }

パッケージングして実行。

$ mvn package
$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book.csv

エラーにならない方の結果は、省略します。

続いて、エラーになる方のCSVを読み込ませます。

$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book_invalid.csv

すると、エラーになるItemに遭遇した時点で例外をスローしてアプリケーションが終了します。

2022-04-28 01:42:46.217  INFO 17951 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book_invalid.csv]
2022-04-28 01:42:46.432  INFO 17951 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=8, filePath=src/main/resources/book_invalid.csv}]
2022-04-28 01:42:46.530  INFO 17951 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [withBeanValidationStep]
2022-04-28 01:42:46.718 ERROR 17951 --- [           main] o.s.batch.core.step.AbstractStep         : Encountered an error executing step withBeanValidationStep in job beanValidationJob

org.springframework.batch.item.validator.ValidationException: Validation failed for org.littlewings.spring.batch.Book@11dee337:
Field error in object 'item' on field 'isbn': rejected value [xx-xxxxxxx]; codes [Pattern.item.isbn,Pattern.isbn,Pattern.java.lang.String,Pattern]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.isbn,isbn]; arguments []; default message [isbn],[Ljavax.validation.constraints.Pattern$Flag;@532a02d9,\d{3}-\d{10}]; default message [正規表現 "\d{3}-\d{10}" にマッチさせてください]
Field error in object 'item' on field 'isbn': rejected value [xx-xxxxxxx]; codes [Size.item.isbn,Size.isbn,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.isbn,isbn]; arguments []; default message [isbn],14,14]; default message [14 から 14 の間のサイズにしてください]
        at org.springframework.batch.item.validator.SpringValidator.validate(SpringValidator.java:54) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.item.validator.ValidatingItemProcessor.process(ValidatingItemProcessor.java:84) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.item.validator.ValidatingItemProcessor$$FastClassBySpringCGLIB$$39980290.invoke(<generated>) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:793) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.support.DelegatingIntroductionInterceptor.doProceed(DelegatingIntroductionInterceptor.java:137) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.support.DelegatingIntroductionInterceptor.invoke(DelegatingIntroductionInterceptor.java:124) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.item.validator.BeanValidatingItemProcessor$$EnhancerBySpringCGLIB$$b5c9b6d8.process(<generated>) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.SimpleChunkProcessor.doProcess(SimpleChunkProcessor.java:134) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.SimpleChunkProcessor.transform(SimpleChunkProcessor.java:319) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.SimpleChunkProcessor.process(SimpleChunkProcessor.java:210) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.ChunkOrientedTasklet.execute(ChunkOrientedTasklet.java:77) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:407) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:331) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140) ~[spring-tx-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.core.step.tasklet.TaskletStep$2.doInChunkContext(TaskletStep.java:273) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.scope.context.StepContextRepeatCallback.doInIteration(StepContextRepeatCallback.java:82) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.repeat.support.RepeatTemplate.getNextResult(RepeatTemplate.java:375) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.repeat.support.RepeatTemplate.executeInternal(RepeatTemplate.java:215) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.repeat.support.RepeatTemplate.iterate(RepeatTemplate.java:145) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.tasklet.TaskletStep.doExecute(TaskletStep.java:258) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.AbstractStep.execute(AbstractStep.java:208) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.SimpleStepHandler.handleStep(SimpleStepHandler.java:152) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.AbstractJob.handleStep(AbstractJob.java:413) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.SimpleJob.doExecute(SimpleJob.java:136) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.AbstractJob.execute(AbstractJob.java:320) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.launch.support.SimpleJobLauncher$1.run(SimpleJobLauncher.java:149) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.core.task.SyncTaskExecutor.execute(SyncTaskExecutor.java:50) ~[spring-core-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.core.launch.support.SimpleJobLauncher.run(SimpleJobLauncher.java:140) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:344) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:198) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.core.configuration.annotation.SimpleBatchConfiguration$PassthruAdvice.invoke(SimpleBatchConfiguration.java:128) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at jdk.proxy2/jdk.proxy2.$Proxy58.run(Unknown Source) ~[na:na]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.execute(JobLauncherApplicationRunner.java:199) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.executeLocalJobs(JobLauncherApplicationRunner.java:173) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.launchJobFromProperties(JobLauncherApplicationRunner.java:160) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.run(JobLauncherApplicationRunner.java:155) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.run(JobLauncherApplicationRunner.java:150) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:768) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:758) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:310) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1312) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1301) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.littlewings.spring.batch.App.main(App.java:11) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:49) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:108) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:58) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
        at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:88) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
Caused by: org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 2 errors
Field error in object 'item' on field 'isbn': rejected value [xx-xxxxxxx]; codes [Pattern.item.isbn,Pattern.isbn,Pattern.java.lang.String,Pattern]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.isbn,isbn]; arguments []; default message [isbn],[Ljavax.validation.constraints.Pattern$Flag;@532a02d9,\d{3}-\d{10}]; default message [正規表現 "\d{3}-\d{10}" にマッチさせてください]
Field error in object 'item' on field 'isbn': rejected value [xx-xxxxxxx]; codes [Size.item.isbn,Size.isbn,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.isbn,isbn]; arguments []; default message [isbn],14,14]; default message [14 から 14 の間のサイズにしてください]
        ... 64 common frames omitted

2022-04-28 01:42:46.722  INFO 17951 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [withBeanValidationStep] executed in 192ms
2022-04-28 01:42:46.778  INFO 17951 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=8, filePath=src/main/resources/book_invalid.csv}] and the following status: [FAILED] in 303ms

FAILEDになってしまいました。

エラーになったItemを読み飛ばす

次に、エラーになったItemをスキップするように設定してみましょう。

Configuring a Step / Chunk-oriented Processing / Configuring Skip Logic

faultTolerantを使い、次にスキップする例外(skip)とスキップ可能な数(skipLimit)を指定します。

    @Bean
    public Step faultTolerantBeanValidationStep() {
        return stepBuilderFactory
                .get("withBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(5)
                .build();
    }

skipLimitは、0より大きな値を指定する必要があります。無制限、みたいなことはできなさそうです。

Job定義も変更。

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                .start(faultTolerantBeanValidationStep())
                .build();
    }

パッケージングして、実行。ここから先は、エラーになるファイルのみ読み込ませます。

$ mvn package
$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book_invalid.csv

ログ。

2022-04-28 01:48:14.347  INFO 18194 --- [           main] org.littlewings.spring.batch.App         : Started App in 1.941 seconds (JVM running for 2.392)
2022-04-28 01:48:14.348  INFO 18194 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book_invalid.csv]
2022-04-28 01:48:14.522  INFO 18194 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=9, filePath=src/main/resources/book_invalid.csv}]
2022-04-28 01:48:14.649  INFO 18194 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [withBeanValidationStep]
2022-04-28 01:48:14.849  INFO 18194 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798142470, title = Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発, price = 4400
2022-04-28 01:48:14.849  INFO 18194 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1492076988, title = Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications, price = 6265
2022-04-28 01:48:14.883  INFO 18194 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1484237236, title = The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud, price = 7361
2022-04-28 01:48:14.911  INFO 18194 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798161488, title = MySQL徹底入門 第4版 MySQL 8.0対応, price = 4180
2022-04-28 01:48:14.911  INFO 18194 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4797393118, title = 基礎からのMySQL 第3版 (基礎からシリーズ), price = 6038
2022-04-28 01:48:14.911  INFO 18194 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4873116389, title = 実践ハイパフォーマンスMySQL 第3版, price = 5280
2022-04-28 01:48:14.965  INFO 18194 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [withBeanValidationStep] executed in 316ms
2022-04-28 01:48:15.019  INFO 18194 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=9, filePath=src/main/resources/book_invalid.csv}] and the following status: [COMPLETED] in 448ms

今度は、正常に終了するようになりました。

ItemWriterはそのまま処理されたItemのみが出力され、こちらは想定通りですが、どのようなItemがエラーになったかはわかりません。

スキップしたItemをログ出力する

スキップしたItemをログ出力するようにしてみましょう。

Common Batch Patterns / Logging Item Processing and Failures

SkipListenerインターフェースを実装するか、@OnSkipInProcessアノテーションを付与したメソッドを持つクラスを作成する必要があります。

SkipListener (Spring Batch 4.3.5 API)

OnSkipInProcess (Spring Batch 4.3.5 API)

このようなListenerを作成。

src/main/java/org/littlewings/spring/batch/RejectItemLoggingListener.java

package org.littlewings.spring.batch;

import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.annotation.OnSkipInProcess;
import org.springframework.batch.item.validator.ValidationException;
import org.springframework.validation.BindException;

public class RejectItemLoggingListener {
    Logger logger = LoggerFactory.getLogger(RejectItemLoggingListener.class);

    @OnSkipInProcess
    public void OnSkipInProcess(Book book, Throwable throwable) {
        if (throwable instanceof ValidationException) {
            ((BindException) throwable.getCause()).getMessage();
            logger.info(
                    "validation error, isbn = {}, title = {}, reject reason = {}",
                    book.getIsbn(),
                    book.getTitle(),
                    ((BindException) throwable.getCause())
                            .getBindingResult()
                            .getFieldErrors()
                            .stream().map(e -> e.getObjectName() + "#" + e.getField() + ":" + e.getDefaultMessage()).collect(Collectors.joining(", "))
            );
        } else {
            logger.error("error", throwable);
        }
    }
}

こちらをBean定義して

    @StepScope
    @Bean
    public RejectItemLoggingListener rejectItemLoggingListener() {
        return new RejectItemLoggingListener();
    }

faultTolerantの後にListenerとして追加します。

    @Bean
    public Step loggingInvalidItemBeanValidationStep() {
        return stepBuilderFactory
                .get("loggingInvalidItemBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(5)
                .listener(rejectItemLoggingListener())
                .build();
    }

Jobを構成。

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                .start(loggingInvalidItemBeanValidationStep())
                .build();
    }

実行。

$ mvn package
$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book_invalid.csv

ログ。

2022-04-28 01:53:07.154  INFO 18429 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book_invalid.csv]
2022-04-28 01:53:07.330  INFO 18429 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=10, filePath=src/main/resources/book_invalid.csv}]
2022-04-28 01:53:07.460  INFO 18429 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [loggingInvalidItemBeanValidationStep]
2022-04-28 01:53:07.693  INFO 18429 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798142470, title = Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発, price = 4400
2022-04-28 01:53:07.693  INFO 18429 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1492076988, title = Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications, price = 6265
2022-04-28 01:53:07.697  INFO 18429 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = xx-xxxxxxx, title = [改訂新版]Spring入門 ――Javaフ レームワーク・より良い設計とアーキテクチャ, reject reason = item#isbn:正規表現 "\d{3}-\d{10}" にマッチさせてください, item#isbn:14 から 14 の間のサイズにしてください
2022-04-28 01:53:07.748  INFO 18429 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1484237236, title = The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud, price = 7361
2022-04-28 01:53:07.748  INFO 18429 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4295000198, title = やさしく学べるMySQL運用・ 管理入門【5.7対応】, reject reason = item#price:1000 以上の値にしてください
2022-04-28 01:53:07.748  INFO 18429 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4798147406, title = 詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド (NEXT ONE), reject reason = item#price:1000 以上の値にしてください
2022-04-28 01:53:07.776  INFO 18429 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798161488, title = MySQL徹底入門 第4版 MySQL 8.0対応, price = 4180
2022-04-28 01:53:07.776  INFO 18429 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4797393118, title = 基礎からのMySQL 第3版 (基礎からシリーズ), price = 6038
2022-04-28 01:53:07.776  INFO 18429 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4873116389, title = 実践ハイパフォーマンスMySQL 第3版, price = 5280
2022-04-28 01:53:07.805  INFO 18429 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4774170206, title = MariaDB&MySQL全機能バイブ ル, reject reason = item#price:1000 以上の値にしてください
2022-04-28 01:53:07.833  INFO 18429 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [loggingInvalidItemBeanValidationStep] executed in 372ms
2022-04-28 01:53:07.883  INFO 18429 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=10, filePath=src/main/resources/book_invalid.csv}] and the following status: [COMPLETED] in 514ms

エラーになったItemが、ログに出力されるようになりました。

ちなみに、以下のようにfaultTolerantの前にListenerを追加しても機能しません。

    @Bean
    public Step invalidSkipItemListenerBeanValidationStep() {
        return stepBuilderFactory
                .get("invalidSkipItemListenerBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .listener(rejectItemLoggingListener())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(5)
                .build();
    }

listenerメソッドの引数の型がObjectのものがあるので渡せてしまうのですが、SkipListenerインターフェースを実装している場合は、
型が合わずにコンパイルが通らなくなるのでこのようなミスは発生しません…。

skipで指定した値を上回った場合

skipで指定した数を超えてItemをスキップした場合に、どうなるかを確認してみましょう。

バリデーションがNGになるItemの数は4なので、skipに3を指定してみます。

    @Bean
    public Step loggingInvalidItemBeanValidationStopStep() {
        return stepBuilderFactory
                .get("loggingInvalidItemBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(3)
                .listener(rejectItemLoggingListener())
                .build();
    }

Jobの構成。

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                .start(loggingInvalidItemBeanValidationStopStep())
                .build();
    }

実行。

$ mvn package
$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book_invalid.csv

結果。

2022-04-28 01:57:38.529  INFO 18665 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book_invalid.csv]
2022-04-28 01:57:38.734  INFO 18665 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=11, filePath=src/main/resources/book_invalid.csv}]
2022-04-28 01:57:38.827  INFO 18665 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [loggingInvalidItemBeanValidationStep]
2022-04-28 01:57:39.020  INFO 18665 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798142470, title = Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発, price = 4400
2022-04-28 01:57:39.020  INFO 18665 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1492076988, title = Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications, price = 6265
2022-04-28 01:57:39.025  INFO 18665 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = xx-xxxxxxx, title = [改訂新版]Spring入門 ――Javaフ レームワーク・より良い設計とアーキテクチャ, reject reason = item#isbn:14 から 14 の間のサイズにしてください, item#isbn:正規表現 "\d{3}-\d{10}" にマッチさせてください
2022-04-28 01:57:39.055  INFO 18665 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1484237236, title = The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud, price = 7361
2022-04-28 01:57:39.056  INFO 18665 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4295000198, title = やさしく学べるMySQL運用・ 管理入門【5.7対応】, reject reason = item#price:1000 以上の値にしてください
2022-04-28 01:57:39.057  INFO 18665 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4798147406, title = 詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド (NEXT ONE), reject reason = item#price:1000 以上の値にしてください
2022-04-28 01:57:39.082  INFO 18665 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798161488, title = MySQL徹底入門 第4版 MySQL 8.0対応, price = 4180
2022-04-28 01:57:39.082  INFO 18665 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4797393118, title = 基礎からのMySQL 第3版 (基礎からシリーズ), price = 6038
2022-04-28 01:57:39.082  INFO 18665 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4873116389, title = 実践ハイパフォーマンスMySQL 第3版, price = 5280
2022-04-28 01:57:39.110 ERROR 18665 --- [           main] o.s.batch.core.step.AbstractStep         : Encountered an error executing step loggingInvalidItemBeanValidationStep in job beanValidationJob

org.springframework.batch.core.step.skip.SkipLimitExceededException: Skip limit of '3' exceeded
        at org.springframework.batch.core.step.skip.LimitCheckingItemSkipPolicy.shouldSkip(LimitCheckingItemSkipPolicy.java:133) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.skip.ExceptionClassifierSkipPolicy.shouldSkip(ExceptionClassifierSkipPolicy.java:70) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.FaultTolerantChunkProcessor.shouldSkip(FaultTolerantChunkProcessor.java:519) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.FaultTolerantChunkProcessor.access$500(FaultTolerantChunkProcessor.java:56) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.FaultTolerantChunkProcessor$2.recover(FaultTolerantChunkProcessor.java:289) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.retry.support.RetryTemplate.handleRetryExhausted(RetryTemplate.java:539) ~[spring-retry-1.3.3.jar!/:na]
        at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:387) ~[spring-retry-1.3.3.jar!/:na]
        at org.springframework.retry.support.RetryTemplate.execute(RetryTemplate.java:255) ~[spring-retry-1.3.3.jar!/:na]
        at org.springframework.batch.core.step.item.BatchRetryTemplate.execute(BatchRetryTemplate.java:217) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.FaultTolerantChunkProcessor.transform(FaultTolerantChunkProcessor.java:308) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.SimpleChunkProcessor.process(SimpleChunkProcessor.java:210) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.ChunkOrientedTasklet.execute(ChunkOrientedTasklet.java:77) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:407) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:331) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140) ~[spring-tx-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.core.step.tasklet.TaskletStep$2.doInChunkContext(TaskletStep.java:273) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.scope.context.StepContextRepeatCallback.doInIteration(StepContextRepeatCallback.java:82) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.repeat.support.RepeatTemplate.getNextResult(RepeatTemplate.java:375) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.repeat.support.RepeatTemplate.executeInternal(RepeatTemplate.java:215) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.repeat.support.RepeatTemplate.iterate(RepeatTemplate.java:145) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.tasklet.TaskletStep.doExecute(TaskletStep.java:258) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.AbstractStep.execute(AbstractStep.java:208) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.SimpleStepHandler.handleStep(SimpleStepHandler.java:152) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.AbstractJob.handleStep(AbstractJob.java:413) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.SimpleJob.doExecute(SimpleJob.java:136) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.job.AbstractJob.execute(AbstractJob.java:320) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.launch.support.SimpleJobLauncher$1.run(SimpleJobLauncher.java:149) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.core.task.SyncTaskExecutor.execute(SyncTaskExecutor.java:50) ~[spring-core-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.core.launch.support.SimpleJobLauncher.run(SimpleJobLauncher.java:140) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:344) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:198) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.core.configuration.annotation.SimpleBatchConfiguration$PassthruAdvice.invoke(SimpleBatchConfiguration.java:128) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at jdk.proxy2/jdk.proxy2.$Proxy58.run(Unknown Source) ~[na:na]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.execute(JobLauncherApplicationRunner.java:199) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.executeLocalJobs(JobLauncherApplicationRunner.java:173) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.launchJobFromProperties(JobLauncherApplicationRunner.java:160) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.run(JobLauncherApplicationRunner.java:155) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.run(JobLauncherApplicationRunner.java:150) ~[spring-boot-autoconfigure-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:768) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:758) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:310) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1312) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1301) ~[spring-boot-2.6.7.jar!/:2.6.7]
        at org.littlewings.spring.batch.App.main(App.java:11) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:49) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:108) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:58) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
        at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:88) ~[batch-beanvalidation-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
Caused by: org.springframework.batch.item.validator.ValidationException: Validation failed for org.littlewings.spring.batch.Book@47428937:
Field error in object 'item' on field 'price': rejected value [850]; codes [Min.item.price,Min.price,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price],1000]; default message [1000 以上の値にしてください]
        at org.springframework.batch.item.validator.SpringValidator.validate(SpringValidator.java:54) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.item.validator.ValidatingItemProcessor.process(ValidatingItemProcessor.java:84) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.item.validator.ValidatingItemProcessor$$FastClassBySpringCGLIB$$39980290.invoke(<generated>) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:793) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.support.DelegatingIntroductionInterceptor.doProceed(DelegatingIntroductionInterceptor.java:137) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.support.DelegatingIntroductionInterceptor.invoke(DelegatingIntroductionInterceptor.java:124) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708) ~[spring-aop-5.3.19.jar!/:5.3.19]
        at org.springframework.batch.item.validator.BeanValidatingItemProcessor$$EnhancerBySpringCGLIB$$b5c9b6d8.process(<generated>) ~[spring-batch-infrastructure-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.SimpleChunkProcessor.doProcess(SimpleChunkProcessor.java:134) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.batch.core.step.item.FaultTolerantChunkProcessor$1.doWithRetry(FaultTolerantChunkProcessor.java:239) ~[spring-batch-core-4.3.5.jar!/:4.3.5]
        at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:329) ~[spring-retry-1.3.3.jar!/:na]
        ... 52 common frames omitted
Caused by: org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'item' on field 'price': rejected value [850]; codes [Min.item.price,Min.price,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price],1000]; default message [1000 以上の値にしてください]
        ... 68 common frames omitted

2022-04-28 01:57:39.115  INFO 18665 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [loggingInvalidItemBeanValidationStep] executed in 288ms
2022-04-28 01:57:39.158  INFO 18665 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=11, filePath=src/main/resources/book_invalid.csv}] and the following status: [FAILED] in 376ms

スキップ可能なItemの数を超えたということで、例外がスローされアプリケーションが停止します。

org.springframework.batch.core.step.skip.SkipLimitExceededException: Skip limit of '3' exceeded
SkipPolicyを設定する

最後に、SkipPolicyを設定してみます。

SkipPolicy (Spring Batch 4.3.5 API)

SkipPolicyはスキップ可能な条件を判定するためのインターフェースで、SkipPolicy#shouldSkip(java.lang.Throwable t, int skipCount)メソッドで
判定を行います。

今まで設定していたスキップと判定する例外やスキップ可能な数は、SkipPolicyの実装であるExceptionClassifierSkipPolicy
LimitCheckingItemSkipPolicyの組み合わせで機能していました。

ExceptionClassifierSkipPolicy (Spring Batch 4.3.5 API)

LimitCheckingItemSkipPolicy (Spring Batch 4.3.5 API)

https://github.com/spring-projects/spring-batch/blob/4.3.5/spring-batch-core/src/main/java/org/springframework/batch/core/step/builder/FaultTolerantStepBuilder.java#L575-L579

ここで、以下のようにskipPolicyメソッドで明示的にSkipPolicyインスタンスを指定することで、スキップの条件をカスタマイズ
することができます。

    @Bean
    public Step loggingInvalidItemAlwaysSkipBeanValidationStep() {
        return stepBuilderFactory
                .get("loggingInvalidItemBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                //.skip(ValidationException.class)
                .skipPolicy(new AlwaysSkipItemSkipPolicy())
                .listener(rejectItemLoggingListener())
                .build();
    }

ここで指定しているAlwaysSkipItemSkipPolicyは、例外やスキップしたItemの数に関わらず常にスキップを許可するクラスです。

AlwaysSkipItemSkipPolicy (Spring Batch 4.3.5 API)

skipPolicyメソッドを使ってSkipPolicyを指定すると、CompositeSkipPolicyというSkipPolicyを合成するクラスが使われ、
もともと利用されるExceptionClassifierSkipPolicyLimitCheckingItemSkipPolicyの組み合わせと合成されます。

CompositeSkipPolicy (Spring Batch 4.3.5 API)

https://github.com/spring-projects/spring-batch/blob/4.3.5/spring-batch-core/src/main/java/org/springframework/batch/core/step/builder/FaultTolerantStepBuilder.java#L586

評価順は、先に登録したSkipPolicyから順に行われるようなので、AlwaysSkipItemSkipPolicyを使用するとスキップ対象の例外を
どのように指定してもスキップ数をどのように指定しても、常にスキップ可能と判定されます。

https://github.com/spring-projects/spring-batch/blob/4.3.5/spring-batch-core/src/main/java/org/springframework/batch/core/step/skip/CompositeSkipPolicy.java#L40-L44

ちなみに、他のSkipPolicyの実装はスキップしないNeverSkipItemSkipPolicyがあるようです。

NeverSkipItemSkipPolicy (Spring Batch 4.3.5 API)

Job定義を変更して

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                .start(loggingInvalidItemAlwaysSkipBeanValidationStep())
                .build();
    }

パッケージングして実行。

$ mvn package
$ java -Dspring.batch.job.names=beanValidationJob -jar target/batch-beanvalidation-0.0.1-SNAPSHOT.jar filePath=src/main/resources/book_invalid.csv

ログ。

2022-05-22 00:44:05.818  INFO 66977 --- [           main] org.littlewings.spring.batch.App         : Started App in 2.269 seconds (JVM running for 2.677)
2022-05-22 00:44:05.819  INFO 66977 --- [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: [filePath=src/main/resources/book_invalid.csv]
2022-05-22 00:44:06.012  INFO 66977 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] launched with the following parameters: [{run.id=4, filePath=src/main/resources/book_invalid.csv}]
2022-05-22 00:44:06.141  INFO 66977 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [loggingInvalidItemBeanValidationStep]
2022-05-22 00:44:06.379  INFO 66977 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798142470, title = Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発, price = 4400
2022-05-22 00:44:06.379  INFO 66977 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1492076988, title = Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications, price = 6265
2022-05-22 00:44:06.387  INFO 66977 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = xx-xxxxxxx, title = [改訂新版]Spring入門 ――Java フレームワーク・より良い設計とアーキテクチャ, reject reason = item#isbn:正規表現 "\d{3}-\d{10}" にマッチさせてください, item#isbn:14 から 14 の間のサイズにしてください
2022-05-22 00:44:06.510  INFO 66977 --- [           main] consoleItemWriter                        : [writer] isbn = 978-1484237236, title = The Definitive Guide to Spring Batch: Modern Finite Batch Processing in the Cloud, price = 7361
2022-05-22 00:44:06.510  INFO 66977 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4295000198, title = やさしく学べるMySQL運用 ・管理入門【5.7対応】, reject reason = item#price:1000 以上の値にしてください
2022-05-22 00:44:06.511  INFO 66977 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4798147406, title = 詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド (NEXT ONE), reject reason = item#price:1000 以上の値にしてください
2022-05-22 00:44:06.543  INFO 66977 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4798161488, title = MySQL徹底入門 第4版 MySQL 8.0対応, price = 4180
2022-05-22 00:44:06.543  INFO 66977 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4797393118, title = 基礎からのMySQL 第3版 (基礎からシリーズ), price = 6038
2022-05-22 00:44:06.544  INFO 66977 --- [           main] consoleItemWriter                        : [writer] isbn = 978-4873116389, title = 実践ハイパフォーマンスMySQL 第3版, price = 5280
2022-05-22 00:44:06.576  INFO 66977 --- [           main] o.l.s.batch.RejectItemLoggingListener    : validation error, isbn = 978-4774170206, title = MariaDB&MySQL全機能バイ ブル, reject reason = item#price:1000 以上の値にしてください
2022-05-22 00:44:06.607  INFO 66977 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [loggingInvalidItemBeanValidationStep] executed in 465ms
2022-05-22 00:44:06.665  INFO 66977 --- [           main] o.s.b.c.l.support.SimpleJobLauncher      : Job: [SimpleJob: [name=beanValidationJob]] completed with the following parameters: [{run.id=4, filePath=src/main/resources/book_invalid.csv}] and the following status: [COMPLETED] in 605ms

スキップ可能な例外もスキップ可能な回数もしていませんが、Itemの処理中に例外がスローされてもスキップとして判定するようになりました。

AlwaysSkipItemSkipPolicyを使用すると、一見スキップ回数の制限を撤廃できるようにも見えますが、例外もすべて無視するので
ちょっと微妙ですね。

もう少し凝ったスキップ条件にしたい場合は、自分でSkipPolicyを実装するのかもしれません。

まとめ

Spring BatchにBean Validationを加えるとともに、Itemをスキップした場合とその制御について確認してみました。

1度Spring Batchをしっかり見ておくと、このあたりは割とすんなりと入れましたね。

最後に、JobStepを定義していたクラスのソースコード全体を載せておきます。

src/main/java/org/littlewings/spring/batch/BeanValidationJobConfig.java

package org.littlewings.spring.batch;

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.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.batch.core.step.skip.AlwaysSkipItemSkipPolicy;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.item.validator.BeanValidatingItemProcessor;
import org.springframework.batch.item.validator.ValidationException;
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.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class BeanValidationJobConfig {
    @Autowired
    JobBuilderFactory jobBuilderFactory;

    @Autowired
    StepBuilderFactory stepBuilderFactory;

    @Autowired
    LocalValidatorFactoryBean localValidatorFactoryBean;

    @Bean
    public Job beanValidationJob() {
        return jobBuilderFactory
                .get("beanValidationJob")
                .incrementer(new RunIdIncrementer())
                //.start(noBeanValidationStep())
                //.start(withBeanValidationStep())
                //.start(faultTolerantBeanValidationStep())
                //.start(invalidSkipItemListenerBeanValidationStep())
                .start(loggingInvalidItemBeanValidationStep())
                //.start(loggingInvalidItemBeanValidationStopStep())
                //.start(loggingInvalidItemAlwaysSkipBeanValidationStep())
                .build();
    }

    @Bean
    public Step noBeanValidationStep() {
        return stepBuilderFactory
                .get("withBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .writer(consoleItemWriter())
                .build();
    }

    @Bean
    public Step withBeanValidationStep() {
        return stepBuilderFactory
                .get("withBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .build();
    }

    @Bean
    public Step faultTolerantBeanValidationStep() {
        return stepBuilderFactory
                .get("withBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(5)
                .build();
    }

    @Bean
    public Step invalidSkipItemListenerBeanValidationStep() {
        return stepBuilderFactory
                .get("invalidSkipItemListenerBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .listener(rejectItemLoggingListener())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(5)
                .build();
    }

    @Bean
    public Step loggingInvalidItemBeanValidationStep() {
        return stepBuilderFactory
                .get("loggingInvalidItemBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(5)
                .listener(rejectItemLoggingListener())
                .build();
    }

    @Bean
    public Step loggingInvalidItemBeanValidationStopStep() {
        return stepBuilderFactory
                .get("loggingInvalidItemBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(3)
                .listener(rejectItemLoggingListener())
                .build();
    }

    @Bean
    public Step loggingInvalidItemAlwaysSkipBeanValidationStep() {
        return stepBuilderFactory
                .get("loggingInvalidItemBeanValidationStep")
                .<Book, Book>chunk(3)
                .reader(flatFileItemReader(null))
                .processor(beanValidatingItemProcessor())
                .writer(consoleItemWriter())
                .faultTolerant()
                //.skip(ValidationException.class)
                .skipPolicy(new AlwaysSkipItemSkipPolicy())
                .listener(rejectItemLoggingListener())
                .build();
    }

    @StepScope
    @Bean
    public FlatFileItemReader<Book> flatFileItemReader(@Value("#{jobParameters['filePath']}") String filePath) {
        Resource fileResource = new FileSystemResource(filePath);

        return new FlatFileItemReaderBuilder<Book>()
                .name("flatFileItemReader")
                .resource(fileResource)
                .encoding("UTF-8")
                .delimited()
                .names(new String[]{"isbn", "title", "price"})
                .linesToSkip(1)
                .targetType(Book.class)
                .saveState(false)
                .build();
    }

    @StepScope
    @Bean
    public BeanValidatingItemProcessor<Book> beanValidatingItemProcessor() {
        return new BeanValidatingItemProcessor<>(localValidatorFactoryBean);
    }

    @StepScope
    @Bean
    public RejectItemLoggingListener rejectItemLoggingListener() {
        return new RejectItemLoggingListener();
    }

    @StepScope
    @Bean
    public ItemWriter<Book> consoleItemWriter() {
        Logger logger = LoggerFactory.getLogger("consoleItemWriter");

        return books -> books.forEach(book ->
                logger.info("[writer] isbn = {}, title = {}, price = {}", book.getIsbn(), book.getTitle(), book.getPrice())
        );
    }
}

MySQL Connector/JとCharacter Set/Character Set Results/Connection Collationとの設定、関係がよくがわからなかったので調べてみる

これは、なにをしたくて書いたもの?

MySQLJDBCドライバー、Connector/JのcharacterEncodingcharacterSetResultsconnectionCollationあたりの説明を見ていて、
不思議な感じがしたので調べてみることにしました。

MySQL :: MySQL Connector/J 8.0 Developer Guide :: 6.3.3 Session

どう指定したらいいか、よくわからなくなるんですよね。

Connector/Jの説明を読む

characterEncodingcharacterSetResultsconnectionCollationの説明を、それぞれ見てみます。

MySQL :: MySQL Connector/J 8.0 Developer Guide :: 6.3.3 Session

なお、このドキュメントを見ている時のMySQL Connector/Jのバージョンは、8.0.28です。

characterEncodingは、character_set_clientおよびcharacter_set_connectionを「指定されたJavaエンコーディングのデフォルトの
Character Setに設定し、collation_connectionをCharacter SetのデフォルトのCollationに設定する」と書かれています。

Instructs the server to set session system variables 'character_set_client' and 'character_set_connection' to the default character set for the specified Java encoding and set 'collation_connection' to the default collation for this character set.

characterEncodingconnectionCollationも指定されていない場合は、characterEncodingとしては8.0.26以降はutf8mb4が指定されると
書かれています。

If neither this property nor the property 'connectionCollation' is set:
For Connector/J 8.0.25 and earlier, the driver will try to use the server default character set; For Connector/J 8.0.26 and later, the driver will use "utf8mb4".

utf8mb4Javaエンコーディングではありませんが…。

ちなみに、現在のMySQLはCharacter Encodingはutf8mb4がデフォルトであり、utf8mb4のデフォルトのCollationはutf8mb4_0900_ai_ciです。

MySQL Server にはサーバー文字セットとサーバー照合順序があります。 デフォルトでは、これらは utf8mb4 および utf8mb4_0900_ai_ci ですが、サーバーの起動時にコマンドラインまたはオプションファイルで明示的に設定し、実行時に変更できます。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 10.3.2 サーバー文字セットおよび照合順序

characterSetResultsについても、「指定されたJavaエンコーディングに対応するCharacter Setでエンコードされたデータを返すように
サーバーに指示します」と書かれています。

Instructs the server to return the data encoded with the default character set for the specified Java encoding.

指定しない、またはnullの場合、サーバーは元のCharacter Setでデータを送信し、ドライバーは結果のメタデータに従ってデータを
デコードします。

If not set or set to "null", the server will send data in its original character set and the driver will decode it according to the result metadata.

connectionCollationは、セッションシステム変数collation_connectionを指定されたCollationに設定し、character_set_client
character_set_connectionを対応するCharacter Setに設定するようにサーバーに指示します。

Instructs the server to set session system variable 'collation_connection' to the specified collation name and set 'character_set_client' and 'character_set_connection' to the corresponding character set.

この結果、connectionCollationcharacterEncodingで指定した値を上書きする挙動になるようです。

This property overrides the value of 'characterEncoding' with the character set this collation belongs to.

そしてconnectionCollationcharacterEncodingも指定されていない場合は、connectionCollationのデフォルトのCollationになると
書かれています。

If neither this property nor the property 'characterEncoding' is set:
For Connector/J 8.0.25 and earlier, the driver will try to use the server default character set;
For Connector/J 8.0.26 and later, the driver will use "utf8mb4" default collation.

これは、どう指定するのが適切でしょうか?こうなると、各変数がアプリケーションの動作に与える影響を確認しておく必要が
ありそうですね。

現在はCharacter Encodingはutf8mb4を指定するのが無難かと思いますので、主にCollationまわりに関する話がポイントかなとは
思いますが。

MySQLのCollationのドキュメントを読んでみる

次のドキュメントを見てみます。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 10.4 接続文字セットおよび照合順序

まずは、サーバーおよびデータベースレベルのCharacter SetとCollationについて。

・character_set_server および collation_server システム変数は、サーバーの文字セットと照合順序を示します。
・character_set_database および collation_database システム変数は、デフォルトデータベースの文字セットおよび照合順序を示します。

character_set_clientは、クライアントが送信するデータのエンコーディングに関わる話になります。

クライアントから離れるときのステートメントの文字セットは何ですか。
サーバーは、character_set_client システム変数値を、クライアントが送信するステートメントの文字セットにします。

character_set_connectionは、クライアントが送信したデータを変換する先のエンコーディングを指定するようです。

サーバーがステートメントを受信したあと、どの文字セットに変換するべきですか。
これを確認するために、サーバーは character_set_connection および collation_connection システム変数を使用します:
サーバーは、クライアントによって送信されたステートメントを character_set_client から character_set_connection に変換します。

一方で、collation_connectionリテラル文字列の比較に使われるだけのようですね。

collation_connection は、リテラル文字列の比較に重要です。 カラム値と文字列を比較する場合、collation_connection は関係ありません。

ということは、collation_connectionを気にすることはほとんどなさそうですね。

character_set_resultsは、サーバーから返すデータのエンコーディングに使用されるようです。

クエリー結果をクライアントに返送する前に、サーバーはどの文字セットに変換する必要がありますか。
character_set_results システム変数値は、サーバーがクライアントにクエリー結果を返信するときに使用する文字セットを示します。 これには、カラム値、結果メタデータ (カラム名など)、エラーメッセージなどの結果データが含まれます。

特に変換を行い場合は、設定しないかbinaryに指定する、と。

結果セットまたはエラーメッセージの変換を実行しないようにサーバーに指示するには、character_set_results を NULL または binary に設定します:

character_set_servercharacter_set_databasecharacter_set_clientcharacter_set_connectionutf8mb4で統一していれば
問題なさそうですし、そうするとcharacter_set_resultsは明示的に指定しなくてもいいのでは、という感じでしょうか。

character_set_resultsを指定したとしても、utf8mb4でしょうね。

それぞれのシステム変数のドキュメントと説明は、こちら。

  • character_set_server … サーバーのデフォルトの文字セット
  • character_set_database … デフォルトデータベースで使用される文字セット
    • 現在は非推奨の設定
  • character_set_client … クライアントから到達するステートメントの文字セット
  • character_set_connection … 文字セットイントロデューサなしで指定されたリテラルおよび数値から文字列への変換に使用される文字セット
  • character_set_results … クエリー結果をクライアントに返すために使用される文字セット。 これには、カラム値、結果メタデータ (カラム名など)、エラーメッセージなどの結果データが含まれます。
  • collation_server … サーバーのデフォルトの照合順序
  • collation_database … デフォルトデータベースで使用される照合
    • 現在は非推奨の設定
  • collation_connection … 接続文字セットの照合順序。collation_connection は、リテラル文字列の比較に重要です。 カラム値と文字列を比較する場合、collation_connection は関係ありません。これは、カラムには照合優先度の高い独自の照合があるためです

MySQL Connector/Jに話を戻すと

ここまでの話から、MySQL Connector/Jの設定に話を戻すと、characterEncodingcharacterSetResultsconnectionCollationのそれぞれを
どう指定すればいいのか?ということなのですが。

MySQLサーバー側のCharacter Setをutf8mb4に統一するのなら

  • characterEncodingUTF-8
  • characterSetResults … 指定なし
  • connectionCollation … 指定しなくても実害はなさそう(文字列リテラルの比較のみの話なので)だが、気になるならサーバーと同じCollationを指定

といったところでしょうか。

characterEncodingについてはMySQL Connector/Jの説明(デフォルト値の部分)が気になるので、この後にテストコードを書いて
確認してみることにします。

環境

今回の環境はこちら。

$ java --version
openjdk 17.0.2 2022-01-18
OpenJDK Runtime Environment (build 17.0.2+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.2+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.5 (3599d3414f046de2324203b78ddcf9b5e4388aa0)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.2, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-109-generic", arch: "amd64", family: "unix"

MySQLはこちらのバージョンで、172.17.0.2で動作しているものとします。

$ mysql --version
mysql  Ver 8.0.28 for Linux on x86_64 (MySQL Community Server - GPL)

また、サーバーのCharacter SetおよびCollationは以下の設定としておきます。

character-set-server = utf8mb4
collation-server = utf8mb4_0900_bin

準備

作成したMavenプロジェクトの依存関係等は、こちら。

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.8.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.22.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

次に、テストコードの雛形を作成します。

src/test/java/org/littlewings/mysql/ConnectorCharacterSetTest.java

package org.littlewings.mysql;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

public class ConnectorCharacterSetTest {
    List<String> characterSetVariables = List.of(
            "character_set_connection",
            "character_set_client",
            "character_set_database",
            "character_set_filesystem",
            "character_set_results",
            "character_set_server",
            "character_set_system"
    );

    List<String> collationVariables = List.of(
            "collation_connection",
            "collation_database",
            "collation_server"
    );

    private Map<String, String> collectCharacterSets(Connection conn) throws SQLException {
        Map<String, String> characterSets = new LinkedHashMap<>();

        for (String characterSetVariable : characterSetVariables) {
            try (PreparedStatement ps = conn.prepareStatement("show variables where variable_name = ?")) {
                ps.setString(1, characterSetVariable);

                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        characterSets.put(rs.getString(1), rs.getString(2));
                    }
                }
            }
        }

        return characterSets;
    }

    private Map<String, String> collectCollations(Connection conn) throws SQLException {
        Map<String, String> collations = new LinkedHashMap<>();

        for (String collationVariable : collationVariables) {
            try (PreparedStatement ps = conn.prepareStatement("show variables where variable_name = ?")) {
                ps.setString(1, collationVariable);

                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        collations.put(rs.getString(1), rs.getString(2));
                    }
                }
            }
        }

        return collations;
    }

    // ここに、テストを書く!!
}

現在の接続内でのCharacter SetおよびCollationを収集するメソッドを用意して、以降に作成するテストで接続プロパティを変更するとともに、
これらのシステム変数がどのように変化していくかを見ていくことにします。

characterEncodingを確認してみる

とりあえず、なにも指定しない場合。

    @Test
    public void nonSettings() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

character_set_connectionutf8mb4になっていますが、MySQL Connector/J 8.0.25以前の場合はサーバー側のデフォルトの
Character Setが使われることになりますがこれはutf8mb4にしていますし、今回使用しているMySQL Connector/Jは8.0.28(8.0.26以降)
なのでどちらにしろutf8mb4です。

collation_connectionutf8mb4のデフォルトのCollationである、utf8mb4_0900_ai_ciですね。

characterEncodingutf8mb4を指定してみます。

    @Test
    public void utf8mb4CharacterEncoding() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=utf8mb4";
        String username = "kazuhira";
        String password = "password";

        assertThatThrownBy(() -> DriverManager.getConnection(url, username, password))
                .isInstanceOf(SQLException.class)
                .hasMessage("Unsupported character encoding 'utf8mb4'");
    }

これは、例外がスローされます。JavaCharsetとしては指定できないからでしょうか。

UTF-8を指定した場合は、utf8mb4になっていますね。

    @Test
    public void utf8CharacterEncoding() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=UTF-8";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

今では使うことはないと思いますが、試しにWindows-31Jにしてみます。

    @Test
    public void windows31jCharacterEncoding() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=Windows-31J";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "cp932"),  // changed
                    entry("character_set_client", "cp932"),  // changed
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "cp932_japanese_ci"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

すると、character_set_connectioncharacter_set_clientcp932に、collation_connectioncp932_japanese_ciに変化しました。

というわけで、ドキュメントに書かれているとおり、characterEncodingJavaエンコーディングで指定するのが正しいみたいですね。

characterEncodingutf8mb4のようなJavaCharsetとしては無効な値を指定すると例外がスローされるのは、String#getBytes
確認しているからのようです。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/user-impl/java/com/mysql/cj/jdbc/JdbcPropertySetImpl.java#L61-L67

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-api/java/com/mysql/cj/util/StringUtils.java#L229

また、UTF-8utf8mb4のようになるのは、MySQL Connector/Jの中でJavaCharsetとして有効なエンコーディング
MySQLのCharacter Setに対する変換表を持っているからみたいですね。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L704

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-api/java/com/mysql/cj/CharsetMapping.java#L113-L170

UTF-8の場合は、こちら。対応するのが2つありますが、最終的に選択されるのはutf8mb4になります。

                new MysqlCharset(MYSQL_CHARSET_NAME_utf8, 3, 0, new String[] { "UTF-8" }),
                new MysqlCharset(MYSQL_CHARSET_NAME_utf8mb4, 4, 1, new String[] { "UTF-8" }), // "UTF-8 = *> 5.5.2 utf8mb4"

これで、characterEncodingについての挙動はわかりました。

characterSetResultsを確認してみる

次は、characterSetResultsを確認してみます。

characterSetResultsを指定しない場合のcharacter_set_resultsは、未設定でした。

    @Test
    public void nonSettings() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

utf8mb4を指定してみます。

    @Test
    public void utf8mb4CharacterSetResults() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterSetResults=utf8mb4";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", "utf8mb4"),  // changed
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

すると、こちらは通ります。character_set_resultsutf8mb4になりました。

では、UTF-8を指定してみましょう。

    @Test
    public void utf8CharacterSetResults() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterSetResults=UTF-8";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", "utf8mb4"),  // changed
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

これも通ります。そして、こちらもcharacter_set_resultsutf8mb4になっています。

Windows-31Jを指定すると、cp932になっています。

    @Test
    public void windows31jCharacterSetResults() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterSetResults=Windows-31J";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", "cp932"),  // changed
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

さて、どうなっているんでしょう?

こちらもやはり、JavaCharsetMySQLのCharacter Setに変換しようとするみたいです。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L397-L398

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L704

反対に、MySQLのCharacter Setとしか解釈できない値を指定した場合は、1度JavaCharsetとして有効なエンコーディング
変換するようです。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L193

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L217-L223

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-api/java/com/mysql/cj/CharsetMapping.java#L595

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-api/java/com/mysql/cj/CharsetMapping.java#L600

この時に使う変換表も、characterEncodingの時と同じですね。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-api/java/com/mysql/cj/CharsetMapping.java#L113-L170

connectionCollationを確認してみる

最後に、connectionCollationを確認してみます。

なにも指定していない時は、collation_connectionはデフォルトのCharacter Setであるutf8mb4のデフォルトのCollation、utf8mb4_0900_ai_ci
なっていました。

    @Test
    public void nonSettings() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

connectionCollationutf8mb4_0900_binを指定してみます。

    @Test
    public void utf8mb4_0900_bin_connectionCollation() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "connectionCollation=utf8mb4_0900_bin";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_bin"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

collation_connectionutf8mb4_0900_binになりました。

cp932_binを指定してみます。

    @Test
    public void cp932_bin_connectionCollation() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "connectionCollation=cp932_bin";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "cp932"),  // changed
                    entry("character_set_client", "cp932"),  // changed
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "cp932_bin"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

こちらも反映されました。

最後に、characterEncodingにはUTF-8connectionCollationにはcp932_binと矛盾した内容を設定してみます。

    @Test
    public void utf8CharacterEncoding_cp932_bin_connectionCollation() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=UTF-8&connectionCollation=cp932_bin";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "cp932"),  // changed & override
                    entry("character_set_client", "cp932"),  // changed & override
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "cp932_bin"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

これは、ドキュメントに書かれている通り、characterEncodingの値がconnectionCollationで指定したCharacter Setで上書きされます。
今回は、character_set_connectioncharacter_set_clientcp932になりましたね。

このようなケースは、以下の部分でCharacter Setの値が補正されます。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L316

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L659-L671

こんな感じで、実際の挙動が確認できました。

まとめ

今回は、MySQL Connector/Jの設定を見ていて、Character Set/Character Set Results/Connection Collationに関する項目と、
そもそもこれらの意味がちゃんとわかっていなかったなと思ってちょっと調べてみました。

ちゃんとドキュメントを見てみると、心配しすぎだったかな、という気がしないでもないですが。いつももやもやしていたので、
この機会に見ておいて意味はあったかなと思います。

ちなみに、このあたりを見ていると、これらの変数で指定した値は最終的にはSET NAMESSET character_set_resultsとして
実行されるようですね。

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L360

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L388

https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/NativeCharsetSettings.java#L406

最後に、今回作成したテストコードの全体を載せておきます。

src/test/java/org/littlewings/mysql/ConnectorCharacterSetTest.java

package org.littlewings.mysql;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

public class ConnectorCharacterSetTest {
    List<String> characterSetVariables = List.of(
            "character_set_connection",
            "character_set_client",
            "character_set_database",
            "character_set_filesystem",
            "character_set_results",
            "character_set_server",
            "character_set_system"
    );

    List<String> collationVariables = List.of(
            "collation_connection",
            "collation_database",
            "collation_server"
    );

    private Map<String, String> collectCharacterSets(Connection conn) throws SQLException {
        Map<String, String> characterSets = new LinkedHashMap<>();

        for (String characterSetVariable : characterSetVariables) {
            try (PreparedStatement ps = conn.prepareStatement("show variables where variable_name = ?")) {
                ps.setString(1, characterSetVariable);

                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        characterSets.put(rs.getString(1), rs.getString(2));
                    }
                }
            }
        }

        return characterSets;
    }

    private Map<String, String> collectCollations(Connection conn) throws SQLException {
        Map<String, String> collations = new LinkedHashMap<>();

        for (String collationVariable : collationVariables) {
            try (PreparedStatement ps = conn.prepareStatement("show variables where variable_name = ?")) {
                ps.setString(1, collationVariable);

                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        collations.put(rs.getString(1), rs.getString(2));
                    }
                }
            }
        }

        return collations;
    }


    @Test
    public void nonSettings() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void utf8mb4CharacterEncoding() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=utf8mb4";
        String username = "kazuhira";
        String password = "password";

        assertThatThrownBy(() -> DriverManager.getConnection(url, username, password))
                .isInstanceOf(SQLException.class)
                .hasMessage("Unsupported character encoding 'utf8mb4'");
    }

    @Test
    public void utf8CharacterEncoding() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=UTF-8";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void windows31jCharacterEncoding() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=Windows-31J";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "cp932"),  // changed
                    entry("character_set_client", "cp932"),  // changed
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "cp932_japanese_ci"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void utf8mb4CharacterSetResults() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterSetResults=utf8mb4";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", "utf8mb4"),  // changed
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void utf8CharacterSetResults() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterSetResults=UTF-8";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", "utf8mb4"),  // changed
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void windows31jCharacterSetResults() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterSetResults=Windows-31J";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", "cp932"),  // changed
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_ai_ci"),
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void utf8mb4_0900_bin_connectionCollation() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "connectionCollation=utf8mb4_0900_bin";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "utf8mb4"),
                    entry("character_set_client", "utf8mb4"),
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "utf8mb4_0900_bin"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void cp932_bin_connectionCollation() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "connectionCollation=cp932_bin";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "cp932"),  // changed
                    entry("character_set_client", "cp932"),  // changed
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "cp932_bin"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }

    @Test
    public void utf8CharacterEncoding_cp932_bin_connectionCollation() throws SQLException {
        String url = "jdbc:mysql://172.17.0.2:3306/practice?" +
                "characterEncoding=UTF-8&connectionCollation=cp932_bin";
        String username = "kazuhira";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, username, password)) {
            Map<String, String> characterSets = collectCharacterSets(conn);
            Map<String, String> collations = collectCollations(conn);

            assertThat(characterSets).containsExactly(
                    entry("character_set_connection", "cp932"),  // changed & override
                    entry("character_set_client", "cp932"),  // changed & override
                    entry("character_set_database", "utf8mb4"),
                    entry("character_set_filesystem", "binary"),
                    entry("character_set_results", ""),
                    entry("character_set_server", "utf8mb4"),
                    entry("character_set_system", "utf8mb3")
            );

            assertThat(collations).containsExactly(
                    entry("collation_connection", "cp932_bin"),  // changed
                    entry("collation_database", "utf8mb4_0900_bin"),
                    entry("collation_server", "utf8mb4_0900_bin")
            );
        }
    }
}