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

Spring Boot 4 で作成したファイルダウンロード画面。「ダウンロード」ボタン押下で DB を読んで CSV ファイルを生成し、そのファイルがクライアントPCにダウンロードされる。

Controller はこんな感じ。

    // ボタン押下で実行される処理
    @PostMapping("/createFile")
    public ResponseEntity<Resource> downloadFile() throws Exception {
        Path path    = HogeService.make_file_path(); // ダウンロードされるファイルを作成するパス
        HogeService.logic_make(path.toString());     // ダウンロードファイルを作成

        Resource resource    = new FileSystemResource(path);

        HttpHeaders headers    = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"");
        headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION);

        return ResponseEntity.ok()
                .contentType(getContentType(path))
                .contentLength(resource.contentLength())
                .headers(headers)
                .body(resource);
    }

    // ContentType の取得
    private MediaType getContentType(Path path) throws IOException {
        try {
            return MediaType.parseMediaType(Files.probeContentType(path));
        }
        catch (IOException e) {
            return MediaType.APPLICATION_OCTET_STREAM;
        }
    }

これで、例えば

    <form action="/createFile" method="post" id="form" name="form">
        <input type="submit" name="download-btn" class="btn-primary" id="download-btn" value="ダウンロード">

みたいな形にすれば、「ダウンロード」ボタン押下でブラウザのダウンロード窓が開いてダウンロードは行われる。

しかし、例えばダウンロードに失敗したときは Web 画面上にエラーメッセージを表示したいとか、成功したら画面に「正常にダウンロードできました」などと表示したい場合はこれではダメだ。

Controller の各メソッドで返却できる値は 1種類である。
上の例のようにデータ(ダウンロードファイル)を出力するか(ダウンロード)、HTML を出力するか(画面表示)、生のテキストや JSON データなどを出力するか(API など)・・・のいずれかひとつのことしかできない。
「先にファイルを出力して、その次に結果を伝える HTML を出力する」という二段構えの出力はできない。ファイルを出力すれば画面(HTML)は表示できないし、画面(HTML)を表示(出力)すればファイルは出力できない。

・・・が、ファイルダウンロードをして結果を Web 画面に表示したい要件ってあるよねえ。

そういうときは、直接ファイルダウンロードの画面を呼び出すのではなく、JavaScript の中でファイルダウンロード画面を呼び出し一旦ファイルを受信しするようにする。そして、そのデータをクライアントに出力し、その後画面の編集をすればいい。JavaScript で処理すれば「ファイル出力か HTML 画面表示かどちらかしかできない」なんて制限ないからね。

具体的には、↓こんな感じの JavaScript。

    document.getElementById('download-btn').addEventListener('click', async (e) => {

        e.preventDefault();

        const btn    = document.getElementById('download-btn');
        const errMesg = document.getElementById('error_msg');
        const sucMesg = document.getElementById('success_msg');

        errMesg.style.display = 'none';  // メッセージ非表示
        sucMesg.style.display = 'none';  // メッセージ非表示

        try {
            btn.disabled = true;  // ボタンが押されたら、二度押し防止のため非活性化

            // ダウンロード処理呼び出し
            const action_url    = /*[[@{/createFile}]]*/ '';
            const response = await fetch(action_url, {  // fetch(非同期通信(HTTPリクエスト))でダウンロードデータ取得
                method: 'POST'
            });

            if (!response.ok) {  // ダウンロード画面からデータが取れなければ例外発生
                throw new Error()
            }

            const blob = await response.blob();  // データをBlob(バイナリの塊)として取得

            const disposition  = response.headers.get('Content-Disposition') ;  // レスポンスヘッダーからファイル名取得
            let filename = 'download.csv';  // ファイル名が取れなかったときのディフォルト値

            if (disposition && disposition.indexOf('attachment'!== -1)) {
                const fileMatch        = /filename="?([^; \n"]*)"?/.exec(disposition);  // 正規表現でファイル名取得
                if (fileMatch && fileMatch[1]) {
                    filename    = fileMatch[1];
                }
            }

            const url  = window.URL.createObjectURL(blob);  // ダウンロード用の疑似リンク作成
            const a    = document.createElement('a');     // 疑似アンカータグ作成

            a.href     = url;  // アンカータグの href 属性に疑似URLを紐づける
            a.download = filename;  // アンカータグの download 属性にファイル名をセット
            document.body.appendChild(a);  // 作成した疑似アンカータグを一時的に(操作のため)実体化
            a.click();  // 実体化したアンカータグを画面の前の人間に代わってクリック(ダウンロードスタート)

            a.remove();  // もう必要ないのでアンカータグ削除
            window.URL.revokeObjectURL(url);  // メモリに残った疑似リンク(blobデータ含む)を削除

            btn.disabled = false;  // ボタン使用可能に
            sucMesg.textContent = "正常にダウンロードが終了しました。";
            sucMesg.style.display = 'block';  // メッセージをセットして表示

        }
        catch(error) {
            btn.disabled    = false;  // ボタン使用可能に
            errMesg.textContent = "ダウンロードに失敗しました。";
            errMesg.style.display = 'block';  // メッセージをセットして表示
        }
        finally {

        }
    });

ダウンロードボタンが押されたらイベントリスナーによりこの処理が実行される。
コメントに書いているとおり、JavaScript 内で(画面の前の人間には見えないところで)実際のダウンロード画面にアクセスしデータを取得。そのデータをダウンロードするための疑似リンクを作って、そのリンクに(画面の前の人に代わって)アクセスして画面の前の人のパソコンにデータがダウンロードされるという動き。

なんでこういうことをするかというと、JavaScript 内でダウンロードの成否を判断して処理を行うため。

ダウンロードに失敗したときは例外を発生させ「失敗」メッセージを表示。最後まで問題なく処理が終われば「正常」メッセージを表示・・・となる。(直接ブラウザでダウンロードをした場合は、成否の情報は画面(JavaScript)には伝わってこないので何もできない。)

Controller のファイルダウンロードのメソッド(上の例では downloadFile メソッド)を修正する必要はない。
Eclipse の Update に失敗してしまい新しく入れ直した。

で、実は(まあ、色々な理由や今までの経緯があって)Eclipse も Workspace もえらく深いところに置いてあったので、もう少し浅いディレクトリにセットアップしようと思って、Workspace を丸ごとコピーしてきたのよね。
C:\per0per0\jyupujyupu\SpanSpan\ikuiku\Kao2KakETE\workspace から C:\pleiades\workspace みたいに。

で、C:\per0per0\jyupujyupu\SpanSpan\ikuiku\Kao2KakETE\workspace 消したらエラー出まくり。コピーして、実行時に Workspace に C:\pleiades\workspace を指定して立ち上げてるのに、ああ、元の Workspace を見てるんだ・・・

workspace\.metadata ディレクトリ以下のファイルにフルパスの指定がたくさんあるので、単純にコピーしたのでは全部古い workspace の方を見てしまうようである。

プロジェクト単位でエクスポート/インポートするのは面倒なので丸っとコピーしたのだが、元の Workspace の中のプロジェクトを一気にインポートできる機能があるのでそれを使うべきだった(笑)

20260730_inport1.jpg

  1. まず、空のディレクトリ(C:\pleiades\workspace)を作る
  2. そのディレクトリ(フォルダ)を Workspace に指定して Eclipse を起動
  3. メニュー「ファイル」→「インポート」選択
  4. インポート・ウィザードの選択で、「一般」→「既存プロジェクトをワークスペースへ」を選択し「次へ」押下
  5. ルートディレクトリーの選択に、元のディレクトリパス(C:\per0per0\jyupujyupu\SpanSpan\ikuiku\Kao2KakETE\workspace)を入力
  6. そのディレクトリに含まれるプロジェクト全てにチェックが入っているのを確認
  7. オプションの「プロジェクトをワークスペースにコピー」をチェックして「完了」押下

20260730_inport2.jpg

これで、古い Workspace から新しい Workspace 下に全てのプロジェクトがコピーされるので、古いほうは削除して大丈夫。

何事も正しい手順を踏むことが大切だね(笑)
20260728_copilot1.jpg

