プログラミングの最近のブログ記事

今まで作ってきた Spring Batch プログラムをコマンドラインから実行可能な JAR ファイルにしてみる。

Maven プロジェクトとして作っているので、以下の手順で。

プロジェクトを選択し、右ボタンメニューから「実行」→「Maven ビルド...」

20260714_maven_buld1.jpg

構成ダイアログが開くので、「ゴール」のところに「clean package」と入力
「ゴール(Goals)」には実行したい Maven のフェーズやプラグインの機能(コマンド)を指定する。
「clean package」は、「過去の生成物を削除し、新しく JAR や WAR などの配布ファイルを生成する」という指定。
テストが不要なら、「clean package -DskipTests」。これで。JAR へのビルドだけが行われる。

20260714_maven_buld2.jpg

「実行」ボタン押下

これでビルドが実行される。

[INFO] Scanning for projects...
[WARNING] The requested profile "pom.xml" could not be activated because it does not exist.
[INFO]
[INFO]  [1m----------------------< [0;36mcom.example:BatchDB2CSV1 [0;1m >---------------------- [m
[INFO]  [1mBuilding BatchDB2CSV1 0.0.1-SNAPSHOT [m
[INFO]   from pom.xml
[INFO]  [1m--------------------------------[ jar ]--------------------------------- [m
[INFO] Downloading from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-clean-plugin/3.5.0/maven-clean-plugin-3.5.0.pom
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-clean-plugin/3.5.0/
<略>
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/junit/platform/junit-platform-launcher/6.0.3/junit-platform-launcher-6.0.3.jar (246 kB at 2.2 MB/s)
[INFO]
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.netandfield.test. [1mBatchDb2Csv1ApplicationTests [m 16:26:53.111 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class
[com.netandfield.test.BatchDb2Csv1ApplicationTests]: BatchDb2Csv1ApplicationTests does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
<略>
※「ゴール」に -DskipTests を記述していないので、実際に起動する形でテストが行われる
   .  ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v4.1.0)

<略>
2026-07-14T16:26:58.105+09:00  INFO 19504 --- [BatchDB2CSV1] [   main] c.n.test.Listener.LogJobListener         : ★Job started: exportJob
2026-07-14T16:26:58.112+09:00  INFO 19504 --- [BatchDB2CSV1] [   main] o.s.batch.core.step.AbstractStep         : Executing step: [exportStep]
2026-07-14T16:26:58.816+09:00  INFO 19504 --- [BatchDB2CSV1] [   main] o.s.batch.core.step.AbstractStep         : Step: [exportStep] executed in 704ms
2026-07-14T16:26:58.821+09:00  INFO 19504 --- [BatchDB2CSV1] [   main] c.n.test.Listener.LogJobListener         : ★Job finished: exportJob with status: COMPLETED
2026-07-14T16:26:58.824+09:00  INFO 19504 --- [BatchDB2CSV1] [   main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=exportJob]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 717ms
<略>
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/vafer/jdependency/2.10/jdependency-2.10.jar (416 kB at 298 kB/s)
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar (20 kB at 12 kB/s)
[INFO] Replacing main artifact C:\HogeHoge\pleiades\workspace\BatchDB2CSV1\target\BatchDB2CSV1-0.0.1-SNAPSHOT.jar with repackaged archive, adding nested dependencies in BOOT-INF/.
[INFO] The original artifact has been renamed to C:\HogeHoge\pleiades\workspace\BatchDB2CSV1\target\BatchDB2CSV1-0.0.1-SNAPSHOT.jar.original
[INFO]  [1m------------------------------------------------------------------------ [m
[INFO]  [1;32mBUILD SUCCESS [m
[INFO]  [1m------------------------------------------------------------------------ [m
[INFO] Total time:  34.209 s
[INFO] Finished at: 2026-07-14T16:27:09+09:00
[INFO]  [1m------------------------------------------------------------------------ [m
[WARNING] The requested profile "pom.xml" could not be activated because it does not exist.

これで、

C:\HogeHoge\pleiades\workspace\BatchDB2CSV1\target\BatchDB2CSV1-0.0.1-SNAPSHOT.jar

という JAR ファイルが作られた。

しかし、ビルド時にもすげえ色々ダウンロードしてくるなあ。こりゃ、インターネット環境のない場所じゃビルドできんわ(笑)
開発環境を完全にインターネットから切り離してるところって時々あるけど、時代にあってないよなあ・・・

じゃ、実行してみる。ちょっと場所が深いので、C:\work の下に移動しよう。

C:\work\BatchDB2CSV1-0.0.1-SNAPSHOT.jar

では、実行。

・・・の前に、ディフォルトで使われる java.exe はバージョンが古い。今回のバッチは Java 21 での実行を前提にしている。

C:\Users\hogehoge>java -version
java version "1.7.0_09"
Java(TM) SE Runtime Environment (build 1.7.0_09-b05_Fujitsu_10-30-12_12:00_patch)
Java HotSpot(TM) Client VM (build 23.5.02_FUJITSU_MODIFIED-B04, mixed mode)

開発で使った Java 21 はここ。

C:\Users\hogehoge>C:\HogeHoge\pleiades\java\21\bin\java -version
openjdk version "21.0.5" 2024-10-15 LTS
OpenJDK Runtime Environment Temurin-21.0.5+11 (build 21.0.5+11-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.5+11 (build 21.0.5+11-LTS, mixed mode, sharing)

環境変数(PATH)を直しちゃうと、他のプログラムがうまく動かなくなる可能性もあるので、とりあえずフルパス指定で実行しよう。

C:\Users\hogehoge>C:\HogeHoge\pleiades\java\21\bin\java -jar C:\work\BatchDB2CSV1-0.0.1-SNAPSHOT.jar models=1 modelName=未来電話

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v4.1.0)
<略>
2026-07-14T16:47:55.110+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] o.s.b.b.a.JobLauncherApplicationRunner   : Running default command line with: [models=1, modelName=未来電話]
2026-07-14T16:47:55.318+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=exportJob]] launched with the following parameters: [{JobParameter{name='models', value=1, type=class java.lang.String, identifying=true},JobParameter{name='modelName', value=未来電話, type=class java.lang.String, identifying=true}}]
2026-07-14T16:47:55.345+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] c.n.test.Listener.LogJobListener         : ★Job started: exportJob
2026-07-14T16:47:55.360+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] o.s.batch.core.step.AbstractStep         : Executing step: [exportStep]
2026-07-14T16:47:56.095+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] o.s.batch.core.step.AbstractStep         : Step: [exportStep] executed in 735ms
2026-07-14T16:47:56.102+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] c.n.test.Listener.LogJobListener         : ★Job finished: exportJob with status: COMPLETED
2026-07-14T16:47:56.104+09:00  INFO 18536 --- [BatchDB2CSV1] [   main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=exportJob]] completed with the following parameters: [{JobParameter{name='models', value=1, type=class java.lang.String, identifying=true},JobParameter{name='modelName', value=未来電話, type=class java.lang.String, identifying=true}}] and the following status: [COMPLETED] in 757ms
2026-07-14T16:47:56.113+09:00  INFO 18536 --- [BatchDB2CSV1] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2026-07-14T16:47:56.135+09:00  INFO 18536 --- [BatchDB2CSV1] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

問題なく実行されたようだ。
Spring Batch 6 のプログラムにデータ検証機能(Validation)を実装してみる。

パラメータ(ジョブ引数)のチェックをするのだが、

  • パラメータとして渡せるのは models、modelName の二つだけ
  • 必須項目はなし
  • models が設定されたときは、数値でなければならない(異常終了)
  • modelName の中身はなんでも良い

という仕様で組み込んでみる。(元ネタは「出力項目を増やすようちょっと修正版 Spring Batch」→「Spring Batch 6 で Listener を追加してログを出す」のソース)


■Validator/OptionalValidator.java

単純な必須チェックなどではない、独自チェックを記述したクラス

package com.netandfield.test.Validator;

import org.springframework.batch.core.job.parameters.InvalidJobParametersException;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersValidator;

public class OptionalValidator implements JobParametersValidator {

    @Override
    public void validate(JobParameters parameters) throws InvalidJobParametersException {

        // パラメーター取得
        String models    = parameters.getString("models");;

        // 存在チェック(必須項目ではない)
        if (models == null || models.isEmpty()) {
            return;
        }

        // 存在していれば数値でなければならない
        try {
            Integer.parseInt(models);
        }
        catch(NumberFormatException e) {
            throw new InvalidJobParametersException("Not Number: models=" + models);
        }

    }

}


■Config/BatchConfig.java

import の追加

import java.util.ArrayList;
import java.util.List;

import org.springframework.batch.core.job.parameters.CompositeJobParametersValidator;
import org.springframework.batch.core.job.parameters.DefaultJobParametersValidator;
import org.springframework.batch.core.job.parameters.JobParametersValidator;

import com.netandfield.test.Validator.OptionalValidator; // 作成した validator

バリデータの定義(使用可能なパラメータ(必須項目と任意項目))を追加

    @Bean
    public DefaultJobParametersValidator defaultValidator() {

        // 1. 必須パラメータの配列
        String[] requiredKeys = {};    // 必須項目はなし

        // 2. 任意パラメータの配列
        String[] optionalKeys = {"models", "modelName"};

        // コンストラクタに(必須, 任意)の順番で渡す
        return new DefaultJobParametersValidator(requiredKeys, optionalKeys);

    }

作成した Validator(OptionalValidator クラス)を CompositeJobParametersValidator(コンポジット・ジョブパラメータ・バリデータ)に追加

    @Bean
    public JobParametersValidator compositeValidator() {

        // Validator List 生成
        List<JobParametersValidator> validators = new ArrayList<>();
        validators.add(defaultValidator());
        validators.add(new OptionalValidator());

        // Composit に Validator を入れる
        CompositeJobParametersValidator compositValidator = new CompositeJobParametersValidator();
        compositValidator.setValidators(validators);

        return compositValidator;

    }

Spring Batch では、1つの Job で 1つの Validator しかセットできない。

具体的には、Jobクラス(exportJob)の中で .validator() で指定できるクラスが 1つだけなので、使用可能なパラメータをチェックする標準の Validator(defaultValidator)クラスと、独自チェックの Validator(OptionalValidator)クラスを compositeValidator クラスにまとめ、.validator(compositeValidator()) と指定するわけ。

まとめた Validator を JobBuilder のメソッドチェーンに追加する。

    @Bean
    public Job exportJob(Step exportStep) {
        return new JobBuilder("exportJob", jobRepository)
                .start(exportStep)
                .listener(jobListener) // JobExecutionListenerを追加
                .validator(compositeValidator()) // ValidatorをまとめたcompositeValidatorを追加
                .build();
    }

これで、完了。

実際に実行(「実行の構成」でプログラムの引数を指定)してみる。

パラメータ(引数)が「models=1 modelName=未来電話 mondamin=あり」のとき(許されていないパラメータ "mondamin" あり)

■実行結果ログ(長いので先頭を "~" で省略)

<略>
~ : Running default command line with: [models=1, modelName=未来電話, mondamin=あり]
~ : The JobParameters contains keys that are not explicitly optional or required: [mondamin]
~ : [{JobParameter{name='models', value=1, type=class java.lang.String, identifying=true},JobParameter{name='modelName', value=未来電話, type=class java.lang.String, identifying=true},JobParameter{name='mondamin', value=あり, type=class java.lang.String, identifying=true}}]
~ : The JobParameters contains keys that are not explicitly optional or required: [mondamin]
<略>

と、"mondamin" が「The JobParameters contains keys that are not explicitly optional or required」(JobParameters に、明示的なオプション(任意)または必須として定義されていないキーが含まれています。)と警告されるが、処理は最後まで通る。

「え?InvalidJobParametersException は発生しないの?」って話だが、実は今回の処理には「必須項目」がないので、defaultValidator は自動的に「緩いチェック」にモードを切り替えてしまう。「必須項目」があれば、Spring Batch は「厳格チェックモード」に入り「未定義のパラメータが渡されたら異常終了」するようになる・・・らしい(^^;;;

俺的には「らしい」としか言えないので、エロい人、コメントで詳細教えて(笑)

次に、数値でなければいけないパラメータに「あ」をセットした場合の動きを見てみる。
パラメータ(引数)を「models=あ modelName=未来電話」とする。

■実行結果ログ(長いので先頭を "~" で省略)

これは、以下のようなログを吐いて無事落ちる。

<略>
    at com.netandfield.test.BatchDb2Csv1Application.main(BatchDb2Csv1Application.java:10) ~[classes/:na]
    at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
    at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:52) ~[spring-boot-devtools-4.1.0.jar:4.1.0]
Caused by: org.springframework.batch.core.job.parameters.InvalidJobParametersException: Not Number: models=あ
    at com.netandfield.test.Validator.OptionalValidator.validate(OptionalValidator.java:25) ~[classes/:na]
    at org.springframework.batch.core.job.parameters.CompositeJobParametersValidator.validate(CompositeJobParametersValidator.java:45) ~[spring-batch-core-6.0.4.jar:6.0.4]
    at org.springframework.batch.core.launch.support.TaskExecutorJobLauncher.createJobExecution(TaskExecutorJobLauncher.java:191) ~[spring-batch-core-6.0.4.jar:6.0.4]

しかし、ぱっと見、「Not Number: models=あ」が見つけられなくて、別の理由で落ちてるのかと思って必死で原因を探したわ(笑)

ああ、年を取って目がしょぼしょぼするのよ(笑)
Oracle Database からデータを取ってくるバッチ処理を Spring Batch 6 で作ろうと思って、Eclipse
で依存関係をこんな風に設定した。

20260702_batch1.jpg

DB関係は、Spring Data JDBC と Oracle Driver を選択した。

pom.xml の中はこんな感じ。以下依存関係が存在することを確認した。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>

        <dependency>
            <groupId>com.oracle.database.jdbc</groupId>
            <artifactId>ojdbc11</artifactId>
            <scope>runtime</scope>
        </dependency>

Spring Batch(Spring Boot)の勉強始めてから、ぼんやり、Spring Data JDBC(spring-boot-starter-data-jdbc)が設定する環境(最低限の変換アダプタとしての基盤)に、Oracle や PostgreSQL ドライバが各 DBMS 特有の機能を補完する形になっているのかと思ってた。つまり、Spring Data JDBC と実際に使用する DBMS を組み合わせて使うものだと。

でも、今朝、ふと「Oracle JDBC ドライバって言うくらいなんだから、ojdbc11 単体の依存関係設定だけでいいんじゃね?」と思いついて、そもそも Spring Data JDBC ってなんやん?実はいらんのんちゃう?と調べてみると・・・

いらんやん・・・(^^;;;

最低限必要なのは Spring Batch と Oracle Driver だけやん。

上に書いたような「最低限の変換アダプタとしての機能」は spring-boot-starter-jdbc(SpringのJDBC基盤)で作られるんだけど、これは Spring Batch のスターター(spring-boot-starter-batch)が内部的に読み込んでいる。なので、この基盤の上で動く各 DBMS のドライバ(今回は Oracle Driver)だけ依存関係を設定してやればええんや。

必要なのは、

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-batch</artifactId>
        </dependency>

        <dependency>
            <groupId>com.oracle.database.jdbc</groupId>
            <artifactId>ojdbc11</artifactId>
            <scope>runtime</scope>
        </dependency>

これだけね。

ちなみに、Spring Data JDBC で設定される機能って何かというと(Qiita の @yoshikawaa(Atsushi
Yoshikawa)さんの記事によると)、

  • CRUDを実現するシンプルなAPIを提供する
  • @Queryでカスタムクエリを定義できる
  • SQL実行前後などのイベントをインターセプトする
  • Spring Data JDBCを経由してMyBatisのSQLを実行できる
  • これらをAuto-Configする

なるほど、依存関係を設定しておくと色々便利なのね。ただ、これらの機能を使用しない、ただ単にDBを読むだけなら必要ない。Oracle Driver だけあればいい・・・という話なのね?

とりあえず残しておこう。
Spring Batch 6 のバッチ処理にパラメータ(ジョブ引数)を渡す方法。
例えばファイルパスなど、毎回違う値を渡すときに推奨されるやり方でコーディングしてみる。(reader や write などの Step で指定する(受け取る)やり方。今回は processor でジョブ引数を受け取るか形)

「Spring Batch 6 で Listener を追加してログを出す」で修正したバッチプログラムに修正を加える。(修正点のみ記述するので、全体のソースが見たい人は、元ネタのページを手繰ってくださいませ)

修正点は以下のとおり。

■import の追加

import org.springframework.batch.core.configuration.annotation.StepScope; //追加
import org.springframework.beans.factory.annotation.Value; //追加


■processor の修正

    @Bean
    @StepScope // ジョブ引数を受け取るために必須
    public ItemProcessor<UserCombinedDto, UserCombinedDto> processor(
                            @Value("#{jobParameters['models']}") Integer models,
                            @Value("#{jobParameters['modelName']}") String modelName) {
        return item -> {
            String phone = item.user_phone_number(); // DtoがRecordの場合のゲッター。通常のクラスなら .getUser_phone_number()

            // 電話番号が null でなく、かつ 090 または 080 で始まる場合
            if (phone != null && (phone.startsWith("090") || phone.startsWith("080"))) {
                // 表示名
                String displayName = "スマホ";
                // ジョブ引数をチェック
                if (models == null || "1".equals(models)) { // ジョブ引数未指定、あるいは models == 1
                    displayName = "携帯";
                }
                else {
                    if ("2".equals(models) && (modelName != null && !modelName.isEmpty())) {
                        // ジョブ引数 models = 2 で、modelName も指定されている
                        displayName = modelName;
                    }
                    else {
                        // ジョブ引数 models が 1でも 2でもない。または modelName が未指定
                        displayName = "未定義";
                    }
                }

                // 文字列を追加した新しいDtoを作成して返す
                // (DtoがRecordの場合はイミュータブル(不変)なので、このように新しくインスタンスを作る)
                return new UserCombinedDto(
                        item.user_id(),
                        item.user_name(),
                        item.user_address1(),
                        item.user_address2(),
                        phone + "(" + displayName + ")",
                        item.anniversary(),
                        item.note()
                );
            }

            // 条件に当てはまらない場合は、加工せずにそのまま次に渡す
            return item;
        };
    }

<修正ポイント>
  • メソッドに @StepScope アノテーションを必ず付与(このアノテーションを付けないとジョブ引数が受け取れない)
  • メソッドの引数に、受け取りたいジョブ引数を設定(例:@Value("#{jobParameters['modelName']}") String modelName)
  • 受け取ったジョブ引数で色々と処理(ジョブ引数は編集できないので、編集したいときは他の変数にコピー)


■Stepの定義の変更

    // 4. Stepの定義 (Spring Batch 6 スタイル)
    @Bean
    public Step exportStep (
                JdbcCursorItemReader<UserCombinedDto> reader,
                ItemProcessor<UserCombinedDto, UserCombinedDto> processor,
                FlatFileItemWriter<UserCombinedDto> writer) {
        return new StepBuilder("exportStep", jobRepository)
                .<UserCombinedDto, UserCombinedDto>chunk(100) // 100件ごとにコミット/出力
                .transactionManager(transactionManager)
                .reader(reader)       // 読んで
                .processor(processor) // 処理して
                .writer(writer)       // 書き出す
                .build();
    }

<修正ポイント>
  • メソッドの引数を。各Bean(Reader、Processor、Writer)の定義を受け取る形に設定
  • 各 Step の呼び出しで引数指定は不要(例:.processor(processor()) →.processor(processor) )


■Jobの定義の変更

    // 5. Jobの定義
    @Bean
    public Job exportJob(Step exportStep) {
        return new JobBuilder("exportJob", jobRepository)
                .start(exportStep)
                .listener(jobListener) // JobExecutionListenerを追加
                .build();
    }

<修正ポイント>
  • メソッドの引数を、exportStep の定義を受け取る形に設定
  • exportStep を呼び出すときの引数を不要に(例:.start(exportStep())→.start(exportStep))

Spring Framework は「@Bean アノテーションの付いたメソッドの引数に他の Bean の型を書いておくと、Spring が自動的にそれを探して持ってきてくれる(依存性注入)」というルールがあるので、これをすると Step を呼ぶときの引数を省略できる。
「processor は引数でパラメータ(ジョブ引数)を受け取る」→「exportStep はその情報を読み込む(Spring により自動で流し込まれる)」→「exportJob は Spring により自動的に組み立てられた Step の情報を読み込む(流し込まれる)」という過程を経て、あとは Spring が引数はうまいことやってくれる・・・という仕掛けだ。なので、呼び出すときの引数を省略できる。

ま、あまり Framework を使わず、一からしこしこコードを書いてきた人間には、こういう「Framework が巧いことなってくれる」という状態がなかなかしっくり来なくて、いつまでも「どうしてこうなる?どこからこの値はきた?」ってことになるんだけど、「そういうもんだ」と納得するしかないのである(笑)

と、ここまでできたら Eclipse で実行してみる。

Eclipse で実行させるときにパラメータ(ジョブ引数)を渡したいときは、プロジェクト名の上で右ボタンメニューから「実行」→「実行の構成」を選択し、開いた窓で、まずはプロジェクト名とメインクラスを指定。

20260713_parameters01.jpg

次に、プログラムのパラメータ(引数)を設定する。これは、コマンドラインから jar を実行するときと同じように、「パラメータ名=値」という形で記述する。複数のパラメータがある場合は半角スペース区切りで入力する。

20260713_parameters02.jpg

値を設定後、「実行」ボタン押下でジョブ実行。

ログにも引数の情報が出力されている。(長いので、行の先頭は'~'に省略)

~: Started BatchDb2Csv1Application in 5.341 seconds (process running for 6.314)
~: Running default command line with: [models=2, modelName=未来電話]
~: [name=exportJob]] launched with the following parameters:
[{JobParameter{name='models', value=2, type=class java.lang.String,
identifying=true},JobParameter{name='modelName', value=未来電話,
type=class java.lang.String, identifying=true}}]
~: ★Job started: exportJob
~: Executing step: [exportStep]
~: Step: [exportStep] executed in 767ms
~: ★Job finished: exportJob with status: COMPLETED
~: [{JobParameter{name='models', value=2, type=class java.lang.String, identifying=true},JobParameter{name='modelName', value=未来電話, type=class java.lang.String, identifying=true}}] and the following status: [COMPLETED] in 785ms


出力された CSV ファイルを見ると、しっかり携帯種が「未来電話」と出ている。パラメータの設定が効いているようだ。

ID,名前,住所1,住所2,電話,記念日,内容
1,テスト太郎,広島市中区橋本町2-17,若林ビル302,082-221-7555 ,,
2,HIROSHIMATAROU,広島県廿日市市宮島町5050-11,,090-1111-2222(未来電話),19661210,誕生日
2,HIROSHIMATAROU,広島県廿日市市宮島町5050-11,,090-1111-2222(未来電話),19981003,結婚
3,中国 花子,島根県呪郡呪村1078-13,川村診療所内,0120-333-444 ,,
4,近藤まちゃ,山口県周南市銀座3丁目2-19,,080-5555-6666(未来電話),19641225,誕生日
4,近藤まちゃ,山口県周南市銀座3丁目2-19,,080-5555-6666(未来電話),20230407,死人村トレイルラン100 完走
4,近藤まちゃ,山口県周南市銀座3丁目2-19,,080-5555-6666(未来電話),20240122,死人村雪山激走フェス 2.1km地点リタイア
5,MASA SAITO,広島市中区橋本町2-17,若林ビル201,082-221-7549 ,,
6,KAORU MITARAI,山口県岩国市周東町祖生99999,,0827-85-0427 ,20011011,結婚
6,KAORU MITARAI,山口県岩国市周東町祖生99999,,0827-85-0427 ,20011210,離婚
6,KAORU MITARAI,山口県岩国市周東町祖生99999,,0827-85-0427 ,20200523,結婚
7,日本 太郎,,,009-1234-5678,,


次回は、第1パラメータの型を整数(Integer)にしているため、ここに文字列を入れられると異常終了していまう。それを回避する修正をしよう。
Spring Batch で用意されている Event Listener も色々あるが、とりあえず Job の実行前後で起動する JobListener を使ってログを吐いてみよう。

一番シンプルな Spring Batch 6 の Chunk 処理例」で作ったバッチ処理に足してみる。

■LogJobListener.java の作成

JobExecutionListener を実装した Job の前後にログを吐くクラス。beforeJob に Job の前の処理を、afterJob に後の処理を書く。

package com.netandfield.test.Listener;

import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.listener.JobExecutionListener;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class LogJobListener implements JobExecutionListener {

    @Override
    public void beforeJob(JobExecution jobExecution) {
        log.info("★Job started: " + jobExecution.getJobInstance().getJobName());
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        log.info("★Job finished: " + jobExecution.getJobInstance().getJobName() + " with status: " + jobExecution.getStatus());
    }

}


■BatchConfig.java

バッチ設定に Listener をセットする

import部に、

import org.springframework.batch.core.listener.JobExecutionListener;
import org.springframework.beans.factory.annotation.Autowired;

を追加。

メンバ変数に jobListener を宣言。

<略>
@Configuration
public class BatchConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final DataSource dataSource;

    @Autowired
    private JobExecutionListener jobListener;
<略>

Jobの定義に JobListener を追加する

    @Bean
    public Job exportJob() {
        return new JobBuilder("exportJob", jobRepository)
                .start(exportStep())
                .listener(jobListener) // JobExecutionListenerを追加
                .build();
    }


ここまでソースをを修正したら実行。

これで、以下のようなログが出力される(長いので頭の方を「~」と略している)

...
~ : Job: [SimpleJob: [name=exportJob]] launched with the following parameters: [{}]
~ : ★Job started: exportJob
~ : Executing step: [exportStep]
~ : Step: [exportStep] executed in 681ms
~ : ★Job finished: exportJob with status: COMPLETED
~ : Job: [SimpleJob: [name=exportJob]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 698ms
...

ちゃんと出てるね。

ちなみにバッチ処理では「ボタンをクリックした」「表示されているオブジェクトにオンマウスした」などのイベントは無いので、用意されている Listener はほとんど「〇〇処理の前後に起動する」ものだ。

反対に、なんらかの処理には全て Listener が用意されている・・・かな。
「Stepの実行前後」「Chunkの実行前後とエラー発生時」「Readerの実行前後とエラー発生時」等など。
色々と Spring Batch 6 で試している元のソースは「一番シンプルな Spring Batch 6 の Chunk 処理例」に掲載しているものだと言っているんだが、実は変更されている。

真面目に「一番シンプルな Spring Batch 6 の Chunk 処理例」のソースを使って、その後の記事の変更点を適用しようとしたらなんかコードが違う!?と戸惑われている方がいらっしゃるかもしれないので、一応、変更後のソースも載せておこう。

一番シンプルな Spring Batch 6 の Chunk 処理例」のソースを参照・・・と書かれていたら、実際にはこのソースを使用する。

テーブルも複数参照するように変わっているが JOIN で連結しているので SQL 文は一本だ。追加したテーブルは以下のとおり。

t_user_addresses(ユーザ住所)

20260713_base_springbatch1.jpg

t_user_anniversaries(ユーザ記念日)

20260713_base_springbatch2.jpg

これを、以前作った t_users(ユーザ情報テーブル)と連結して CSV に出力するように修正した。

20260713_base_springbatch3.jpg

ただ、 SQL が変わったのと、出力する項目数が増えただけなので、基本的な構造は変わらない。

以下、修正点。


■Dto/UserCombinedDto.java(record クラス)

package com.netandfield.test.Dto;

public record UserCombinedDto(
    Long user_id,
    String user_name,
    String user_address1,
    String user_address2,
    String user_phone_number,
    String anniversary,
    String note
) {}

※以前作った TUserDto.java は廃止。


■Config/BatchConfig.java(バッチ設定クラス)

package com.netandfield.test.Config;

import javax.sql.DataSource;

import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.item.ItemProcessor;
import org.springframework.batch.infrastructure.item.database.JdbcCursorItemReader;
import org.springframework.batch.infrastructure.item.database.builder.JdbcCursorItemReaderBuilder;
import org.springframework.batch.infrastructure.item.file.FlatFileItemWriter;
import org.springframework.batch.infrastructure.item.file.builder.FlatFileItemWriterBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.jdbc.core.DataClassRowMapper;
import org.springframework.transaction.PlatformTransactionManager;

import com.netandfield.test.Dto.UserCombinedDto;

@Configuration
public class BatchConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final DataSource dataSource;

    // コンストラクタ注入
    public BatchConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager, DataSource dataSource)
{
        this.jobRepository = jobRepository;
        this.transactionManager = transactionManager;
        this.dataSource = dataSource;
    }

    // 1. Reader: Oracle DBからデータを読み込む
    @Bean
    public JdbcCursorItemReader<UserCombinedDto> reader() {

        // 記念日が複数あり
        String joinSql = """
            SELECT
                u.user_id,
                u.user_name,
                a.user_address1,
                a.user_address2,
                u.user_phone_number,
                an.anniversary,
                an.note
            FROM t_users u
            LEFT JOIN t_user_addresses a ON u.user_id = a.user_id
            LEFT JOIN t_user_anniversaries an ON u.user_id = an.user_id
            ORDER BY u.user_id ASC, an.anniversary ASC
            """;
        return new JdbcCursorItemReaderBuilder<UserCombinedDto>()
                .dataSource(dataSource)
                .name("oracleItemReader")
                .sql(joinSql)
                .rowMapper(new DataClassRowMapper<>(UserCombinedDto.class)) // RowMapperでResultSet(検索結果オブジェクト)をDTOへマッピング
                .fetchSize(1000) // Oracleのパフォーマンス最適化(1,000行ずつ読込)
                .build();
    }

    // 2. Processor: データを加工する
    @Bean
    @StepScope // ジョブ引数を受け取るために必須
    public ItemProcessor<UserCombinedDto, UserCombinedDto> processor() {
        return item -> {
            String phone = item.user_phone_number(); // DtoがRecordの場合のゲッター。通常のクラスなら .getUser_phone_number()

            // 電話番号が null でなく、かつ 090 または 080 で始まる場合
            if (phone != null && (phone.startsWith("090") || phone.startsWith("080"))) {
                // 文字列を追加した新しいDtoを作成して返す
                // (DtoがRecordの場合はイミュータブル(不変)なので、このように新しくインスタンスを作る)
                return new UserCombinedDto(
                        item.user_id(),
                        item.user_name(),
                        item.user_address1(),
                        item.user_address2(),
                        phone + "(携帯)",
                        item.anniversary(),
                        item.note()
                );            }

            // 条件に当てはまらない場合は、加工せずにそのまま次に渡す
            return item;
        };
    }

    // 3. Writer: CSVファイルへ出力する
    @Bean
    public FlatFileItemWriter<UserCombinedDto> writer() {
        return new FlatFileItemWriterBuilder<UserCombinedDto>()
                .name("csvItemWriter")
                .resource(new FileSystemResource("C:\\work\\test\\t_users.csv"))
                .delimited() // 区切り文字(Delimited)を使ったフォーマットで書き出すことを指定
                .delimiter(",") // 区切り文字をカンマに設定(省略した場合もカンマ)
                .names("user_id", "user_name", "user_address1", "user_address2", "user_phone_number", "anniversary", "note") // 出力するフィールド名
                .headerCallback(writer -> writer.write("ID,名前,住所1,住所2,電話,記念日,内容")) // ヘッダー行
                .build();
    }

    // 4. Stepの定義 (Spring Batch 6 スタイル)
    @Bean
    public Step exportStep () {
        return new StepBuilder("exportStep", jobRepository)
                .<UserCombinedDto, UserCombinedDto>chunk(100) // 100件ごとにコミット/出力
                .transactionManager(transactionManager)
                .reader(reader())       // 読んで
                .processor(processor()) // 処理して
                .writer(writer())       // 書き出す
                .build();
    }

    // 5. Jobの定義
    @Bean
    public Job exportJob() {
        return new JobBuilder("exportJob", jobRepository)
                .start(exportStep())
                .listener(jobListener) // JobExecutionListenerを追加
                .build();
    }
}


これで実行すれば、

20260713_base_springbatch4.jpg

こういう CSV ファイルが出力される。
Chunk(チャンク)処理の基本通り、

  • ItemReader: Oracle Database からユーザー一覧(ID、名前、電話番号)を読み込む
  • ItemProcessor: 電話番号が携帯電話番号だったときは、番号の後ろに'(携帯)'とつける加工処理
  • ItemWriter: 読み込んだデータ(加工されたものもあり)を CSV ファイルに出力

という 3ステップ構成。
ただし、データが 1件もなかったときの処理や、CSV 出力に失敗したときの例外処理など、色々本物の業務バッチ処理としては足らない機能がたくさんあるので、それを勉強しながら少しずつ実装していくつもり。

まずは、そういう勉強の基盤になるソースということで、メモ代わりに貼っておく。
(プロジェクト名は 'BatchDb2Csv1')

とりあえず、

20260702_sample1.jpg

※「Oracle Database 21c Express Edition (XE) に A5 で接続」というエントリーで作成したデータ

こういうデータを Oracle Database から読み込んで、このエントリのバッチを走らせたら、

20260702_sample2.jpg

こういう CSV ファイルが吐かれます。


■application.properties

spring.application.name=BatchDB2CSV1

# Oracle 接続設定
spring.datasource.url=jdbc:oracle:thin:@localhost:1521/XEPDB1
spring.datasource.username=tester
spring.datasource.password=tester01
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver

# アプリ起動時にSpring Batchのジョブを自動実行する
spring.batch.job.enabled=true

# 【重要】Spring Batch等の管理テーブルを自動作成させる設定
spring.sql.init.mode=always

# Spring Boot DevToolsの自動再起動を無効化する(終了前に一時停止状態になるので)
spring.devtools.restart.enabled=false

YAML形式の方がお好みの方もいらっしゃるでしょうが、俺はこっちが好み。
どうも、半角スペースでインデント(字下げ)するのが(^^; Python 界隈の人は抵抗ないでしょうが(^^;;


■BatchDb2Csv1Application.java

package com.netandfield.test;

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

@SpringBootApplication
public class BatchDb2Csv1Application {

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

}


■Dto/TUserDto.java

package com.netandfield.test.Dto;

// DTOクラス・・・ではなく record(不変オブジェクト)で
public record TUserDto(Long user_id, String user_name, String user_phone_number) {}


■Config/BatchConfig.java

package com.netandfield.test.Config;

import javax.sql.DataSource;

import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.item.ItemProcessor;
import org.springframework.batch.infrastructure.item.database.JdbcCursorItemReader;
import org.springframework.batch.infrastructure.item.database.builder.JdbcCursorItemReaderBuilder;
import org.springframework.batch.infrastructure.item.file.FlatFileItemWriter;
import org.springframework.batch.infrastructure.item.file.builder.FlatFileItemWriterBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.jdbc.core.DataClassRowMapper;
import org.springframework.transaction.PlatformTransactionManager;

import com.netandfield.test.Dto.TUserDto;

@Configuration
public class BatchConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final DataSource dataSource;

    // コンストラクタ注入
    public BatchConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager, DataSource dataSource)
    {
        this.jobRepository = jobRepository;
        this.transactionManager = transactionManager;
        this.dataSource = dataSource;
    }

    // 1. Reader: Oracle DBからデータを読み込む
    @Bean
    public JdbcCursorItemReader<TUserDto> reader() {
        return new JdbcCursorItemReaderBuilder<TUserDto>()
                .dataSource(dataSource)
                .name("oracleItemReader")
                .sql("SELECT user_id, user_name, user_phone_number
FROM t_users ORDER BY user_id ASC")
                .rowMapper(new DataClassRowMapper<>(TUserDto.class)) // RowMapperでResultSet(検索結果オブジェクト)をDTOへマッピング
                .fetchSize(1000) // Oracleのパフォーマンス最適化(1,000行ずつ読込)
                .build();
    }

    // 2. Processor: データを加工する
    @Bean
    public ItemProcessor<TUserDto, TUserDto> processor() {
        return item -> {
            String phone = item.user_phone_number(); // DtoがRecordの場合のゲッター。通常のクラスなら getUser_phone_number()

            // 電話番号が null でなく、かつ 090 または 080 で始まる場合
            if (phone != null && (phone.startsWith("090") || phone.startsWith("080"))) {
                // 文字列を追加した新しいDtoを作成して返す
                // (DtoがRecordの場合はイミュータブル(不変)なので、このように新しくインスタンスを作る)
                return new TUserDto(
                        item.user_id(),
                        item.user_name(),
                        phone + "(携帯)"
                );
            }

            // 条件に当てはまらない場合は、加工せずにそのまま次に渡す
            return item;
        };
    }

    // 3. Writer: CSVファイルへ出力する
    @Bean
    public FlatFileItemWriter<TUserDto> writer() {
        return new FlatFileItemWriterBuilder<TUserDto>()
                .name("csvItemWriter")
                .resource(new FileSystemResource("C:\\work\\test\\t_users.csv"))
                .delimited() // 区切り文字(Delimited)を使ったフォーマットで書き出すことを指定
                .delimiter(",") // 区切り文字をカンマに設定(省略した場合もカンマ)
                .names("user_id", "user_name", "user_phone_number") // 出力するフィールド名
                .headerCallback(writer -> writer.write("ID,名前,電話番号")) // ヘッダー行
                .build();
    }

    // 4. Stepの定義 (Spring Batch 6 スタイル)
    @Bean
    public Step exportStep() {
        return new StepBuilder("exportStep", jobRepository)
                .<TUserDto, TUserDto>chunk(100) // 100件ごとにコミット/出力
                .transactionManager(transactionManager)
                .reader(reader())       // 読んで
                .processor(processor()) // 処理して
                .writer(writer())       // 書き出す
                .build();
    }

    // 5. Jobの定義
    @Bean
    public Job exportJob() {
        return new JobBuilder("exportJob", jobRepository)
                .start(exportStep())
                .build();
    }
}

とこんな感じで。

次は、DBから 1件も取得できなかったときとか、CSVに出力するレコードが 0件とか(出力する?しない?ヘッダだけ出す?出さない?)、あと例外処理か。そういうのを追加していこう。
Java 案件は何度もやったことはあるが、既存システムの修正などが主体だったので、そんなにJavaの言語仕様を深く理解はしていなかったのよね。

なもんで、今回初めて Java のソースで """ とダブルクォーテーションが3つ並んでるのを見て、「なんじゃこりゃ?」って(^^;;;。

君が代は千代に八千代に
さざれ石の巌となりて
苔のむすまで

これを文字列リテラルに突っ込むんだけど、

String msg = """
君が代は千代に八千代に
さざれ石の巌となりて
苔のむすまで
""";

となってて。

VB.NETのようにダブルクォーテーションをダブルクォーテーションでエスケープしているわけじゃないよな。それだと文の前後にダブルクォーテーションが付いちゃうしな(「"君が世は~むすまで"」ってなっちゃう)・・・と悩む。

実はこれ、文字列リテラルにそのまま複数行を突っ込める「テキストブロック(Text Blocks)」という機能。
これを使わないと、

String msg = "君が代は千代に八千代に\n"
           + "さざれ石の巌となりて\n"
           + "苔のむすまで";

こんな風に書くしかないのでごちゃごちゃ読みづらい。

Java の改修履歴を追ってみると、

Java SE 13(2019年9月17日)
 Text Blocks(文字列リテラルの複数行記述可能)第1回試作
Java SE 14(2020年3月17日)
 Text Blocks(文字列リテラルの複数行記述可能)第2回試作
Java SE 15(2020年9月15日)
 Text Blocks・第3回試作 ※これ以降、試作の記述なし

と、2020年にやっと正式な仕様として取り込まれたようだ。マジ?それまで Java って改行を含む複数行を一発で文字列リテラルに突っ込む方法ってなかったん?

俺の母国語(笑)である Perl なら、もう何十年も前からヒアドキュメントってやつで複数行を文字列リテラルに突っ込める。

my $msg = <<EOS;
君が代は千代に八千代に
さざれ石の巌となりて
苔のむすまで
EOS

こういうやつね。さらに言えばヒアドキュメント使わなくても単純にクォーテーション(シングルでもダブルでも)で囲ってやるだけでいい。

my $msg = '君が代は千代に八千代に
さざれ石の巌となりて
苔のむすまで';

VB.NETでも同じように(こっちはダブルクォーテーションね)、

Dim msg As String = "君が代は千代に八千代に
さざれ石の巌となりて
苔のむすまで"

って書ける。

ただ、一行目をクォーテーションのすぐ横から開始しないといけないので(改行しちゃうと改行がそのまま一行目として入っちゃうので)一行目の開始位置はずれる。これが嫌なら Perl ならやっぱヒアドキュメントがいいか。

今知ったんだけど、VB.NETも 2015年リースの VB 14.0 から可能になったんだなあ。そういえば、その前は & でつなげて書いてたっけ?

Dim msg As String = "君が代は千代に八千代に" + vbCrLF +
                    "さざれ石の巌となりて" + vbCrLF +
                    "苔のむすまで"

みたいに?(もう記憶が・・・)

まあ、なんにせよ、Java でこういう書き方ができるようになったのが 2020年からとは本当に意外だ。
Java が母国語の人って、こういう要件ってあまりなかったのかね?
プログラムの中にあまり HTML を直書きしたりとか、そういうのが無かったのかもね。
「どんどんレコード(行)が追加されていく複数のテキストデータを一定間隔(1秒)で読んで、追加されたレコードをDBに登録していく」バッチ処理(Spring Batch 6)の話の最後。

一気に、実際の実行部のソースを貼っておきます。
いや、俺も勉強中でしっかり中身が理解できているわけではないので(^^;;;
でも、とりあえず「ネットに公開されているソースをパチって実際に動かして内容を理解したい」って人もいるでしょ?(俺はもろにそっち系(笑))

■バッチ処理全体構成・設定クラス(BatchConfig.java)※Bean 定義クラス

package com.netandfield.test;

import jakarta.persistence.EntityManagerFactory;

import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.item.ItemProcessor;
import org.springframework.batch.infrastructure.item.database.JpaItemWriter;
import org.springframework.batch.infrastructure.item.database.builder.JpaItemWriterBuilder;
import org.springframework.batch.infrastructure.item.file.FlatFileItemReader;
import org.springframework.batch.infrastructure.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.infrastructure.item.file.transform.FixedLengthTokenizer;
import org.springframework.batch.infrastructure.item.file.transform.Range;
import org.springframework.batch.infrastructure.item.support.SynchronizedItemStreamReader;
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.transaction.PlatformTransactionManager;

@Configuration
public class BatchConfig {

    @Bean
    public Job fileProcessJob(JobRepository jobRepository, Step fileProcessStep) {
        return new JobBuilder("fileProcessJob", jobRepository)
                .start(fileProcessStep)
                .build();
    }

    @Bean
    public Step fileProcessStep(JobRepository jobRepository,
                                PlatformTransactionManager transactionManager,
                                SynchronizedItemStreamReader<ProcessedData> synchronizedReader,
                                ItemProcessor<ProcessedData, ProcessedData> processor,
                                JpaItemWriter<ProcessedData> jpaWriter,
                                StepProgressUpdateListener progressListener) {
        return new StepBuilder("fileProcessStep", jobRepository)
                .<ProcessedData, ProcessedData>chunk(100)
                .transactionManager(transactionManager)
                .reader(synchronizedReader)
                .processor(processor)
                .writer(jpaWriter)
                .listener(progressListener) // リスナーをここで登録!
                .build();
    }

    @Bean
    @StepScope
    public SynchronizedItemStreamReader<ProcessedData> synchronizedReader(
            @Value("#{jobParameters['filePath']}") String filePath,
            @Value("#{jobParameters['linesToSkip']}") Long linesToSkip) {

        FixedLengthTokenizer tokenizer = new FixedLengthTokenizer();
        tokenizer.setColumns(new Range(1, 4), new Range(5, 24), new Range(25, 27));
        tokenizer.setNames("code", "name", "value");

        FlatFileItemReader<ProcessedData> delegate = new FlatFileItemReaderBuilder<ProcessedData>()
                .name("flatFileItemReader")
                .resource(new FileSystemResource(filePath))
                .linesToSkip(linesToSkip.intValue())
                .lineMapper((line, lineNumber) -> {
                    var fieldSet = tokenizer.tokenize(line);
                    ProcessedData data = new ProcessedData();
                    data.setCode(fieldSet.readString("code").trim());
                    data.setName(fieldSet.readString("name").trim());
                    data.setValue(fieldSet.readInt("value"));
                    return data;
                })
                .build();

        return new SynchronizedItemStreamReader<>(delegate);
    }

    @Bean
    public ItemProcessor<ProcessedData, ProcessedData> processor() {
        return item -> {
            item.setName(item.getName().toUpperCase()); // Name を大文字に変換する
            return item;
        };
    }

    @Bean
    public JpaItemWriter<ProcessedData> jpaWriter(EntityManagerFactory entityManagerFactory) {
        return new JpaItemWriterBuilder<ProcessedData>()
                .entityManagerFactory(entityManagerFactory)
                .build();
    }
}


■スケジューラ(FileWatchScheduler.java)※ジョブを定期的に実行する Bean

package com.netandfield.test;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;

import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Component
@RequiredArgsConstructor
@Slf4j
public class FileWatchScheduler {

    // Spring Batch 6 では、JobLauncher の役割を内包した JobOperator を使用
    private final JobOperator jobOperator;
    private final Job fileProcessJob;
    private final FileProgressRepository progressRepository;

    private final AppProperties appProperties;

    @Scheduled(fixedDelay = 1000)
    public void runPeriodicBatchJob() {
        Path listFile = Paths.get(appProperties.getFilesListPath());
        if (!Files.exists(listFile)) {
            log.warn("ファイル一覧が見つかりません: {}", appProperties.getFilesListPath());
            return;
        }

        try {
            List<String> targetFilePaths = Files.readAllLines(listFile);
            for (String filePath : targetFilePaths) {
                if (filePath.trim().isEmpty() || !Files.exists(Paths.get(filePath))) {
                    continue;
                }
                processSingleFile(filePath.trim());
            }
        } catch (IOException e) {
            log.error("ファイル一覧の読み込みに失敗しました", e);
        }
    }

    private void processSingleFile(String filePath) {
        // 1. 進捗管理DBから前回の行数を取得
        FileProgress progress = progressRepository.findById(filePath)
                .orElseGet(() -> {
                    FileProgress newProgress = new FileProgress();
                    newProgress.setFilePath(filePath);
                    newProgress.setLastReadLines(0);
                    return progressRepository.save(newProgress);
                });

        try {
            // 2. パラメータの組み立て
            JobParameters jobParameters = new JobParametersBuilder()
                    .addString("filePath", filePath)
                    .addLong("linesToSkip", (long) progress.getLastReadLines())
                    .addLocalDateTime("runTime", LocalDateTime.now()) // 毎回新規実行にするため
                    .toJobParameters();

            // 3. [Spring Batch 6 正攻法]
            // JobOperator が JobLauncher を継承し、run メソッドを直接安全に呼び出せる(警告なし)
            jobOperator.run(fileProcessJob, jobParameters);

            // ※進捗(行数)の更新処理は、バッチのライフサイクルに則り「StepProgressUpdateListener」が自動的に実行

        } catch (Exception e) {
            log.error("ファイル [{}] のバッチ処理中にエラーが発生しました", filePath, e);
        }
    }
}


■進捗(行数)更新処理クラス(StepProgressUpdateListener.java)※もちろん Bean

package com.netandfield.test;

import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.listener.StepExecutionListener;
import org.springframework.batch.core.step.StepExecution;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Component
@RequiredArgsConstructor
@Slf4j
public class StepProgressUpdateListener implements StepExecutionListener {

    private final FileProgressRepository progressRepository;

    @Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        // ステップが正常終了した場合のみ、進捗を更新する
        if (stepExecution.getExitStatus().equals(ExitStatus.COMPLETED)) {
            // 起動パラメータからファイルパスを取得
            String filePath = stepExecution.getJobParameters().getString("filePath");
            long newlyRead = stepExecution.getReadCount();

            if (filePath != null && newlyRead > 0) {
                FileProgress progress = progressRepository.findById(filePath)
                        .orElseGet(() -> {
                            FileProgress newProgress = new FileProgress();
                            newProgress.setFilePath(filePath);
                            newProgress.setLastReadLines(0);
                            return newProgress;
                        });

                progress.setLastReadLines(progress.getLastReadLines() + (int) newlyRead);
                progressRepository.save(progress);
                log.info("ファイル [{}] の進捗を更新しました。新たに {} 行処理、累計 {} 行。",
                        filePath, newlyRead, progress.getLastReadLines());
            }
        }
        return stepExecution.getExitStatus();
    }
}

「Spring Batch 6 、だんだん増えていく固定長レコードを処理する」の(1)~(3)に載せているソースを使って新しいプロジェクトを作れば、Spring Batch 6 で、「だんだん増えていく固定長レコード」が処理できます(笑)

Spring Batch は ver 6 大きくライブラリ構成などが変わっちゃったり、古い機能が完全に削除されたりしてるので、2026年6月現在の最新の Eclipse Pleiades All in One版を落としてきて Spring Batch の勉強をしようとすると、ネット上に公開されている色々なサンプルソースではまず動かない(^^;;;

AI に「Spring Batch 6 で動かすにはどうすればいいの?」と聞いても、3 やら 5 やらのソースばかり返してくるのよね(^^;;; AI はネット上の情報をかき集めて自分の知識としてるんだけど、そもそもネット上に Spring Batch 6 に対応したソースなどの情報が少ないので、AI 自身その関係の知識がないのよね(笑)

でも、このエントリーのソースなら動きます(笑)
さあ、実際に動かして、俺に色々教えてくれたまえ。

周回レースの集計データの処理を想定した「どんどんレコード(行)が追加されていく複数のテキストデータを一定間隔(1秒)で読んで、追加されたレコードをDBに登録していく」バッチ処理(Spring Batch 6)の話のつづき

ちなみに(1)で「仕様」に書き忘れてたけど、データベースへのアクセスは JDBC ではなく JPA を使っています。
俺的には全然 SQL は苦手ではないし、もっと言えば複雑な SQL を書くのは好きな方ですが(笑)、一昨年から昨年にかけて C#.NET で「DB操作は LINQ」って案件やって、ああ、糞っ、直接生の SQL 書けば一発なのにと苦労した記憶があるので(^^;;;、敢えて JPA を選んでみたわけです。勉強のために。

※LINQ でも直接 SQL 書けるじゃんってツッコミは無しで願います。言語仕様の話ではなく、コーディングルールで禁止されていたということなのよ。

ま、とういうわけで、今回はプロパティファイルや、テーブルの設定などを。

■Apache Maven プロジェクト設定ファイル(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>BatchTest5</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>BatchTest5</name>
    <description/>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>21</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-h2console</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-batch</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-batch-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                        <configuration>
                            <annotationProcessorPaths>
                                <path>
                                    <groupId>org.projectlombok</groupId>
                                    <artifactId>lombok</artifactId>
                                </path>
                            </annotationProcessorPaths>
                        </configuration>
                    </execution>
                    <execution>
                        <id>default-testCompile</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>testCompile</goal>
                        </goals>
                        <configuration>
                            <annotationProcessorPaths>
                                <path>
                                    <groupId>org.projectlombok</groupId>
                                    <artifactId>lombok</artifactId>
                                </path>
                            </annotationProcessorPaths>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

他のエントリーでも書いたけど、バッチ処理だけど H2 コンソールを動かすために tomcat が必要なので、spring-boot-starter-webmvc モジュールが依存関係(dependency)として登録されていること。


■プロパティ(resource/application.properties)

spring.application.name=BatchTest5

# ファイル一覧
app.files-list-path=C:\\work\\files.txt

# H2 を PostgreSQL 互換モードで使用(テーブル名等は小文字で)
spring.datasource.url=jdbc:h2:./.data/h2/db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# H2 Console を有効化
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# JPA/Hibernate設定
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.batch.job.enabled=false

# Webアプリケーションとして常駐
spring.main.web-application-type=servlet

特に注意するのは
spring.batch.job.enabled=false
かな。
これは起動時に自動でバッチジョブを実行しないようにしている。

Spring Boot はバッチ用のライブラリ(spring-boot-starter-batch)を検知すると、定義されている @Bean のジョブを起動時にすべて片っ端から実行しようとしてしまう。今回はスケジューラでパラメータ(読み込むファイル名とか)を与えてジョブを起動する形にしているから、勝手にジョブを起動されちゃうと「パラメータが無い状態」の実行となりエラーが発生する。なので「自動で起動すんなよ」と抑制しているわけやね。

あと、app.files-list-path は「'app.files-list-path' is an unknown property. [PROP_UNKNOWN_PROPERTY]」という警告が出るので、プロパティを認識させるためのメタデータを作ってやる。


■メタデータファイル(resource/META-INF/additional-spring-configuration-metadata.json)

{"properties": [{
  "name": "app.files-list-path",
  "type": "java.lang.String",
  "description": "A description for 'app.files-list-path'"
}]}


■Spring Boot メインクラス(BatchTest5Application.java)

package com.netandfield.test;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

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

Eclipse で Spring Batch のバッチを実行するときは、このファイルを Java アプリケーションとして実行する。


■DBマッピングクラス(FileProgress.java)
 ※各ファイルが何行目まで読まれているかを保持するテーブル(file_progress)

package com.netandfield.test;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import lombok.Data;

@Entity
@Table(name = "file_progress")
@Data
// 各ファイルの読み込み行数を管理するエンティティ
public class FileProgress {
    @Id
    private String filePath; // ファイルのフルパス
    private int lastReadLines; // 前回までに読み込み完了した行数
}

20260630_e2_01.jpg


■DBマッピングクラス(ProcessedData.java)
 ※読み込まれたデータが保存されるテーブル(processed_data)

package com.netandfield.test;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import lombok.Data;

@Entity
@Table(name = "processed_data")
@Data
// データを保存するエンティティ
public class ProcessedData {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String code;
    private String name;
    // データベース上の列名を「data_value」に退避させる(value は予約語なので)
    @Column(name = "data_value")
    private Integer value;
}

20260630_e2_02.jpg

id は、@Id アノテーションで主キーに設定され、データベースの Auto Increment機能を利用して自動採番されている。
id が 1~5までは続いて、急に 33に飛んでいるのは、DBに採番を依頼するとき、ある程度まとめて採番してもらっているから。

今回は、一度に 32個の ID を発行し、読み込んだデータに順に割り当てている。足らなければ再度採番依頼をするが、今回は最初の処理では 5件しかデータが無かったので、5番まで使って 6~32は捨てている。
そして、ファイルに新しい行が追加されたので次の処理が実行されるが、この時に前回の続きで、「33~64までの32個」のIDを採番し先頭から新しいレコードの id に割り当てたというわけである。

せっかく採番した ID が捨てられるのはもったいないということなら、この同時採番される数を 1 にしても良いが、当然 1レコードずつ採番要求が発生するので処理スピードは落ちる。


■FileProgressクラスのインタフェース(FileProgressRepository.java)

package com.netandfield.test;

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

public interface FileProgressRepository extends
JpaRepository<FileProgress, String> {
}

org.springframework.data.jpa.repository.JpaRepository を継承しており、findById や save といったメソッドが実行可能に。


このアーカイブについて

このページには、過去に書かれたブログ記事のうちプログラミングカテゴリに属しているものが含まれています。

前のカテゴリはパソコンです。

最近のコンテンツはインデックスページで見られます。過去に書かれたものはアーカイブのページで見られます。

月別 アーカイブ

電気ウナギ的○○ mobile ver.

携帯版「電気ウナギ的○○」はこちら