概要

この記事は Java バッチ連載の最終回です。全体設計のインターフェース、基本構造、設定ファイル読み込み、リトライ処理、ログ設計、エラーハンドリングを一つの動作するバッチジョブとして統合します。設定ファイルからパラメータを読み込み、CSV を1行ずつ処理し、エラー戦略に従ってスキップまたは停止し、外部API呼び出しにはリトライを適用し、すべての経過をログに記録します。各クラスの役割と接続点を示し、連載全体の設計がどう組み合わさるかを確認できます。実務で自前のバッチフレームワークを構築する際の出発点として活用してください。

使いどころ

社内システムの CSV 取込バッチを新規開発する際の設計テンプレートとして使う

既存のバッチ処理をリファクタリングし、リトライ・ログ・エラーハンドリングを共通化する

Spring Batch を導入するほどではない小〜中規模バッチの自前フレームワークとして採用する

コード例

CsvImportJob — 全クラスを統合した完成版バッチ
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.Callable;
import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;

public class CsvImportJob {

    // ===== BatchConfig =====
    static class BatchConfig {
        private final Properties props;
        BatchConfig(String path) throws IOException {
            this.props = new Properties();
            FileInputStream fis = new FileInputStream(path);
            try { props.load(fis); } finally { fis.close(); }
        }
        String get(String key, String def) { return props.getProperty(key, def); }
        int getInt(String key, int def) { return Integer.parseInt(props.getProperty(key, String.valueOf(def))); }
    }

    // ===== BatchLogger =====
    static class BatchJobLogger {
        private final Logger logger;
        BatchJobLogger(String name, String logFile) throws IOException {
            this.logger = Logger.getLogger(name);
            logger.setUseParentHandlers(false);
            FileHandler fh = new FileHandler(logFile, 5_000_000, 3, true);
            fh.setFormatter(new SimpleFormatter() {
                @Override public String format(LogRecord r) {
                    return String.format("[%1$tF %1$tT] [%2$-7s] %3$s%n", r.getMillis(), r.getLevel(), r.getMessage());
                }
            });
            logger.addHandler(fh);
            logger.setLevel(Level.ALL);
        }
        void info(String msg) { logger.info(msg); }
        void warn(String msg) { logger.warning(msg); }
        void error(String msg, Throwable t) { logger.log(Level.SEVERE, msg, t); }
    }

    // ===== RetryExecutor =====
    static class RetryExecutor {
        private final int max; private final long initDelay; private final long maxDelay; private final BatchJobLogger log;
        RetryExecutor(int max, long initDelay, long maxDelay, BatchJobLogger log) {
            this.max = max; this.initDelay = initDelay; this.maxDelay = maxDelay; this.log = log;
        }
        <T> T execute(Callable<T> task) throws Exception {
            int attempt = 0; long delay = initDelay;
            while (true) {
                try { return task.call(); } catch (Exception e) {
                    attempt++;
                    if (attempt >= max) throw e;
                    log.warn(String.format("リトライ %d/%d — %dms後", attempt, max, delay));
                    Thread.sleep(delay);
                    delay = Math.min(delay * 2, maxDelay);
                }
            }
        }
    }

    // ===== ErrorHandler =====
    enum ErrorPolicy { FAIL_FAST, CONTINUE, SKIP_LIMIT }
    static class ErrorHandler {
        private final ErrorPolicy policy; private final int maxSkips; private int skipCount;
        private final List<String> skipped = new ArrayList<String>();
        ErrorHandler(ErrorPolicy p, int max) { this.policy = p; this.maxSkips = max; }
        void handle(int line, String rec, Exception e, BatchJobLogger log) {
            switch (policy) {
                case FAIL_FAST: throw new RuntimeException("行" + line + "で中断", e);
                case CONTINUE: log.warn("行" + line + "スキップ"); skipped.add(line + ":" + rec); break;
                case SKIP_LIMIT: skipCount++; skipped.add(line + ":" + rec);
                    if (skipCount > maxSkips) throw new RuntimeException("スキップ上限超過", e);
                    log.warn("行" + line + "スキップ(" + skipCount + "/" + maxSkips + ")"); break;
            }
        }
        int getSkipCount() { return skipCount; }
    }

