概要
この記事は Java バッチ連載の最終回です。全体設計のインターフェース、基本構造、設定ファイル読み込み、リトライ処理、ログ設計、エラーハンドリングを一つの動作するバッチジョブとして統合します。設定ファイルからパラメータを読み込み、CSV を1行ずつ処理し、エラー戦略に従ってスキップまたは停止し、外部API呼び出しにはリトライを適用し、すべての経過をログに記録します。各クラスの役割と接続点を示し、連載全体の設計がどう組み合わさるかを確認できます。実務で自前のバッチフレームワークを構築する際の出発点として活用してください。
使いどころ
社内システムの CSV 取込バッチを新規開発する際の設計テンプレートとして使う
既存のバッチ処理をリファクタリングし、リトライ・ログ・エラーハンドリングを共通化する
Spring Batch を導入するほどではない小〜中規模バッチの自前フレームワークとして採用する
コード例
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 + "円");
}
}Version Coverage
var、テキストブロック、switch 式でコードが簡潔になる。
// Java 17: ラムダ + var で簡潔に
RecordProcessor processor = record -> {
var fields = record.split(",");
};Library Comparison
注意点
この完成版はあくまで出発点。トランザクション管理やチャンク処理が必要な場合は各クラスに拡張を加えること
設定ファイルのパスやログ出力先は環境依存。本番ではシステムプロパティや環境変数で外部指定する構成にすること
CSV のエンコーディングは InputStreamReader で明示的に指定する
System.exit で終了コードを返す場合、finally や shutdown hook の実行に注意すること
各クラスは別ファイルに分割してパッケージ構成を整えること。1ファイルにまとめているのは連載の俯瞰用
FAQ
小〜中規模なら出発点として使えます。ログローテーション、終了コード管理、監視連携の追加を推奨します。
RecordProcessor は ItemProcessor に、ErrorPolicy は SkipPolicy に、RetryExecutor は RetryTemplate に対応します。
ExecutorService でスレッドプールを作り submit する構成にします。ErrorHandler を AtomicInteger 等でスレッドセーフにします。