すっかりプログラミングに AI は必需品。一度 AI に頼っちゃうともう AI なしの生活には戻れんなあ(^^;;;
まあ、AI は嘘もつくので、ある程度のリテラシーは必要なんだけど、単純なミス(そういうのに限ってこちらも思い込みがあるので見つけられなかったりする)を見つけてくれるスピードと正確さは純粋にすごいよ。

ただ、あくまで AI は今ネット上にあふれている情報を元に回答を作るので、このブログでもよく話題にしているけど「Spring Batch 6 のコード例を見せて」と言っても。実際には 6 ではなく、多く出回っている 3 や 4 の情報を元にした回答をしてくる。

それに気づいてこちらが「6 のコードって言ったよね?」と指摘すると、Gemini は「おっしゃる通りです。6 では書き方が変わっています」とか言って修正したコードを提示してくるんだけど・・・

GitHub の AI「Copilot Free」は駄目だな・・・(^^;;; ほんま、まるで老害頑固ジジィや・・・(^^;;;

いつもは Gemini 使ってるんだけど、たまには GitHub Copilot にも聞いてみようと(Gemini よりプログラミングに強そうじゃん(笑))、「Spring Batch のコードのチェックをして」と自作のコードを上げてみたのよ。

そしたら、

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

を見て、「ほぼ正しいけどインポートを間違えている」と指摘する。core の後ろの job や listener が不要だと言う。

出た!!これ、Gemini もよくやるんだけど、Spring Batch 6 からパスが変わったことを知らないんよね、AI は。ネット上の情報が少なすぎて。
だから俺は「Spring Batch 6 では、パッケージの位置はこれであっていると思うのですが、どうでしょうか?」と聞いたわけ。これで Gemini であれば「正しいご指摘ありがとうございます。おっしゃる通りです」って正しいコードを再提示してくるんよね。

ところが・・・Copilot ときたら自分の間違えを絶対に認めないのよ。

「いいえ、その指定は間違いです。Spring Batch 6 でも正しいインポートは提示したとおりです」と一歩も譲らない(^^;;;

「でも、そのパスだと『インポートされたパッケージは見つからない』ってエラーになるよ」と言っても、「なるほど。結論から言うと、クラスのパッケージ自体は Spring Batch 6 でも提示した内容であってます」と(自分は間違ってないと(^^;;)回答。そして、「多分あなたの依存関係の設定がおかしい。pom.xml を見せてくれれば間違いがどこか指摘してあげましょう」的なことを言うのよ(^^;;;;

Gemini なら(指摘さえすれば)新しい情報を見つけてきてくれるのに、Copilot は全然駄目じゃの。こりゃ、プログラミング領域でも Gemini の方が使えるで(笑)。GitHub信者は怒るだろうけど(笑)

Copilot に pom.xml 見せても当然間違いはなくて、そしたらそのことにはまったく触れず(笑)「依存関係が間違っているはずなので、今度は .\mvnw.cmd dependency:tree の実行結果を見せてください」と。「はぁ?」イライラしつつも実行結果を見せてやったよ。「いいですね、Maven で依存は解決できていて spring-batch-core:6.0.4 が入っていることが確認できました。つまり『クラスが存在しない』の原因はソースのインポートが間違っています」だって(笑)。いや、笑うしかない

さすがに俺も堪忍袋の緒が切れて、「間違っているのは俺の提示しているパスではなく、そちらが提示しているパスの方だ。もう一度情報を確認しろ」と言うと、「怒らせてしまったようで申し訳ありません。落ち着いて確認しましょう」と言いつつ、「結論から言うと、通常の Spring Batch 6 の配布(spring-batch-core jar)では JobExecution の FQN org.springframework.batch.core.JobExecution です。」と同じ主張を繰り返す。これ、人間同士なら殺人事件に発展してるで(笑)

俺が提示している org.springframework.batch.core.job.JobExecution は一般的な配布物には存在しない、何か特殊な例だと(^^;;;
そして自信満々に「spring-batch-core-6.0.4.jar の中を JobExecution という文字列で検索すれば(俺の過ちが)はっきりするだろう」ってさ(^^;;;

もう付き合えんわ(^^;;;
俺は静かに Copilot に Spring Batch の正式ドキュメントサイトの URL を送ったのであった。


そして、しばらくの Thinking... 表示のあと、ついに Copilot も自らの過ちを認めた(笑)

「確認しました -- ご提示の公式 Javadoc を見て正しく理解しました。おっしゃる通り、Spring Batch の該当 Javadoc ページでは JobExecution は次のパッケージになっています:org.springframework.batch.core.job.JobExecution 私の先の案内で誤った情報を伝えて混乱させてしまい、申し訳ありません。訂正します。」・・・と。

長い闘いだった(笑)

ま、Free 版だからというのは当然あるだろうが、俺が使ってる Gemini も無償版だからな。無償版同士だと明らかに Gemini の方が賢いわ。実際、Gemini が 3、4秒で返してきた答えを、Copilot は 20秒くらい考えるってこともちょくちょくレベルで発生したし。(GitHub信者から凄い反論受けそうだけど(笑))

まあ、資金力に差があるのは確かだから思考時間の長短は仕方ないにしても、こちらから「それ違うでしょ?」と言うたものの再調査のロジックは少し考えた方が良いんじゃないかね?Copilot Free。
今まで作ってきた 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件とか(出力する?しない?ヘッダだけ出す?出さない?)、あと例外処理か。そういうのを追加していこう。

このアーカイブについて

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

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

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

月別 アーカイブ

電気ウナギ的○○ mobile ver.

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