    // ===== メイン処理 =====
    public static void main(String[] args) {
        if (args.length < 1) { System.err.println("使い方: java CsvImportJob <config.properties>"); System.exit(1); }
        try {
            BatchConfig config = new BatchConfig(args[0]);
            BatchJobLogger log = new BatchJobLogger("CsvImportJob", config.get("log.path", "logs/batch.log"));
            RetryExecutor retry = new RetryExecutor(config.getInt("retry.max", 3), config.getInt("retry.delay.ms", 1000), 16000L, log);
            ErrorHandler errHandler = new ErrorHandler(ErrorPolicy.valueOf(config.get("error.policy", "SKIP_LIMIT")), config.getInt("error.max.skips", 50));

            log.info("=== CsvImportJob 開始 ===");
            long start = System.currentTimeMillis();
            int success = 0; int lineNum = 0;
            BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(config.get("csv.path", "data/input.csv")), "UTF-8"));
            try {
                String line;
                while ((line = reader.readLine()) != null) {
                    lineNum++;
                    if (lineNum == 1) continue;
                    final String record = line;
                    try {
                        retry.execute(new Callable<Void>() {
                            @Override public Void call() throws Exception { processRecord(record); return null; }
                        });
                        success++;
                    } catch (Exception e) { errHandler.handle(lineNum, record, e, log); }
                }
            } finally { reader.close(); }

            long elapsed = System.currentTimeMillis() - start;
            log.info(String.format("完了: 成功=%d, スキップ=%d, %dms", success, errHandler.getSkipCount(), elapsed));
            log.info("=== CsvImportJob 終了 ===");
        } catch (Exception e) { System.err.println("異常終了: " + e.getMessage()); System.exit(2); }
    }

    private static void processRecord(String csvLine) throws Exception {
        String[] f = csvLine.split(",");
        if (f.length < 3) throw new IllegalArgumentException("フィールド数不足");
        int amount = Integer.parseInt(f[2].trim());
        if (amount < 0) throw new IllegalArgumentException("金額が負数: " + amount);
        System.out.println("処理: " + f[0].trim() + " / " + f[1].trim() + " / " + amount + "円");
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

var、テキストブロック、switch 式でコードが簡潔になる。

Java 17
// Java 17: ラムダ + var で簡潔に
RecordProcessor processor = record -> {
    var fields = record.split(",");
};

Library Comparison

Pure Java 自前フレームワークバッチ対象が数千〜数万件で外部依存を最小にしたい場合。チャンクコミットやジョブリスタートは自前で実装が必要。
Spring Batch数十万件以上の大量データ処理、チャンクトランザクション、再実行管理が必要な場合。Spring Boot + Spring Batch の依存が大幅に増える。
Quartz Scheduler + 自前バッチジョブのスケジューリングを Java プロセス内で完結させたい場合。Quartz はスケジューリング特化。バッチ構造は別途実装が必要。

注意点

この完成版はあくまで出発点。トランザクション管理やチャンク処理が必要な場合は各クラスに拡張を加えること

設定ファイルのパスやログ出力先は環境依存。本番ではシステムプロパティや環境変数で外部指定する構成にすること

CSV のエンコーディングは InputStreamReader で明示的に指定する

System.exit で終了コードを返す場合、finally や shutdown hook の実行に注意すること

各クラスは別ファイルに分割してパッケージ構成を整えること。1ファイルにまとめているのは連載の俯瞰用

FAQ

この完成版をそのまま本番で使えますか。

小〜中規模なら出発点として使えます。ログローテーション、終了コード管理、監視連携の追加を推奨します。

Spring Batch に移行する場合、この設計は活かせますか。

RecordProcessor は ItemProcessor に、ErrorPolicy は SkipPolicy に、RetryExecutor は RetryTemplate に対応します。

並列処理を追加するには。

ExecutorService でスレッドプールを作り submit する構成にします。ErrorHandler を AtomicInteger 等でスレッドセーフにします。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます