概要
バッチジョブが増えるたびに JAR を分けていると、ビルド成果物の管理やデプロイ手順が煩雑になります。ジョブごとに main クラスを用意して起動スクリプトを書き分ける運用は、10 本を超えたあたりから保守コストが無視できなくなります。この記事では、1 つの JAR ファイルに全ジョブをまとめ、起動引数で渡す Properties ファイルの中身だけで実行対象を切り替える BatchDispatcher を実装します。Properties の `job.class` キーに書かれた完全修飾クラス名を `Class.forName` で読み込み、リフレクションで BatchJob のインスタンスを生成し、SimpleBatchRunner に渡して実行します。新しいジョブを追加するときは BatchJob 実装クラスを書いて Properties ファイルを追加するだけで済み、Dispatcher 本体も Runner も触る必要がありません。JP1 や cron から呼ぶ場合も、引数の Properties ファイルパスを差し替えるだけです。
使いどころ
10 本以上のバッチジョブを 1 つの JAR にまとめ、JP1 のジョブネットから Properties ファイル指定で起動する
開発環境で複数ジョブの動作確認を 1 つの成果物で行い、ビルド・配布の手間を減らす
ジョブ追加時に既存コードを一切変更せず、新規クラスと Properties ファイルの追加だけでリリースする
コード例
import java.io.FileInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;
interface BatchJob {
void initialize(JobContext context) throws Exception;
ExitCode execute(JobContext context) throws Exception;
void terminate(JobContext context);
}
enum ExitCode {
SUCCESS(0), WARNING(1), ERROR(2);
private final int code;
ExitCode(int code) { this.code = code; }
public int getCode() { return code; }
}
class JobContext {
private final String jobName;
private final Properties properties;
private final Map<String, Object> attributes = new HashMap<String, Object>();
private long startTimeMillis;
public JobContext(String jobName, Properties properties) {
this.jobName = jobName;
this.properties = properties;
}
public String getJobName() { return jobName; }
public String getProperty(String key, String defaultValue) {
return properties.getProperty(key, defaultValue);
}
public void setAttribute(String key, Object value) { attributes.put(key, value); }
@SuppressWarnings("unchecked")
public <T> T getAttribute(String key, Class<T> type) {
Object v = attributes.get(key);
return (v != null && type.isInstance(v)) ? (T) v : null;
}
public long getStartTimeMillis() { return startTimeMillis; }
public void setStartTimeMillis(long v) { this.startTimeMillis = v; }
}
class SimpleBatchRunner {
private static final Logger LOGGER = Logger.getLogger(SimpleBatchRunner.class.getName());
public ExitCode run(BatchJob job, JobContext context) {
ExitCode exitCode = ExitCode.ERROR;
context.setStartTimeMillis(System.currentTimeMillis());
LOGGER.info("[" + context.getJobName() + "] ジョブ開始");
try {
job.initialize(context);
exitCode = job.execute(context);
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "[" + context.getJobName() + "] エラー", e);
} finally {
try { job.terminate(context); } catch (Exception e) {
LOGGER.log(Level.WARNING, "後処理エラー", e);
}
long elapsed = System.currentTimeMillis() - context.getStartTimeMillis();
LOGGER.info("[" + context.getJobName() + "] 終了 ("
+ elapsed + "ms) コード: " + exitCode.getCode());
}
return exitCode;
}
}
// ===== サンプルジョブ 1: CSV取込 =====
class CsvImportJob implements BatchJob {
@Override
public void initialize(JobContext ctx) throws Exception {
String inputFile = ctx.getProperty("input.file", "");
if (inputFile.isEmpty()) {
throw new IllegalArgumentException("input.file が設定されていません");
}
ctx.setAttribute("inputFile", inputFile);
}
@Override
public ExitCode execute(JobContext ctx) throws Exception {
String inputFile = ctx.getAttribute("inputFile", String.class);
int count = 0;
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.FileReader(inputFile));
try {
while (reader.readLine() != null) { count++; }
} finally { reader.close(); }
ctx.setAttribute("processedCount", Integer.valueOf(count));
return ExitCode.SUCCESS;
}
@Override
public void terminate(JobContext ctx) {
Integer count = ctx.getAttribute("processedCount", Integer.class);
System.out.println("CSV取込完了: " + (count != null ? count : 0) + " 件");
}
}
// ===== サンプルジョブ 2: レポート出力 =====
class ReportExportJob implements BatchJob {
@Override
public void initialize(JobContext ctx) throws Exception {
ctx.setAttribute("outputDir", ctx.getProperty("output.dir", "."));
}
@Override
public ExitCode execute(JobContext ctx) throws Exception {
String outputDir = ctx.getAttribute("outputDir", String.class);
String path = outputDir + "/report_" + System.currentTimeMillis() + ".txt";
java.io.FileWriter writer = new java.io.FileWriter(path);
try {
writer.write("日次売上レポート\n");
writer.write("生成日時: " + new java.util.Date() + "\n");
} finally { writer.close(); }
ctx.setAttribute("reportPath", path);
return ExitCode.SUCCESS;
}
@Override
public void terminate(JobContext ctx) {
String path = ctx.getAttribute("reportPath", String.class);
System.out.println("レポート出力完了: " + (path != null ? path : "未生成"));
}
}
// ===== ディスパッチャー本体 =====
public class BatchDispatcher {
private static final Logger LOGGER = Logger.getLogger(BatchDispatcher.class.getName());
public static void main(String[] args) {
if (args.length < 1) {
System.err.println("使い方: java -jar batch.jar <properties-file-path>");
System.err.println("例: java -jar batch.jar config/csv-import.properties");
System.exit(ExitCode.ERROR.getCode());
return;
}
// 1. Properties ファイル読み込み
String propsPath = args[0];
Properties props = new Properties();
FileInputStream fis = null;
try {
fis = new FileInputStream(propsPath);
props.load(fis);
} catch (IOException e) {
System.err.println("設定ファイル読込失敗: " + propsPath);
e.printStackTrace();
System.exit(ExitCode.ERROR.getCode());
return;
} finally {
if (fis != null) { try { fis.close(); } catch (IOException ignored) {} }
}
// 2. ジョブクラス名と表示名を取得
String jobClassName = props.getProperty("job.class");
String jobName = props.getProperty("job.name", "unknown");
if (jobClassName == null || jobClassName.trim().isEmpty()) {
System.err.println("job.class が未定義: " + propsPath);
System.exit(ExitCode.ERROR.getCode());
return;
}
LOGGER.info("ディスパッチ: job.class=" + jobClassName + ", job.name=" + jobName);
// 3. リフレクションで BatchJob インスタンスを生成
BatchJob job;
try {
Class<?> clazz = Class.forName(jobClassName.trim());
Object instance = clazz.getDeclaredConstructor().newInstance();
if (!(instance instanceof BatchJob)) {
System.err.println(jobClassName + " は BatchJob を実装していません");
System.exit(ExitCode.ERROR.getCode());
return;
}
job = (BatchJob) instance;
} catch (ClassNotFoundException e) {
System.err.println("クラスが見つかりません: " + jobClassName);
System.exit(ExitCode.ERROR.getCode());
return;
} catch (NoSuchMethodException e) {
System.err.println("引数なしコンストラクタがありません: " + jobClassName);
System.exit(ExitCode.ERROR.getCode());
return;
} catch (Exception e) {
System.err.println("インスタンス化失敗: " + jobClassName);
e.printStackTrace();
System.exit(ExitCode.ERROR.getCode());
return;
}
// 4. ジョブ実行
JobContext context = new JobContext(jobName, props);
SimpleBatchRunner runner = new SimpleBatchRunner();
ExitCode exitCode = runner.run(job, context);
LOGGER.info("ディスパッチ完了: " + jobName + " -> " + exitCode.name());
System.exit(exitCode.getCode());
}
}
/*
* === Properties ファイル例 ===
*
* --- config/csv-import.properties ---
* job.class=CsvImportJob
* job.name=日次CSV取込
* input.file=/data/import/daily_sales.csv
*
* --- config/report-export.properties ---
* job.class=ReportExportJob
* job.name=日次レポート出力
* output.dir=/data/reports
*
* === 起動例 ===
* java -jar batch.jar config/csv-import.properties
* java -jar batch.jar config/report-export.properties
*
* === cron 設定例 ===
* 0 2 * * * cd /opt/batch && java -jar batch.jar config/csv-import.properties
* 0 6 * * * cd /opt/batch && java -jar batch.jar config/report-export.properties
*/Version Coverage
ServiceLoader を使えばリフレクションなしでジョブを発見できる。モジュール境界を超える場合は module-info.java に opens が必要。
// Java 17: ServiceLoader でリフレクション不要
ServiceLoader<BatchJob> loader = ServiceLoader.load(BatchJob.class);
var job = loader.stream()
.filter(p -> p.type().getName().equals(className))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("未登録: " + className))
.get();Library Comparison
注意点
Class.forName はクラスパス上にクラスが存在しなければ ClassNotFoundException を投げる。fat JAR のビルド時に依存クラスが含まれているか確認すること
リフレクションで生成するクラスには引数なしの public コンストラクタが必要。private や引数付きだと InstantiationException になる
Properties の job.class に任意のクラス名を書けるため、BatchJob を実装していないクラスが指定される可能性がある。instanceof チェックを必ず入れること
本番環境では Properties ファイルのパスに絶対パスを使うか、実行ディレクトリを固定する運用ルールを設けること
Java 17 以降のモジュールシステムでは、対象クラスのパッケージが opens されている必要がある
FAQ
maven-shade-plugin や gradle の shadowJar が定番です。Main-Class に BatchDispatcher を指定すれば java -jar で起動できます。
Map にジョブ名とインスタンスを登録する方法が最も単純です。ただしジョブ追加のたびに Map の修正が必要になります。
可能ですが、ジョブ固有のパラメータも引数で管理することになり煩雑です。Properties にまとめる方が運用しやすいです。